draxl-cli 0.1.1

Command-line interface for Draxl
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

const GENERATED_HEADER: &str = "# Generated by `draxl mcp setup --client codex`.\n";
const APPROVED_READ_TOOLS: &[&str] = &[
    "draxl_inspect_file",
    "draxl_get_node",
    "draxl_check_conflicts",
];
const CODEX_DEVELOPER_INSTRUCTIONS: &str = r#"This workspace is configured for Draxl MCP editing.

Read files and inspect code normally.
Use the `draxl_*` MCP tools for edits to `.rs.dx` files.
Do not modify `.rs.dx` files directly.
When a Draxl tool fails, report the failure instead of falling back to direct file edits for that file.
Start by calling `draxl_inspect_file` on the target `.rs.dx` file.
Pass `expected_fingerprint` on mutating tool calls when it is available."#;

pub(crate) fn run_setup<I>(args: I) -> Result<(), String>
where
    I: IntoIterator<Item = String>,
{
    let options = SetupOptions::parse(args)?;
    let root = canonicalize_existing_dir(
        options.root.unwrap_or(
            env::current_dir()
                .map_err(|err| format!("failed to determine current directory: {err}"))?,
        ),
    )?;
    let draxl_exe = env::current_exe()
        .map_err(|err| format!("failed to determine draxl executable path: {err}"))?
        .canonicalize()
        .map_err(|err| format!("failed to canonicalize draxl executable path: {err}"))?;
    let config = build_codex_config(&draxl_exe, &root);

    if options.print {
        print!("{config}");
        return Ok(());
    }

    let codex_dir = root.join(".codex");
    let config_path = codex_dir.join("config.toml");
    if config_path.exists() {
        let existing = fs::read_to_string(&config_path)
            .map_err(|err| format!("failed to read {}: {err}", config_path.display()))?;
        if !options.force && !is_managed_config(&existing) {
            return Err(format!(
                "refusing to overwrite unmanaged Codex config at {}. Re-run with `--force` or use `--print`.",
                config_path.display()
            ));
        }
    }

    fs::create_dir_all(&codex_dir)
        .map_err(|err| format!("failed to create {}: {err}", codex_dir.display()))?;
    fs::write(&config_path, &config)
        .map_err(|err| format!("failed to write {}: {err}", config_path.display()))?;
    println!("wrote {}", config_path.display());

    if let Some(updated_exclude) = ensure_git_exclude(&root)? {
        println!("updated {}", updated_exclude.display());
    }

    Ok(())
}

pub(crate) fn setup_usage() -> &'static str {
    "usage:
  draxl mcp setup --client codex [--root <workspace>] [--print] [--force]"
}

#[derive(Debug, Default)]
struct SetupOptions {
    client: Option<String>,
    root: Option<PathBuf>,
    print: bool,
    force: bool,
}

impl SetupOptions {
    fn parse<I>(args: I) -> Result<Self, String>
    where
        I: IntoIterator<Item = String>,
    {
        let mut options = Self::default();
        let mut args = args.into_iter();
        while let Some(arg) = args.next() {
            match arg.as_str() {
                "--client" => {
                    let Some(client) = args.next() else {
                        return Err(format!("missing value for `--client`\n\n{}", setup_usage()));
                    };
                    options.client = Some(client);
                }
                "--root" => {
                    let Some(root) = args.next() else {
                        return Err(format!("missing value for `--root`\n\n{}", setup_usage()));
                    };
                    options.root = Some(PathBuf::from(root));
                }
                "--print" => options.print = true,
                "--force" => options.force = true,
                _ => return Err(format!("unknown argument `{arg}`\n\n{}", setup_usage())),
            }
        }

        match options.client.as_deref() {
            Some("codex") => Ok(options),
            Some(other) => Err(format!(
                "unsupported client `{other}`; expected `codex`\n\n{}",
                setup_usage()
            )),
            None => Err(format!("missing `--client codex`\n\n{}", setup_usage())),
        }
    }
}

fn build_codex_config(draxl_exe: &Path, root: &Path) -> String {
    let root_text = root.display().to_string();
    let draxl_exe_text = draxl_exe.display().to_string();
    let args = ["mcp", "serve", "--root", root_text.as_str()]
        .into_iter()
        .map(toml_string)
        .collect::<Vec<_>>()
        .join(", ");
    let tool_sections = APPROVED_READ_TOOLS
        .iter()
        .map(|tool| format!("[mcp_servers.draxl.tools.{tool}]\napproval_mode = \"approve\"\n"))
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        r#"{GENERATED_HEADER}sandbox_mode = "read-only"
approval_policy = "on-request"
allow_login_shell = false
developer_instructions = """
{CODEX_DEVELOPER_INSTRUCTIONS}
"""

[mcp_servers.draxl]
command = {draxl_exe}
args = [{args}]
cwd = {root}

{tool_sections}"#,
        draxl_exe = toml_string(&draxl_exe_text),
        root = toml_string(&root_text),
    )
}

fn is_managed_config(source: &str) -> bool {
    source.starts_with(GENERATED_HEADER)
}

fn canonicalize_existing_dir(path: PathBuf) -> Result<PathBuf, String> {
    let canonical = path
        .canonicalize()
        .map_err(|err| format!("failed to canonicalize {}: {err}", path.display()))?;
    if !canonical.is_dir() {
        return Err(format!(
            "workspace root must be a directory: {}",
            canonical.display()
        ));
    }
    Ok(canonical)
}

fn toml_string(value: &str) -> String {
    serde_json::to_string(value).expect("json string escaping should succeed")
}

fn ensure_git_exclude(root: &Path) -> Result<Option<PathBuf>, String> {
    let Some((repo_root, git_dir)) = find_git_repo(root)? else {
        return Ok(None);
    };
    let exclude_path = git_dir.join("info").join("exclude");
    let entry = exclude_entry(&repo_root, root)?;

    let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
    if existing.lines().any(|line| line.trim() == entry) {
        return Ok(None);
    }

    if let Some(parent) = exclude_path.parent() {
        fs::create_dir_all(parent)
            .map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
    }

    let mut updated = existing;
    if !updated.is_empty() && !updated.ends_with('\n') {
        updated.push('\n');
    }
    updated.push_str(&entry);
    updated.push('\n');
    fs::write(&exclude_path, updated)
        .map_err(|err| format!("failed to write {}: {err}", exclude_path.display()))?;
    Ok(Some(exclude_path))
}

fn exclude_entry(repo_root: &Path, root: &Path) -> Result<String, String> {
    let relative = root
        .strip_prefix(repo_root)
        .map_err(|err| format!("failed to relativize {}: {err}", root.display()))?;
    let entry = if relative.as_os_str().is_empty() {
        ".codex".to_owned()
    } else {
        relative.join(".codex").to_string_lossy().into_owned()
    };
    Ok(format!("{}/", entry.replace('\\', "/")))
}

fn find_git_repo(root: &Path) -> Result<Option<(PathBuf, PathBuf)>, String> {
    for candidate in root.ancestors() {
        let dot_git = candidate.join(".git");
        if dot_git.is_dir() {
            return Ok(Some((candidate.to_path_buf(), dot_git)));
        }
        if dot_git.is_file() {
            let git_dir = resolve_git_dir(candidate, &dot_git)?;
            return Ok(Some((candidate.to_path_buf(), git_dir)));
        }
    }
    Ok(None)
}

fn resolve_git_dir(repo_root: &Path, dot_git_file: &Path) -> Result<PathBuf, String> {
    let source = fs::read_to_string(dot_git_file)
        .map_err(|err| format!("failed to read {}: {err}", dot_git_file.display()))?;
    let Some(path_text) = source.trim().strip_prefix("gitdir: ") else {
        return Err(format!(
            "unsupported gitdir file format in {}",
            dot_git_file.display()
        ));
    };
    let git_dir = Path::new(path_text);
    let resolved = if git_dir.is_absolute() {
        git_dir.to_path_buf()
    } else {
        repo_root.join(git_dir)
    };
    Ok(resolved)
}