use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
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
";
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),
) {
let before = &existing[..start_pos];
let after = &existing[end_pos + GITIGNORE_SECTION_END.len()..];
let after = after.strip_prefix('\n').unwrap_or(after);
format!("{before}{managed_block}{after}")
} else {
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(())
}
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)
}
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"))
}
};
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()),
);
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(())
}