diff --git a/src/cli.rs b/src/cli.rs index c204eef..5a15e4e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -117,6 +117,20 @@ pub enum Commands { #[arg(long)] clear: bool, }, + /// Initialise Feluda in the current project (generates .feluda.toml and .pre-commit-config.yaml) + Init { + /// Path to the local project directory + #[arg(short, long, default_value = "./")] + path: String, + + /// Overwrite existing config files without prompting + #[arg(long)] + force: bool, + + /// Skip creating or updating .pre-commit-config.yaml + #[arg(long)] + no_pre_commit: bool, + }, } #[derive(Parser, Debug, Clone)] @@ -538,10 +552,7 @@ mod tests { assert_eq!(language, Some("rust".to_string())); assert_eq!(project_license, Some("MIT".to_string())); } - Commands::Sbom { .. } => { - panic!("Expected Generate command"); - } - Commands::Cache { .. } => { + Commands::Sbom { .. } | Commands::Cache { .. } | Commands::Init { .. } => { panic!("Expected Generate command"); } } @@ -588,10 +599,7 @@ mod tests { assert_eq!(language, None); assert_eq!(project_license, None); } - Commands::Sbom { .. } => { - panic!("Expected Generate command"); - } - Commands::Cache { .. } => { + Commands::Sbom { .. } | Commands::Cache { .. } | Commands::Init { .. } => { panic!("Expected Generate command"); } } diff --git a/src/init.rs b/src/init.rs new file mode 100644 index 0000000..cd16855 --- /dev/null +++ b/src/init.rs @@ -0,0 +1,598 @@ +use crate::debug::{log, LogLevel}; +use crate::licenses::detect_project_license; +use colored::*; +use std::fs; +use std::io::{self, Write}; +use std::path::Path; + +const FELUDA_TOML: &str = ".feluda.toml"; +const PRE_COMMIT_YAML: &str = ".pre-commit-config.yaml"; + +/// Scan the project directory and return detected language names +fn detect_languages(path: &Path) -> Vec { + let mut detected: Vec<&'static str> = Vec::new(); + + let file_checks: &[(&str, &str)] = &[ + ("Cargo.toml", "Rust"), + ("package.json", "Node.js"), + ("go.mod", "Go"), + ("go.work", "Go"), + ("pyproject.toml", "Python"), + ("requirements.txt", "Python"), + ("Pipfile.lock", "Python"), + ("pip_freeze.txt", "Python"), + ("pom.xml", "Java/Maven"), + ("build.gradle", "Java/Gradle"), + ("build.gradle.kts", "Java/Gradle"), + ("CMakeLists.txt", "C++"), + ("vcpkg.json", "C++"), + ("conanfile.txt", "C++"), + ("conanfile.py", "C++"), + ("MODULE.bazel", "C++"), + ("configure.ac", "C"), + ("configure.in", "C"), + ("DESCRIPTION", "R"), + ("renv.lock", "R"), + ("composer.json", "PHP"), + ("composer.lock", "PHP"), + ("Gemfile", "Ruby"), + ("Gemfile.lock", "Ruby"), + ]; + + for (file, lang) in file_checks { + if path.join(file).exists() && !detected.contains(lang) { + detected.push(lang); + } + } + + // .NET: scan for project files by extension + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy().to_string(); + if (name_str.ends_with(".csproj") + || name_str.ends_with(".fsproj") + || name_str.ends_with(".vbproj") + || name_str.ends_with(".slnx")) + && !detected.contains(&"C#/.NET") + { + detected.push("C#/.NET"); + } + } + } + + detected.iter().map(|s| s.to_string()).collect() +} + +/// Check if the pre-commit YAML already contains a feluda hook +fn pre_commit_has_feluda(content: &str) -> bool { + content.contains("feluda-license-check") || content.contains("entry: feluda") +} + +/// Generate the content for .feluda.toml +fn generate_feluda_toml(project_license: Option<&str>) -> String { + let license_comment = match project_license { + Some(lic) => format!( + "# Project license detected: {lic}\n# Dependencies are checked for compatibility against this license.\n" + ), + None => "# Set your project license here for compatibility checking:\n# project_license = \"MIT\"\n".to_string(), + }; + + format!( + r#"# Feluda configuration — generated by `feluda init` +# Documentation: https://github.com/anistark/feluda + +{license_comment} +[licenses] +# Licenses flagged as restrictive. Dependencies using these will be highlighted. +# AI coding tools (Cursor, Copilot, Windsurf) can silently pull in GPL/AGPL deps — +# keeping this list tight catches those before they reach production. +restrictive = [ + "GPL-3.0", + "AGPL-3.0", + "LGPL-3.0", + "MPL-2.0", + "CC-BY-SA-4.0", + "EPL-2.0", +] + +# Licenses to skip from the scan entirely (e.g. internal or pre-approved deps). +ignore = [] + +[dependencies] +# Maximum depth for transitive dependency resolution (1–100). +max_depth = 10 + +# To exclude a specific dependency from scanning, uncomment and fill in: +# [[dependencies.ignore]] +# name = "some-package" +# version = "" # leave empty to ignore all versions +# reason = "Why this dependency is excluded" +"# + ) +} + +/// Generate a fresh .pre-commit-config.yaml with the feluda hook +fn generate_pre_commit_yaml() -> String { + r#"# .pre-commit-config.yaml — generated by `feluda init` +# Activate with: pre-commit install +# See https://pre-commit.com for more information. + +repos: + - repo: local + hooks: + - id: feluda-license-check + name: Feluda License Check + description: "Scan dependencies for restrictive or incompatible licenses (catches GPL/AGPL deps added by AI coding tools)" + language: system + entry: feluda + args: + - "--fail-on-restrictive" + pass_filenames: false + always_run: true +"# + .to_string() +} + +/// The block appended when merging into an existing .pre-commit-config.yaml +fn pre_commit_feluda_block() -> &'static str { + r#" + # Added by `feluda init` + - repo: local + hooks: + - id: feluda-license-check + name: Feluda License Check + description: "Scan dependencies for restrictive or incompatible licenses" + language: system + entry: feluda + args: + - "--fail-on-restrictive" + pass_filenames: false + always_run: true +"# +} + +fn write_feluda_toml(toml_path: &Path, project_license: Option<&str>) { + let content = generate_feluda_toml(project_license); + match fs::write(toml_path, &content) { + Ok(_) => println!( + " {} Created {}", + "✓".green().bold(), + FELUDA_TOML.bright_white() + ), + Err(e) => { + println!( + " {} Failed to write {}: {}", + "✗".red().bold(), + FELUDA_TOML, + e + ); + log( + LogLevel::Error, + &format!("Failed to write {FELUDA_TOML}: {e}"), + ); + } + } +} + +fn write_pre_commit_yaml(yaml_path: &Path) { + let content = generate_pre_commit_yaml(); + match fs::write(yaml_path, &content) { + Ok(_) => println!( + " {} Created {}", + "✓".green().bold(), + PRE_COMMIT_YAML.bright_white() + ), + Err(e) => { + println!( + " {} Failed to write {}: {}", + "✗".red().bold(), + PRE_COMMIT_YAML, + e + ); + log( + LogLevel::Error, + &format!("Failed to write {PRE_COMMIT_YAML}: {e}"), + ); + } + } +} + +fn merge_pre_commit_yaml(yaml_path: &Path) { + match fs::read_to_string(yaml_path) { + Ok(existing) => { + if pre_commit_has_feluda(&existing) { + println!( + " {} {} already contains a feluda hook — skipped.", + "ℹ".blue().bold(), + PRE_COMMIT_YAML + ); + } else { + let merged = format!("{}{}", existing.trim_end(), pre_commit_feluda_block()); + match fs::write(yaml_path, merged) { + Ok(_) => println!( + " {} Updated {} (feluda hook appended)", + "✓".green().bold(), + PRE_COMMIT_YAML.bright_white() + ), + Err(e) => { + println!( + " {} Failed to update {}: {}", + "✗".red().bold(), + PRE_COMMIT_YAML, + e + ); + log( + LogLevel::Error, + &format!("Failed to update {PRE_COMMIT_YAML}: {e}"), + ); + } + } + } + } + Err(e) => { + println!( + " {} Could not read {}: {}", + "✗".red().bold(), + PRE_COMMIT_YAML, + e + ); + } + } +} + +fn ask_yes_no(prompt: &str, default_yes: bool) -> bool { + let hint = if default_yes { "[Y/n]" } else { "[y/N]" }; + print!("{} {}: ", prompt, hint.dimmed()); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + let trimmed = input.trim().to_lowercase(); + + if trimmed.is_empty() { + return default_yes; + } + matches!(trimmed.as_str(), "y" | "yes") +} + +/// Entry point for `feluda init` +pub fn handle_init_command(path: String, force: bool, no_pre_commit: bool) { + log( + LogLevel::Info, + &format!("Starting init command at path: {path}"), + ); + + println!( + "\n{}", + "┌─────────────────────────────────────────────┐".bright_cyan() + ); + println!( + "{}", + "│ feluda init — project setup │" + .bright_cyan() + .bold() + ); + println!( + "{}", + "└─────────────────────────────────────────────┘".bright_cyan() + ); + println!(); + + let base_path = Path::new(&path); + + // ── Language detection ────────────────────────────────────────────────── + let languages = detect_languages(base_path); + if languages.is_empty() { + println!( + "{} {}", + "→".cyan(), + "No recognized project files found (defaults will be used).".dimmed() + ); + } else { + println!("{} Detected: {}", "→".cyan(), languages.join(", ").yellow()); + } + + // ── License detection ─────────────────────────────────────────────────── + let project_license = match detect_project_license(&path) { + Ok(Some(lic)) => { + println!("{} Project license: {}", "→".cyan(), lic.yellow()); + Some(lic) + } + _ => { + println!( + "{} {}", + "→".cyan(), + "Project license: not detected.".dimmed() + ); + None + } + }; + + println!(); + + // ── .feluda.toml ──────────────────────────────────────────────────────── + let toml_path = base_path.join(FELUDA_TOML); + + if toml_path.exists() && !force { + if ask_yes_no( + &format!( + "{} {} already exists. Overwrite?", + "⚠".yellow().bold(), + FELUDA_TOML + ), + false, + ) { + write_feluda_toml(&toml_path, project_license.as_deref()); + } else { + println!(" {} Skipped {}.", "·".dimmed(), FELUDA_TOML); + } + } else { + write_feluda_toml(&toml_path, project_license.as_deref()); + } + + // ── .pre-commit-config.yaml ───────────────────────────────────────────── + if !no_pre_commit { + let yaml_path = base_path.join(PRE_COMMIT_YAML); + + if yaml_path.exists() { + if force + || ask_yes_no( + &format!( + "{} {} exists. Add feluda hook?", + "→".cyan(), + PRE_COMMIT_YAML + ), + true, + ) + { + merge_pre_commit_yaml(&yaml_path); + } else { + println!(" {} Skipped {}.", "·".dimmed(), PRE_COMMIT_YAML); + } + } else if force || ask_yes_no(&format!("{} Create {}?", "→".cyan(), PRE_COMMIT_YAML), true) + { + write_pre_commit_yaml(&yaml_path); + } else { + println!(" {} Skipped {}.", "·".dimmed(), PRE_COMMIT_YAML); + } + } + + // ── Next steps ────────────────────────────────────────────────────────── + println!(); + println!("{}", "Next steps:".bold()); + println!( + " {} Run {} to scan your project", + "1.".dimmed(), + "feluda".bright_white() + ); + if !no_pre_commit { + println!( + " {} Run {} to activate the pre-commit hook", + "2.".dimmed(), + "pre-commit install".bright_white() + ); + println!( + " {} Edit {} to customise restrictive licenses or add ignore rules", + "3.".dimmed(), + FELUDA_TOML.bright_white() + ); + } else { + println!( + " {} Edit {} to customise restrictive licenses or add ignore rules", + "2.".dimmed(), + FELUDA_TOML.bright_white() + ); + } + println!(); + println!("{}", "Docs: https://github.com/anistark/feluda".dimmed()); + println!(); + + log(LogLevel::Info, "Init command completed"); +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_detect_languages_empty_dir() { + let dir = TempDir::new().unwrap(); + let langs = detect_languages(dir.path()); + assert!(langs.is_empty()); + } + + #[test] + fn test_detect_languages_rust() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); + let langs = detect_languages(dir.path()); + assert!(langs.contains(&"Rust".to_string())); + } + + #[test] + fn test_detect_languages_node() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + let langs = detect_languages(dir.path()); + assert!(langs.contains(&"Node.js".to_string())); + } + + #[test] + fn test_detect_languages_python() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("requirements.txt"), "requests==2.0").unwrap(); + let langs = detect_languages(dir.path()); + assert!(langs.contains(&"Python".to_string())); + } + + #[test] + fn test_detect_languages_go() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/m").unwrap(); + let langs = detect_languages(dir.path()); + assert!(langs.contains(&"Go".to_string())); + } + + #[test] + fn test_detect_languages_dotnet() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("MyApp.csproj"), "").unwrap(); + let langs = detect_languages(dir.path()); + assert!(langs.contains(&"C#/.NET".to_string())); + } + + #[test] + fn test_detect_languages_multi() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"t\"").unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + let langs = detect_languages(dir.path()); + assert!(langs.contains(&"Rust".to_string())); + assert!(langs.contains(&"Node.js".to_string())); + } + + #[test] + fn test_go_deduplication() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("go.mod"), "module x").unwrap(); + fs::write(dir.path().join("go.work"), "go 1.21").unwrap(); + let langs = detect_languages(dir.path()); + // Go should appear exactly once + assert_eq!(langs.iter().filter(|l| l.as_str() == "Go").count(), 1); + } + + #[test] + fn test_pre_commit_has_feluda_true() { + let content = "repos:\n - repo: local\n hooks:\n - id: feluda-license-check\n"; + assert!(pre_commit_has_feluda(content)); + } + + #[test] + fn test_pre_commit_has_feluda_false() { + let content = "repos:\n - repo: local\n hooks:\n - id: other-hook\n"; + assert!(!pre_commit_has_feluda(content)); + } + + #[test] + fn test_generate_feluda_toml_with_license() { + let content = generate_feluda_toml(Some("MIT")); + assert!(content.contains("MIT")); + assert!(content.contains("GPL-3.0")); + assert!(content.contains("AGPL-3.0")); + assert!(content.contains("restrictive")); + assert!(content.contains("max_depth")); + } + + #[test] + fn test_generate_feluda_toml_without_license() { + let content = generate_feluda_toml(None); + assert!(content.contains("project_license")); + assert!(content.contains("GPL-3.0")); + } + + #[test] + fn test_generate_pre_commit_yaml() { + let content = generate_pre_commit_yaml(); + assert!(content.contains("feluda-license-check")); + assert!(content.contains("entry: feluda")); + assert!(content.contains("--fail-on-restrictive")); + assert!(content.contains("pass_filenames: false")); + } + + #[test] + fn test_write_feluda_toml_creates_file() { + let dir = TempDir::new().unwrap(); + let toml_path = dir.path().join(FELUDA_TOML); + write_feluda_toml(&toml_path, Some("Apache-2.0")); + assert!(toml_path.exists()); + let content = fs::read_to_string(&toml_path).unwrap(); + assert!(content.contains("Apache-2.0")); + assert!(content.contains("GPL-3.0")); + } + + #[test] + fn test_write_pre_commit_yaml_creates_file() { + let dir = TempDir::new().unwrap(); + let yaml_path = dir.path().join(PRE_COMMIT_YAML); + write_pre_commit_yaml(&yaml_path); + assert!(yaml_path.exists()); + let content = fs::read_to_string(&yaml_path).unwrap(); + assert!(content.contains("feluda-license-check")); + } + + #[test] + fn test_merge_pre_commit_yaml_adds_hook() { + let dir = TempDir::new().unwrap(); + let yaml_path = dir.path().join(PRE_COMMIT_YAML); + fs::write( + &yaml_path, + "repos:\n - repo: local\n hooks:\n - id: other-hook\n entry: other\n", + ) + .unwrap(); + merge_pre_commit_yaml(&yaml_path); + let content = fs::read_to_string(&yaml_path).unwrap(); + assert!(content.contains("feluda-license-check")); + assert!(content.contains("other-hook")); + } + + #[test] + fn test_merge_pre_commit_yaml_skips_if_present() { + let dir = TempDir::new().unwrap(); + let yaml_path = dir.path().join(PRE_COMMIT_YAML); + let original = "repos:\n - repo: local\n hooks:\n - id: feluda-license-check\n entry: feluda\n"; + fs::write(&yaml_path, original).unwrap(); + merge_pre_commit_yaml(&yaml_path); + // File should be unchanged + let content = fs::read_to_string(&yaml_path).unwrap(); + assert_eq!(content, original); + } + + #[test] + fn test_handle_init_command_no_pre_commit() { + let dir = TempDir::new().unwrap(); + let path = dir.path().to_str().unwrap().to_string(); + // Should not panic; no interactive prompts triggered since force=true + handle_init_command(path, true, true); + assert!(dir.path().join(FELUDA_TOML).exists()); + assert!(!dir.path().join(PRE_COMMIT_YAML).exists()); + } + + #[test] + fn test_handle_init_command_force_creates_both() { + let dir = TempDir::new().unwrap(); + let path = dir.path().to_str().unwrap().to_string(); + handle_init_command(path, true, false); + assert!(dir.path().join(FELUDA_TOML).exists()); + assert!(dir.path().join(PRE_COMMIT_YAML).exists()); + } + + #[test] + fn test_handle_init_command_force_overwrites_toml() { + let dir = TempDir::new().unwrap(); + let path = dir.path().to_str().unwrap().to_string(); + let toml_path = dir.path().join(FELUDA_TOML); + fs::write(&toml_path, "old content").unwrap(); + handle_init_command(path, true, true); + let content = fs::read_to_string(&toml_path).unwrap(); + assert!(!content.contains("old content")); + assert!(content.contains("GPL-3.0")); + } + + #[test] + fn test_handle_init_command_force_merges_pre_commit() { + let dir = TempDir::new().unwrap(); + let path = dir.path().to_str().unwrap().to_string(); + let yaml_path = dir.path().join(PRE_COMMIT_YAML); + fs::write( + &yaml_path, + "repos:\n - repo: local\n hooks:\n - id: other-hook\n", + ) + .unwrap(); + handle_init_command(path, true, false); + let content = fs::read_to_string(&yaml_path).unwrap(); + assert!(content.contains("feluda-license-check")); + assert!(content.contains("other-hook")); + } +} diff --git a/src/main.rs b/src/main.rs index 02e468a..f8af16c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod config; mod debug; mod generate; +mod init; mod languages; mod licenses; mod parser; @@ -16,6 +17,7 @@ use clap::Parser; use cli::{print_version_info, Cli, Commands}; use debug::{log, log_debug, set_debug_mode, FeludaError, FeludaResult, LogLevel}; use generate::handle_generate_command; +use init::handle_init_command; use licenses::{ detect_project_license, is_license_compatible, set_github_token, LicenseCompatibility, }; @@ -204,6 +206,14 @@ fn run() -> FeludaResult<()> { handle_cache_command(clear)?; Ok(()) } + Commands::Init { + path, + force, + no_pre_commit, + } => { + handle_init_command(path, force, no_pre_commit); + Ok(()) + } } } }