uncomment 3.3.0

A CLI tool to remove comments from code using tree-sitter for accurate parsing
use anyhow::{Context, anyhow};
use saphyr::{LoadableYamlNode, Yaml};
use std::{
    fs,
    io::Write,
    path::{Path, PathBuf},
    process::Command,
};
use walkdir::WalkDir;

const EXTENSIONS: &[(&str, &str)] = &[(".js", "js"), (".ts", "ts"), (".py", "py")];

struct RepoList {
    repos: Vec<RepoEntry>,
}

struct RepoEntry {
    url: String,
}

#[test]
#[ignore = "Network dependent test - run manually with --ignored"]
fn integration_test_uncomment_on_real_repos() {
    let repos = read_repos_yaml("fixtures/repos/repos.yaml").expect("Failed to read repos.yaml");
    let work_dir = Path::new("tests/integration_test/repos_cache");
    fs::create_dir_all(work_dir).expect("Failed to create cache directory");

    let mut total_files = 0;
    let mut failed_files = Vec::new();
    let mut skipped_files = Vec::new();

    for repo in repos.repos {
        println!("\n=== Processing repo: {} ===", repo.url);
        let repo_name = repo.url.rsplit('/').next().unwrap_or(&repo.url).replace(".git", "");
        let repo_path = work_dir.join(&repo_name);

        if !repo_path.exists() {
            println!("Cloning {}...", repo.url);
            assert!(clone_repo(&repo.url, &repo_path), "Failed to clone {}", repo.url);
            assert!(
                wait_for_files_to_stabilize(&repo_path, 10),
                "Repo files did not stabilize after clone"
            );
        } else {
            println!("Repo already cloned: {}", repo_name);
        }

        let files = find_source_files(&repo_path);
        println!("Found {} source files", files.len());

        for file in files {
            if !file.exists() {
                eprintln!("  [SKIP] File does not exist: {}", file.display());
                skipped_files.push(file.display().to_string());
                continue;
            }
            total_files += 1;
            println!("Testing file: {}", file.display());
            let lang = file
                .extension()
                .and_then(|ext| ext.to_str())
                .and_then(|ext| {
                    EXTENSIONS
                        .iter()
                        .find(|(e, _)| *e == format!(".{ext}"))
                        .map(|(_, l)| *l)
                })
                .unwrap_or("unknown");

            if !parse_ast(&file, lang) {
                eprintln!("  [SKIP] Could not parse AST before uncomment: {}", file.display());
                skipped_files.push(file.display().to_string());
                continue;
            }

            assert!(run_uncomment(&file), "[FAIL] Uncomment failed: {}", file.display());

            if !parse_ast(&file, lang) {
                eprintln!("  [FAIL] AST parse failed after uncomment: {}", file.display());
                failed_files.push(file.display().to_string());
            } else {
                println!("  [PASS]");
            }
        }
    }

    if !skipped_files.is_empty() {
        eprintln!("\nThe following files were skipped (could not parse before uncomment):");
        for f in &skipped_files {
            eprintln!("  - {}", f);
        }
    }

    if !failed_files.is_empty() {
        let mut file = std::fs::File::create("fixtures/failing_files.txt").expect("Could not create failing_files.txt");
        for f in &failed_files {
            writeln!(file, "{}", f).expect("Could not write to failing_files.txt");
        }
    }

    if !failed_files.is_empty() {
        eprintln!("\nThe following files failed AST parsing after uncomment:");
        for f in &failed_files {
            eprintln!("  - {}", f);
        }
    }
    assert!(
        failed_files.is_empty(),
        "{} files failed AST parsing after uncomment",
        failed_files.len()
    );
    assert!(total_files > 0, "No source files were tested");
}

fn read_repos_yaml<P: AsRef<Path>>(path: P) -> anyhow::Result<RepoList> {
    let content = fs::read_to_string(path)?;
    let docs = Yaml::load_from_str(&content).context("Failed to parse YAML repo list")?;
    let doc = docs
        .first()
        .ok_or_else(|| anyhow!("repos.yaml does not contain any documents"))?;

    let repos_yaml = doc["repos"]
        .as_vec()
        .ok_or_else(|| anyhow!("repos.yaml missing 'repos' array"))?;

    let repos = repos_yaml
        .iter()
        .map(|entry| {
            let url = entry["url"]
                .as_str()
                .ok_or_else(|| anyhow!("repo entry missing 'url' field"))?;
            Ok(RepoEntry { url: url.to_string() })
        })
        .collect::<anyhow::Result<Vec<_>>>()?;

    Ok(RepoList { repos })
}

fn clone_repo(repo_url: &str, dest: &Path) -> bool {
    Command::new("git")
        .args(["clone", "--depth=1", repo_url, dest.to_str().unwrap()])
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

fn find_source_files(dir: &Path) -> Vec<PathBuf> {
    let mut files = Vec::new();
    for entry in WalkDir::new(dir).into_iter().filter_map(Result::ok) {
        if entry.file_type().is_file() {
            let path = entry.path();
            if EXTENSIONS
                .iter()
                .any(|(ext, _)| path.extension().is_some_and(|e| e == &ext[1..]))
            {
                files.push(path.to_path_buf());
            }
        }
    }
    files
}

fn parse_ast(file: &Path, lang: &str) -> bool {
    match lang {
        "py" => parse_python_ast(file),
        "js" | "ts" => parse_js_ts_ast(file),
        _ => false,
    }
}

fn parse_python_ast(file: &Path) -> bool {
    let code = "import ast, sys; ast.parse(open(sys.argv[1]).read())";
    let output = Command::new("python3").arg("-c").arg(code).arg(file).output();

    match output {
        Ok(out) if out.status.success() => true,
        Ok(out) => {
            eprintln!("    [PY AST ERROR] {}", String::from_utf8_lossy(&out.stderr));
            false
        }
        Err(e) => {
            eprintln!("    [PY AST ERROR] {e}");
            false
        }
    }
}

fn parse_js_ts_ast(file: &Path) -> bool {
    let code = r#"
        const fs = require('fs');
        const esprima = require('esprima');
        try {
            esprima.parseScript(fs.readFileSync(process.argv[1], 'utf8'));
        } catch (e) {
            console.error(e.message);
            process.exit(1);
        }
    "#;
    let output = Command::new("node").arg("-e").arg(code).arg(file).output();

    match output {
        Ok(out) if out.status.success() => true,
        Ok(out) => {
            eprintln!("    [JS/TS AST ERROR] {}", String::from_utf8_lossy(&out.stderr));
            false
        }
        Err(e) => {
            eprintln!("    [JS/TS AST ERROR] {e}");
            false
        }
    }
}

fn run_uncomment(file: &Path) -> bool {
    let output = Command::new("uncomment").arg(file).output();

    match output {
        Ok(out) if out.status.success() => true,
        Ok(out) => {
            eprintln!("    [UNCOMMENT ERROR] {}", String::from_utf8_lossy(&out.stderr));
            false
        }
        Err(e) => {
            eprintln!("    [UNCOMMENT ERROR] {e}");
            false
        }
    }
}

fn wait_for_files_to_stabilize(dir: &Path, timeout_secs: u64) -> bool {
    use std::time::Instant;
    let start = Instant::now();
    let mut last_count = 0;
    loop {
        let count = walkdir::WalkDir::new(dir)
            .into_iter()
            .filter_map(Result::ok)
            .filter(|e| e.file_type().is_file())
            .count();
        if count == last_count && count > 0 {
            return true;
        }
        last_count = count;
        if start.elapsed().as_secs() > timeout_secs {
            return false;
        }
        std::thread::sleep(std::time::Duration::from_millis(200));
    }
}