ccgo 3.7.2

A high-performance C++ cross-platform build CLI
//! Idempotently update IDE and VCS ignore / exclude configs so that
//! ccgo-generated directories don't pollute file explorers, search results,
//! or version control.
//!
//! Targets:
//! * `.gitignore`             — prevents accidental commits
//! * `.vscode/settings.json`  — `files.exclude` + `search.exclude`
//! * `.idea/*.iml`            — `<excludeFolder>` entries for JetBrains IDEs

use std::fs;
use std::io::Write;
use std::path::Path;

use anyhow::Result;

/// Directories that ccgo generates and that should be hidden from IDEs / git.
const IGNORE_DIRS: &[&str] = &[".ccgo", "cmake_build"];

/// Update `.gitignore`, `.vscode/settings.json`, and `.idea/*.iml` for the
/// given project root. All operations are idempotent — already-present entries
/// are never duplicated.
pub fn update_ide_ignores(project_dir: &Path) -> Result<()> {
    update_gitignore(project_dir)?;
    update_vscode(project_dir)?;
    update_jetbrains(project_dir)?;
    Ok(())
}

// ─── .gitignore ──────────────────────────────────────────────────────────────

fn update_gitignore(project_dir: &Path) -> Result<()> {
    let path = project_dir.join(".gitignore");
    let existing = if path.exists() {
        fs::read_to_string(&path)?
    } else {
        String::new()
    };

    // A dir is already covered if either "dir" or "dir/" appears in the file.
    let to_add: Vec<&str> = IGNORE_DIRS
        .iter()
        .filter(|&&dir| {
            !existing.contains(&format!("{dir}/"))
                && !existing.split_whitespace().any(|token| token == dir)
        })
        .copied()
        .collect();

    if to_add.is_empty() {
        return Ok(());
    }

    let mut file = if path.exists() {
        fs::OpenOptions::new().append(true).open(&path)?
    } else {
        fs::File::create(&path)?
    };

    writeln!(file, "\n# Generated by ccgo")?;
    for dir in &to_add {
        writeln!(file, "{dir}/")?;
        println!("   Added {dir}/ to .gitignore");
    }

    Ok(())
}

// ─── .vscode/settings.json ───────────────────────────────────────────────────

fn update_vscode(project_dir: &Path) -> Result<()> {
    let vscode_dir = project_dir.join(".vscode");
    let settings_path = vscode_dir.join("settings.json");

    let mut settings: serde_json::Value = if settings_path.exists() {
        let content = fs::read_to_string(&settings_path)?;
        match serde_json::from_str(&content) {
            Ok(v) => v,
            // VS Code settings may contain comments or trailing commas (JSONC).
            // Skip rather than overwrite a file we can't safely round-trip.
            Err(_) => return Ok(()),
        }
    } else {
        serde_json::json!({})
    };

    let obj = match settings.as_object_mut() {
        Some(o) => o,
        None => return Ok(()),
    };

    let mut changed = false;
    for section in &["files.exclude", "search.exclude"] {
        let map = obj
            .entry(*section)
            .or_insert_with(|| serde_json::json!({}));
        if let Some(map) = map.as_object_mut() {
            for dir in IGNORE_DIRS {
                if !map.contains_key(*dir) {
                    map.insert((*dir).to_string(), serde_json::Value::Bool(true));
                    changed = true;
                }
            }
        }
    }

    if !changed {
        return Ok(());
    }

    fs::create_dir_all(&vscode_dir)?;
    let json = serde_json::to_string_pretty(&settings)?;
    // serde_json::to_string_pretty omits a trailing newline; add one.
    fs::write(&settings_path, format!("{json}\n"))?;
    println!("   Updated .vscode/settings.json (files.exclude / search.exclude)");

    Ok(())
}

// ─── .idea/*.iml ─────────────────────────────────────────────────────────────

fn update_jetbrains(project_dir: &Path) -> Result<()> {
    let idea_dir = project_dir.join(".idea");
    if !idea_dir.is_dir() {
        return Ok(());
    }

    let iml_files: Vec<_> = fs::read_dir(&idea_dir)?
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().map(|x| x == "iml").unwrap_or(false))
        .collect();

    for entry in iml_files {
        let path = entry.path();
        let content = fs::read_to_string(&path)?;

        let to_add: Vec<&str> = IGNORE_DIRS
            .iter()
            .filter(|&&dir| !content.contains(&format!("$MODULE_DIR$/{dir}")))
            .copied()
            .collect();

        if to_add.is_empty() {
            continue;
        }

        // Find the closing </content> tag and insert <excludeFolder> lines
        // just before it, matching the indentation of the closing tag.
        let Some(close_pos) = content.find("</content>") else {
            // No <content> block present — nothing to do for this file.
            continue;
        };

        // Determine the whitespace prefix of the </content> line so our
        // new entries use the same indentation level.
        let close_indent = content[..close_pos]
            .rfind('\n')
            .map(|nl| {
                let after = &content[nl + 1..close_pos];
                let ws = after.len() - after.trim_start().len();
                " ".repeat(ws)
            })
            .unwrap_or_else(|| "    ".to_string());

        let inner_indent = format!("{close_indent}  ");
        let insert: String = to_add
            .iter()
            .map(|dir| {
                format!(
                    "{inner_indent}<excludeFolder url=\"file://$MODULE_DIR$/{dir}\" />\n"
                )
            })
            .collect();

        let new_content =
            format!("{}{insert}{close_indent}</content>{}", &content[..close_pos], &content[close_pos + "</content>".len()..]);

        fs::write(&path, new_content)?;
        println!(
            "   Updated {} (excludeFolder for {})",
            path.display(),
            to_add.join(", ")
        );
    }

    Ok(())
}