repopilot 0.20.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use crate::cli::{InitOptions, McpClientArg};
use repopilot::config::template::default_config_toml;
use std::fs;
use std::path::{Path, PathBuf};

const ACTION_PATH: &str = ".github/workflows/repopilot-review.yml";
const MCP_DIR: &str = ".repopilot/bootstrap";

pub fn run(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
    let root = std::env::current_dir()?;
    run_at(options, &root)
}

fn run_at(options: InitOptions, root: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let generate_action = options.github_action || options.all;
    let mcp_client = options
        .mcp_client
        .or(options.all.then_some(McpClientArg::Generic));

    let config = default_config_toml();
    write_owned_file(&options.path, &config, options.force, "RepoPilot config")?;

    if generate_action {
        write_owned_file(
            &root.join(ACTION_PATH),
            &github_action_workflow(),
            options.force,
            "GitHub Actions workflow",
        )?;
    }

    if let Some(client) = mcp_client {
        let path = root.join(mcp_output_path(client));
        write_owned_file(
            &path,
            &mcp_bootstrap(client),
            options.force,
            "MCP bootstrap",
        )?;
    }

    print_next_steps(&options.path, generate_action, mcp_client);
    Ok(())
}

fn write_owned_file(
    path: &Path,
    content: &str,
    force: bool,
    label: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    if path.exists() && !force {
        println!(
            "{label} already exists at {}. Use `repopilot init --force` to overwrite RepoPilot-owned generated files.",
            path.display()
        );
        return Ok(());
    }

    if let Some(parent) = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, content)?;
    println!("Created {label} at {}", path.display());
    Ok(())
}

fn github_action_workflow() -> String {
    format!(
        r#"name: RepoPilot review

on:
  pull_request:

permissions:
  contents: read
  pull-requests: write
  security-events: write

jobs:
  repopilot:
    uses: MykytaStel/repopilot/.github/workflows/repopilot-pr-review.yml@v{}
    with:
      fail-on-review: none
      upload-sarif: true
"#,
        env!("CARGO_PKG_VERSION")
    )
}

fn mcp_output_path(client: McpClientArg) -> PathBuf {
    let name = match client {
        McpClientArg::Claude => "claude.json",
        McpClientArg::Cursor => "cursor.json",
        McpClientArg::Generic => "generic.json",
    };
    Path::new(MCP_DIR).join(name)
}

fn mcp_bootstrap(client: McpClientArg) -> String {
    match client {
        McpClientArg::Claude => r#"{
  "registration_command": "claude mcp add repopilot -- repopilot mcp --root .",
  "note": "Run the registration command from the repository root."
}
"#
        .to_string(),
        McpClientArg::Cursor => r#"{
  "mcpServers": {
    "repopilot": {
      "command": "repopilot",
      "args": ["mcp", "--root", "."]
    }
  },
  "note": "Copy this server entry into the MCP configuration used by Cursor."
}
"#
        .to_string(),
        McpClientArg::Generic => r#"{
  "mcpServers": {
    "repopilot": {
      "command": "repopilot",
      "args": ["mcp", "--root", "."]
    }
  }
}
"#
        .to_string(),
    }
}

fn print_next_steps(config: &Path, action: bool, mcp: Option<McpClientArg>) {
    println!();
    println!("Next:");
    println!("  repopilot review .");
    println!("  repopilot scan . --config {}", config.display());

    if action {
        println!("  git add {ACTION_PATH}");
        println!("  open a pull request to verify the generated RepoPilot workflow");
    }
    if let Some(client) = mcp {
        println!("  review {}", mcp_output_path(client).display());
        println!("  copy or register the MCP entry in your client explicitly");
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    fn options(root: &Path) -> InitOptions {
        InitOptions {
            force: false,
            path: root.join("repopilot.toml"),
            github_action: false,
            mcp_client: None,
            all: false,
        }
    }

    #[test]
    fn init_generates_config_action_and_generic_mcp_bootstrap() {
        let temp = tempdir().expect("temp dir");
        let mut options = options(temp.path());
        options.github_action = true;
        options.mcp_client = Some(McpClientArg::Generic);

        run_at(options, temp.path()).expect("init succeeds");

        assert!(temp.path().join("repopilot.toml").is_file());
        let action = fs::read_to_string(temp.path().join(ACTION_PATH)).expect("action");
        assert!(action.contains(&format!(
            "repopilot-pr-review.yml@v{}",
            env!("CARGO_PKG_VERSION")
        )));
        assert!(action.contains("upload-sarif: true"));

        let mcp = fs::read_to_string(temp.path().join(MCP_DIR).join("generic.json")).expect("mcp");
        assert!(mcp.contains("\"repopilot\""));
        assert!(mcp.contains("\"--root\""));
    }

    #[test]
    fn init_preserves_existing_generated_files_without_force() {
        let temp = tempdir().expect("temp dir");
        let path = temp.path().join("repopilot.toml");
        fs::write(&path, "custom = true\n").expect("existing config");

        run_at(options(temp.path()), temp.path()).expect("init succeeds");

        assert_eq!(fs::read_to_string(path).expect("config"), "custom = true\n");
    }

    #[test]
    fn force_replaces_existing_config() {
        let temp = tempdir().expect("temp dir");
        let path = temp.path().join("repopilot.toml");
        fs::write(&path, "custom = true\n").expect("existing config");
        let mut options = options(temp.path());
        options.force = true;

        run_at(options, temp.path()).expect("init succeeds");

        assert_ne!(fs::read_to_string(path).expect("config"), "custom = true\n");
    }

    #[test]
    fn every_mcp_bootstrap_is_valid_json_and_launches_or_registers_repopilot() {
        for client in [
            McpClientArg::Claude,
            McpClientArg::Cursor,
            McpClientArg::Generic,
        ] {
            let value: serde_json::Value =
                serde_json::from_str(&mcp_bootstrap(client)).expect("valid bootstrap JSON");
            let rendered = value.to_string();
            assert!(rendered.contains("repopilot"));
            assert!(rendered.contains("mcp"));
        }
    }
}