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";
fn detect_languages(path: &Path) -> Vec<String> {
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);
}
}
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()
}
fn pre_commit_has_feluda(content: &str) -> bool {
content.contains("feluda-license-check") || content.contains("entry: feluda")
}
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"
"#
)
}
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()
}
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")
}
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);
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());
}
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!();
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());
}
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);
}
}
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"), "<Project />").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());
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);
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();
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"));
}
}