crosslink 0.8.0

A synced issue tracker CLI for multi-agent AI development
Documentation
//! File merge utilities for `crosslink init` — gitignore, MCP, settings.

use anyhow::{Context, Result};
use std::fs;
use std::path::Path;

// Section markers for idempotent gitignore management
pub(super) const GITIGNORE_SECTION_START: &str =
    "# === Crosslink managed (do not edit between markers) ===";
pub(super) const GITIGNORE_SECTION_END: &str = "# === End crosslink managed ===";

const GITIGNORE_MANAGED_SECTION: &str = "\
# .crosslink/ — machine-local state (never commit)
.crosslink/issues.db
.crosslink/issues.db-wal
.crosslink/issues.db-shm
.crosslink/agent.json
.crosslink/session.json
.crosslink/daemon.pid
.crosslink/daemon.log
.crosslink/last_test_run
.crosslink/.active-issue
.crosslink/keys/
.crosslink/.hub-cache/
.crosslink/.knowledge-cache/
.crosslink/.cache/
.crosslink/init-manifest.json
.crosslink/init-manifest.json.tmp
.crosslink/hook-config.local.json
.crosslink/integrations/
.crosslink/rules.local/

# .crosslink/ — DO track these (project-level policy):
#   .crosslink/hook-config.json   — shared team configuration
#   .crosslink/rules/             — project coding standards
#   .crosslink/.gitignore         — inner gitignore for agent files

# .claude/ — auto-generated by crosslink init (not project source)
.claude/hooks/
.claude/commands/
.claude/mcp/

# .claude/ — DO track these (if manually configured):
#   .claude/settings.json         — Claude Code project settings
#   .claude/settings.local.json is per-developer, ignore separately if needed
";

/// Write or update a managed section in the project root `.gitignore`.
///
/// The section is delimited by `GITIGNORE_SECTION_START` / `GITIGNORE_SECTION_END` markers.
/// On first run the section is appended; on subsequent runs the existing section is replaced
pub(super) fn write_root_gitignore(project_root: &Path) -> Result<()> {
    let gitignore_path = project_root.join(".gitignore");

    let managed_block =
        format!("{GITIGNORE_SECTION_START}\n{GITIGNORE_MANAGED_SECTION}{GITIGNORE_SECTION_END}\n");

    let existing = fs::read_to_string(&gitignore_path).unwrap_or_default();

    let new_content = if let (Some(start_pos), Some(end_pos)) = (
        existing.find(GITIGNORE_SECTION_START),
        existing.find(GITIGNORE_SECTION_END),
    ) {
        // Replace existing managed section in-place
        let before = &existing[..start_pos];
        let after = &existing[end_pos + GITIGNORE_SECTION_END.len()..];
        // Strip leading newline from `after` so we don't accumulate blank lines
        let after = after.strip_prefix('\n').unwrap_or(after);
        format!("{before}{managed_block}{after}")
    } else {
        // Append new section (with a blank separator if file has content)
        if existing.is_empty() {
            managed_block
        } else {
            let separator = if existing.ends_with('\n') {
                "\n"
            } else {
                "\n\n"
            };
            format!("{existing}{separator}{managed_block}")
        }
    };

    fs::write(&gitignore_path, new_content).context("Failed to write .gitignore")?;
    Ok(())
}

/// Merge crosslink's MCP server entries into an existing `.mcp.json`, or create it fresh.
use super::{MCP_JSON, PYTHON_PREFIX_PLACEHOLDER, SETTINGS_JSON};

pub(super) fn write_mcp_json_merged(mcp_path: &Path) -> Result<Vec<String>> {
    let embedded: serde_json::Value = serde_json::from_str(MCP_JSON)
        .context("embedded MCP_JSON is not valid JSON — this is a build defect")?;
    let src_servers = embedded
        .get("mcpServers")
        .and_then(|v| v.as_object())
        .context("embedded MCP_JSON missing mcpServers object — this is a build defect")?;

    let mut obj = match fs::read_to_string(mcp_path) {
        Ok(raw) => {
            let parsed: serde_json::Value = serde_json::from_str(&raw).with_context(|| {
                format!(
                    "Existing .mcp.json at {} contains invalid JSON — \
                     refusing to overwrite. Fix or remove it, then retry.",
                    mcp_path.display()
                )
            })?;
            match parsed {
                serde_json::Value::Object(map) => map,
                _ => anyhow::bail!(
                    "Existing .mcp.json at {} is not a JSON object — \
                     refusing to overwrite. Fix or remove it, then retry.",
                    mcp_path.display()
                ),
            }
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::Map::new(),
        Err(e) => return Err(anyhow::Error::from(e).context("Failed to read existing .mcp.json")),
    };

    let mut dest_map = match obj.remove("mcpServers") {
        Some(serde_json::Value::Object(map)) => map,
        Some(_) => anyhow::bail!(
            "Existing .mcp.json has a non-object mcpServers value — \
             refusing to overwrite. Fix or remove it, then retry."
        ),
        None => serde_json::Map::new(),
    };

    let mut warnings = Vec::new();
    for (key, value) in src_servers {
        if dest_map.contains_key(key) {
            warnings.push(format!(
                "Warning: overwriting existing mcpServers entry \"{key}\" with crosslink default"
            ));
        }
        dest_map.insert(key.clone(), value.clone());
    }

    obj.insert("mcpServers".into(), serde_json::Value::Object(dest_map));

    let mut output = serde_json::to_string_pretty(&serde_json::Value::Object(obj))
        .context("Failed to serialize .mcp.json")?;
    output.push('\n');
    fs::write(mcp_path, output).context("Failed to write .mcp.json")?;
    Ok(warnings)
}

/// Merge crosslink's default `allowedTools` into an existing `.claude/settings.json`,
/// or create it fresh.  Hooks are always overwritten (they are crosslink-managed),
/// but user-added `allowedTools` entries are preserved.
///
/// The `python_prefix` is substituted into hook commands via the `__PYTHON_PREFIX__`
pub(super) fn write_settings_json_merged(settings_path: &Path, python_prefix: &str) -> Result<()> {
    let template_raw = SETTINGS_JSON.replace(PYTHON_PREFIX_PLACEHOLDER, python_prefix);
    let template: serde_json::Value = serde_json::from_str(&template_raw).context(
        "embedded SETTINGS_JSON is not valid JSON after substitution — this is a build defect",
    )?;

    let embedded_tools: Vec<String> = template
        .get("allowedTools")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    let mut obj = match fs::read_to_string(settings_path) {
        Ok(raw) => {
            let parsed: serde_json::Value = serde_json::from_str(&raw).with_context(|| {
                format!(
                    "Existing settings.json at {} contains invalid JSON — \
                         refusing to overwrite. Fix or remove it, then retry.",
                    settings_path.display()
                )
            })?;
            match parsed {
                serde_json::Value::Object(map) => map,
                _ => anyhow::bail!(
                    "Existing settings.json at {} is not a JSON object — \
                     refusing to overwrite. Fix or remove it, then retry.",
                    settings_path.display()
                ),
            }
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::Map::new(),
        Err(e) => {
            return Err(anyhow::Error::from(e).context("Failed to read existing settings.json"))
        }
    };

    // Merge allowedTools: union of existing entries + embedded defaults (no duplicates)
    let mut tools: Vec<String> = obj
        .get("allowedTools")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    for tool in &embedded_tools {
        if !tools.contains(tool) {
            tools.push(tool.clone());
        }
    }

    obj.insert(
        "allowedTools".into(),
        serde_json::Value::Array(tools.into_iter().map(serde_json::Value::String).collect()),
    );

    // Overwrite hooks (crosslink-managed) and enableAllProjectMcpServers
    if let Some(hooks) = template.get("hooks") {
        obj.insert("hooks".into(), hooks.clone());
    }
    if let Some(enable) = template.get("enableAllProjectMcpServers") {
        obj.insert("enableAllProjectMcpServers".into(), enable.clone());
    }

    let mut output = serde_json::to_string_pretty(&serde_json::Value::Object(obj))
        .context("Failed to serialize settings.json")?;
    output.push('\n');
    fs::write(settings_path, output).context("Failed to write settings.json")?;
    Ok(())
}