use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use crate::adopt::plan::{AdoptionPlan, GateType, ProposedCheckSource};
pub fn write_klasp_toml(
repo_root: &Path,
plan: &AdoptionPlan,
force: bool,
agents: Option<&[String]>,
) -> io::Result<PathBuf> {
let target = repo_root.join("klasp.toml");
if target.exists() && !force {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"klasp.toml already exists; pass --force to overwrite",
));
}
let toml_content = generate_toml(plan, agents);
klasp_core::ConfigV1::parse(&toml_content).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("generated klasp.toml failed validation (generator bug): {e}"),
)
})?;
crate::fs_util::atomic_write_text(&target, &toml_content)?;
Ok(target)
}
fn generate_toml(plan: &AdoptionPlan, agents: Option<&[String]>) -> String {
let adopted_note = if plan.findings.is_empty() {
"# adopted: no existing gates detected\n"
} else {
"# adopted: mirroring existing gates detected by `klasp init --adopt`\n"
};
let agents_line = match agents {
Some(list) => {
let items: Vec<String> = list
.iter()
.map(|a| format!("\"{}\"", escape_toml_string(a)))
.collect();
format!("agents = [{}]\n", items.join(", "))
}
None => {
"# Agent surfaces that klasp intercepts. Comment out any you don't use.\n\
agents = [\"claude_code\", \"codex\", \"aider\"]\n"
.to_string()
}
};
let mut out = format!(
"# klasp.toml — generated by `klasp init --adopt`\n\
# Docs: https://github.com/klasp-dev/klasp\n\
# Verify this install: run `klasp doctor`\n\
{adopted_note}\
\n\
version = 1\n\
\n\
[gate]\n\
{agents_line}\
policy = \"any_fail\"\n"
);
let mut seen_names: HashMap<String, usize> = HashMap::new();
for finding in &plan.findings {
for check in &finding.proposed_checks {
let base = &check.name;
let count = seen_names.entry(base.clone()).or_insert(0);
let effective_name = if *count == 0 {
base.clone()
} else {
format!("{}-{}", base, gate_suffix(&finding.gate_type))
};
*count += 1;
out.push('\n');
out.push_str("[[checks]]\n");
out.push_str(&format!(
"name = \"{}\"\n",
escape_toml_string(&effective_name)
));
let trigger_items: Vec<String> = check
.triggers
.iter()
.map(|t| format!("\"{}\"", escape_toml_string(t.as_str())))
.collect();
out.push_str(&format!(
"triggers = [{{ on = [{}] }}]\n",
trigger_items.join(", ")
));
out.push_str(&format!("timeout_secs = {}\n", check.timeout_secs));
out.push_str("[checks.source]\n");
match &check.source {
ProposedCheckSource::PreCommit {
hook_stage,
config_path,
} => {
out.push_str("type = \"pre_commit\"\n");
if let Some(stage) = hook_stage {
out.push_str(&format!("hook_stage = \"{}\"\n", escape_toml_string(stage)));
}
if let Some(path) = config_path {
out.push_str(&format!(
"config_path = \"{}\"\n",
escape_toml_string(&path.display().to_string())
));
}
}
ProposedCheckSource::Shell { command } => {
out.push_str("type = \"shell\"\n");
out.push_str(&format!("command = \"{}\"\n", escape_toml_string(command)));
}
}
}
}
out
}
fn gate_suffix(gate_type: &GateType) -> &'static str {
match gate_type {
GateType::PreCommitFramework => "pre-commit",
GateType::Husky { .. } => "husky",
GateType::Lefthook => "lefthook",
GateType::PlainGitHook { .. } => "git-hook",
GateType::LintStaged => "lint-staged",
GateType::Tooling(_) => "tooling",
}
}
fn escape_toml_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::adopt::plan::{
AdoptionPlan, ChainSupport, DetectedGate, GateType, HookStage, ProposedCheck,
ProposedCheckSource, TriggerKind,
};
fn pre_commit_plan() -> AdoptionPlan {
AdoptionPlan {
findings: vec![DetectedGate {
gate_type: GateType::PreCommitFramework,
source_path: PathBuf::from(".pre-commit-config.yaml"),
proposed_checks: vec![ProposedCheck {
name: "pre-commit".to_string(),
triggers: vec![TriggerKind::Commit],
timeout_secs: 120,
source: ProposedCheckSource::PreCommit {
hook_stage: None,
config_path: None,
},
}],
chain_support: ChainSupport::ManualOnly,
manual_chain_instructions: None,
warnings: vec![],
}],
}
}
fn shell_plan(command: &str) -> AdoptionPlan {
AdoptionPlan {
findings: vec![DetectedGate {
gate_type: GateType::LintStaged,
source_path: PathBuf::from("package.json"),
proposed_checks: vec![ProposedCheck {
name: "lint-staged".to_string(),
triggers: vec![TriggerKind::Commit],
timeout_secs: 120,
source: ProposedCheckSource::Shell {
command: command.to_string(),
},
}],
chain_support: ChainSupport::ManualOnly,
manual_chain_instructions: None,
warnings: vec![],
}],
}
}
#[test]
fn writes_and_parses_pre_commit_check() {
let tmp = tempfile::TempDir::new().unwrap();
let plan = pre_commit_plan();
let written = write_klasp_toml(tmp.path(), &plan, false, None).unwrap();
assert_eq!(written, tmp.path().join("klasp.toml"));
let content = std::fs::read_to_string(&written).unwrap();
let config =
klasp_core::ConfigV1::parse(&content).expect("generated TOML should parse cleanly");
assert_eq!(config.checks.len(), 1);
assert_eq!(config.checks[0].name, "pre-commit");
assert!(matches!(
&config.checks[0].source,
klasp_core::CheckSourceConfig::PreCommit { hook_stage, config_path }
if hook_stage.is_none() && config_path.is_none()
));
}
#[test]
fn writes_and_parses_shell_check() {
let tmp = tempfile::TempDir::new().unwrap();
let plan = shell_plan("pnpm exec lint-staged");
let written = write_klasp_toml(tmp.path(), &plan, false, None).unwrap();
let content = std::fs::read_to_string(&written).unwrap();
let config = klasp_core::ConfigV1::parse(&content).unwrap();
assert_eq!(config.checks.len(), 1);
assert!(matches!(
&config.checks[0].source,
klasp_core::CheckSourceConfig::Shell { command }
if command == "pnpm exec lint-staged"
));
}
#[test]
fn errors_if_file_exists_without_force() {
let tmp = tempfile::TempDir::new().unwrap();
let plan = pre_commit_plan();
write_klasp_toml(tmp.path(), &plan, false, None).unwrap();
let err = write_klasp_toml(tmp.path(), &plan, false, None).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn overwrites_with_force() {
let tmp = tempfile::TempDir::new().unwrap();
let plan = pre_commit_plan();
write_klasp_toml(tmp.path(), &plan, false, None).unwrap();
let result = write_klasp_toml(tmp.path(), &plan, true, None);
assert!(result.is_ok(), "force should allow overwrite");
}
#[test]
fn empty_plan_writes_minimal_scaffold() {
let tmp = tempfile::TempDir::new().unwrap();
let plan = AdoptionPlan::default();
let written = write_klasp_toml(tmp.path(), &plan, false, None).unwrap();
let content = std::fs::read_to_string(&written).unwrap();
let config = klasp_core::ConfigV1::parse(&content).unwrap();
assert!(config.checks.is_empty());
assert!(content.contains("no existing gates detected"));
}
#[test]
fn pre_commit_with_hook_stage_and_config_path() {
let tmp = tempfile::TempDir::new().unwrap();
let plan = AdoptionPlan {
findings: vec![DetectedGate {
gate_type: GateType::PreCommitFramework,
source_path: PathBuf::from(".pre-commit-config.yaml"),
proposed_checks: vec![ProposedCheck {
name: "pre-commit-push".to_string(),
triggers: vec![TriggerKind::Push],
timeout_secs: 120,
source: ProposedCheckSource::PreCommit {
hook_stage: Some("pre-push".to_string()),
config_path: Some(PathBuf::from("tools/pre-commit.yaml")),
},
}],
chain_support: ChainSupport::ManualOnly,
manual_chain_instructions: None,
warnings: vec![],
}],
};
let written = write_klasp_toml(tmp.path(), &plan, false, None).unwrap();
let content = std::fs::read_to_string(&written).unwrap();
let config = klasp_core::ConfigV1::parse(&content).unwrap();
assert_eq!(config.checks.len(), 1);
match &config.checks[0].source {
klasp_core::CheckSourceConfig::PreCommit {
hook_stage,
config_path,
} => {
assert_eq!(hook_stage.as_deref(), Some("pre-push"));
assert_eq!(
config_path.as_ref().map(|p| p.display().to_string()),
Some("tools/pre-commit.yaml".to_string())
);
}
other => panic!("expected PreCommit, got {other:?}"),
}
}
#[test]
fn toml_string_escaping_works() {
assert_eq!(escape_toml_string(r#"foo"bar"#), r#"foo\"bar"#);
assert_eq!(escape_toml_string(r"foo\bar"), r"foo\\bar");
assert_eq!(escape_toml_string("simple"), "simple");
}
#[test]
fn header_does_not_mention_mode_mirror() {
let tmp = tempfile::TempDir::new().unwrap();
let plan = AdoptionPlan::default();
let written = write_klasp_toml(tmp.path(), &plan, false, None).unwrap();
let content = std::fs::read_to_string(&written).unwrap();
assert!(
!content.contains("--mode mirror"),
"header should not hardcode --mode mirror"
);
}
#[test]
fn agents_some_uses_narrowed_list() {
let tmp = tempfile::TempDir::new().unwrap();
let plan = AdoptionPlan::default();
let agents = vec!["claude_code".to_string()];
let written = write_klasp_toml(tmp.path(), &plan, false, Some(&agents)).unwrap();
let content = std::fs::read_to_string(&written).unwrap();
let config = klasp_core::ConfigV1::parse(&content).unwrap();
assert_eq!(config.gate.agents, vec!["claude_code"]);
assert!(
!content.contains("Comment out any you don't use"),
"narrowed list should not include fallback comment"
);
}
#[test]
fn agents_none_uses_three_agent_fallback_with_comment() {
let tmp = tempfile::TempDir::new().unwrap();
let plan = AdoptionPlan::default();
let written = write_klasp_toml(tmp.path(), &plan, false, None).unwrap();
let content = std::fs::read_to_string(&written).unwrap();
let config = klasp_core::ConfigV1::parse(&content).unwrap();
assert_eq!(config.gate.agents, vec!["claude_code", "codex", "aider"]);
assert!(
content.contains("Comment out any you don't use"),
"fallback should include edit-me comment"
);
}
#[test]
fn duplicate_name_suffix_on_collision() {
let plan = AdoptionPlan {
findings: vec![
DetectedGate {
gate_type: GateType::Husky {
hook: HookStage::PreCommit,
},
source_path: PathBuf::from(".husky/pre-commit"),
proposed_checks: vec![ProposedCheck {
name: "lint".to_string(),
triggers: vec![TriggerKind::Commit],
timeout_secs: 60,
source: ProposedCheckSource::Shell {
command: "pnpm lint".to_string(),
},
}],
chain_support: ChainSupport::ManualOnly,
manual_chain_instructions: None,
warnings: vec![],
},
DetectedGate {
gate_type: GateType::Lefthook,
source_path: PathBuf::from("lefthook.yml"),
proposed_checks: vec![ProposedCheck {
name: "lint".to_string(), triggers: vec![TriggerKind::Commit],
timeout_secs: 60,
source: ProposedCheckSource::Shell {
command: "pnpm lint".to_string(),
},
}],
chain_support: ChainSupport::ManualOnly,
manual_chain_instructions: None,
warnings: vec![],
},
],
};
let toml_str = generate_toml(&plan, None);
assert!(
toml_str.contains("name = \"lint\"\n"),
"first 'lint' should keep bare name:\n{toml_str}"
);
assert!(
toml_str.contains("name = \"lint-lefthook\"\n"),
"second 'lint' (from Lefthook) should be 'lint-lefthook':\n{toml_str}"
);
let tmp = tempfile::TempDir::new().unwrap();
let written = write_klasp_toml(tmp.path(), &plan, false, None).unwrap();
let config = klasp_core::ConfigV1::parse(&std::fs::read_to_string(&written).unwrap())
.expect("collision-resolved TOML should parse cleanly");
let names: Vec<&str> = config.checks.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["lint", "lint-lefthook"]);
}
#[test]
fn first_occurrence_keeps_bare_name() {
let plan = AdoptionPlan {
findings: vec![DetectedGate {
gate_type: GateType::Husky {
hook: HookStage::PreCommit,
},
source_path: PathBuf::from(".husky/pre-commit"),
proposed_checks: vec![ProposedCheck {
name: "lint".to_string(),
triggers: vec![TriggerKind::Commit],
timeout_secs: 60,
source: ProposedCheckSource::Shell {
command: "pnpm lint".to_string(),
},
}],
chain_support: ChainSupport::ManualOnly,
manual_chain_instructions: None,
warnings: vec![],
}],
};
let toml_str = generate_toml(&plan, None);
assert!(
toml_str.contains("name = \"lint\"\n"),
"single 'lint' must stay bare:\n{toml_str}"
);
assert!(
!toml_str.contains("lint-husky"),
"no suffix unless collision:\n{toml_str}"
);
}
}