infigraph-cli 1.0.0

CLI for infigraph — AST-powered code analysis and impact review
use anyhow::{Context, Result};
use serde_json::json;

#[derive(Clone, Copy, PartialEq)]
pub(crate) enum ConfigFormat {
    Json,
    Toml,
}

pub(crate) struct AgentTarget {
    pub dir_name: &'static str,
    pub config_file: &'static str,
    pub format: ConfigFormat,
    pub label: &'static str,
}

pub(crate) const AGENT_TARGETS: &[AgentTarget] = &[
    AgentTarget {
        dir_name: ".claude",
        config_file: "CLAUDE_CODE_SPECIAL",
        format: ConfigFormat::Json,
        label: "Claude Code",
    },
    AgentTarget {
        dir_name: ".cursor",
        config_file: "mcp.json",
        format: ConfigFormat::Json,
        label: "Cursor",
    },
    AgentTarget {
        dir_name: ".vscode",
        config_file: "mcp.json",
        format: ConfigFormat::Json,
        label: "VS Code",
    },
    AgentTarget {
        dir_name: ".codex",
        config_file: "config.toml",
        format: ConfigFormat::Toml,
        label: "Codex",
    },
    AgentTarget {
        dir_name: ".gemini",
        config_file: "settings.json",
        format: ConfigFormat::Json,
        label: "Gemini CLI",
    },
    AgentTarget {
        dir_name: ".zed",
        config_file: "mcp.json",
        format: ConfigFormat::Json,
        label: "Zed",
    },
    AgentTarget {
        dir_name: ".opencode",
        config_file: "config.json",
        format: ConfigFormat::Json,
        label: "OpenCode",
    },
    AgentTarget {
        dir_name: ".aider",
        config_file: "mcp.json",
        format: ConfigFormat::Json,
        label: "Aider",
    },
    AgentTarget {
        dir_name: ".windsurf",
        config_file: "mcp.json",
        format: ConfigFormat::Json,
        label: "Windsurf",
    },
    AgentTarget {
        dir_name: ".kiro",
        config_file: "mcp.json",
        format: ConfigFormat::Json,
        label: "Kiro",
    },
    AgentTarget {
        dir_name: ".copilot",
        config_file: "mcp.json",
        format: ConfigFormat::Json,
        label: "GitHub Copilot CLI",
    },
];

pub(crate) fn install_json_target(config_path: &std::path::Path, mcp_path_str: &str) -> Result<()> {
    let mut config: serde_json::Value = if config_path.is_file() {
        let content = std::fs::read_to_string(config_path)
            .with_context(|| format!("Failed to read {}", config_path.display()))?;
        serde_json::from_str(&content)
            .with_context(|| format!("Failed to parse {}", config_path.display()))?
    } else {
        json!({})
    };

    if config.get("mcpServers").is_none() {
        config["mcpServers"] = json!({});
    }

    config["mcpServers"]["infigraph"] = json!({
        "command": mcp_path_str,
        "args": ["--ui", "--mcp", "--port=9749"]
    });

    let pretty = serde_json::to_string_pretty(&config)?;
    std::fs::write(config_path, pretty.as_bytes())
        .with_context(|| format!("Failed to write {}", config_path.display()))?;

    Ok(())
}

pub(crate) fn install_toml_target(config_path: &std::path::Path, mcp_path_str: &str) -> Result<()> {
    let existing = if config_path.is_file() {
        std::fs::read_to_string(config_path)
            .with_context(|| format!("Failed to read {}", config_path.display()))?
    } else {
        String::new()
    };

    let mcp_block = format!(
        "[mcp]\ninfigraph = {{ command = \"{}\", args = [\"--ui\", \"--mcp\", \"--port=9749\"] }}\n",
        mcp_path_str
    );

    let new_content = if existing.is_empty() {
        mcp_block
    } else if let Some(start) = existing.find("[mcp]") {
        let after_header = start + "[mcp]".len();
        let section_end = existing[after_header..]
            .find("\n[")
            .map(|pos| after_header + pos + 1)
            .unwrap_or(existing.len());
        format!(
            "{}{}{}",
            &existing[..start],
            mcp_block,
            &existing[section_end..]
        )
    } else {
        let sep = if existing.ends_with('\n') { "" } else { "\n" };
        format!("{}{}\n{}", existing, sep, mcp_block)
    };

    std::fs::write(config_path, new_content.as_bytes())
        .with_context(|| format!("Failed to write {}", config_path.display()))?;

    Ok(())
}

pub(crate) fn uninstall_json_target<'a>(
    config_path: &std::path::Path,
    label: &'a str,
) -> Result<Option<&'a str>> {
    if !config_path.is_file() {
        println!("  Skipping {} (no config found)", label);
        return Ok(None);
    }

    let content = std::fs::read_to_string(config_path)
        .with_context(|| format!("Failed to read {}", config_path.display()))?;
    let mut config: serde_json::Value = serde_json::from_str(&content)
        .with_context(|| format!("Failed to parse {}", config_path.display()))?;

    if let Some(servers) = config.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
        if servers.remove("infigraph").is_some() {
            let pretty = serde_json::to_string_pretty(&config)?;
            std::fs::write(config_path, pretty.as_bytes())
                .with_context(|| format!("Failed to write {}", config_path.display()))?;
            println!(
                "  Removed infigraph from {} ({})",
                label,
                config_path.display()
            );
            return Ok(Some(label));
        } else {
            println!("  Skipping {} (infigraph entry not found)", label);
        }
    } else {
        println!("  Skipping {} (no mcpServers in config)", label);
    }

    Ok(None)
}

pub(crate) fn uninstall_toml_target<'a>(
    config_path: &std::path::Path,
    label: &'a str,
) -> Result<Option<&'a str>> {
    if !config_path.is_file() {
        println!("  Skipping {} (no config found)", label);
        return Ok(None);
    }

    let content = std::fs::read_to_string(config_path)
        .with_context(|| format!("Failed to read {}", config_path.display()))?;

    if let Some(start) = content.find("[mcp]") {
        let after_header = start + "[mcp]".len();
        let section_end = content[after_header..]
            .find("\n[")
            .map(|pos| after_header + pos + 1)
            .unwrap_or(content.len());

        let section_text = &content[start..section_end];
        if section_text.contains("infigraph") {
            let new_content = format!("{}{}", &content[..start], &content[section_end..]);
            let trimmed = new_content.trim_end().to_string();
            let final_content = if trimmed.is_empty() {
                String::new()
            } else {
                format!("{}\n", trimmed)
            };
            std::fs::write(config_path, final_content.as_bytes())
                .with_context(|| format!("Failed to write {}", config_path.display()))?;
            println!(
                "  Removed infigraph from {} ({})",
                label,
                config_path.display()
            );
            return Ok(Some(label));
        } else {
            println!("  Skipping {} (infigraph entry not found in [mcp])", label);
        }
    } else {
        println!("  Skipping {} (no [mcp] section in config)", label);
    }

    Ok(None)
}