use anyhow::{Context, Result};
use colored::Colorize;
use rust_i18n::t;
use std::path::{Path, PathBuf};
use std::process::Command;
use agnix_core::config::LintConfig;
struct ToolDescriptor {
toml_key: &'static str,
display: &'static str,
invocations: &'static [(&'static str, &'static [&'static str])],
}
const DESCRIPTORS: &[ToolDescriptor] = &[
ToolDescriptor {
toml_key: "claude_code",
display: "Claude Code",
invocations: &[("claude", &["--version"])],
},
ToolDescriptor {
toml_key: "codex",
display: "Codex CLI",
invocations: &[("codex", &["--version"])],
},
ToolDescriptor {
toml_key: "cursor",
display: "Cursor",
invocations: &[("cursor", &["--version"])],
},
ToolDescriptor {
toml_key: "copilot",
display: "GitHub Copilot",
invocations: &[
("copilot", &["--version"]),
("gh", &["copilot", "--version"]),
],
},
];
fn config_version_for(config: &LintConfig, key: &str) -> Option<String> {
let tv = config.tool_versions();
match key {
"claude_code" => tv.claude_code.clone(),
"codex" => tv.codex.clone(),
"cursor" => tv.cursor.clone(),
"copilot" => tv.copilot.clone(),
_ => None,
}
}
fn detect_installed(invocations: &[(&'static str, &'static [&'static str])]) -> Option<String> {
for (binary, args) in invocations {
let Ok(out) = Command::new(binary).args(*args).output() else {
continue;
};
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if let Some(v) = extract_version(&combined) {
return Some(v);
}
}
None
}
fn extract_version(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_digit() {
let start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'.' {
continue;
}
i += 1;
let minor_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == minor_start || i >= bytes.len() || bytes[i] != b'.' {
continue;
}
i += 1;
let patch_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == patch_start {
continue;
}
if i < bytes.len() && bytes[i] == b'-' {
i += 1;
while i < bytes.len()
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'.' || bytes[i] == b'-')
{
i += 1;
}
}
if i < bytes.len() && bytes[i] == b'+' {
i += 1;
while i < bytes.len()
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'.' || bytes[i] == b'-')
{
i += 1;
}
}
return Some(s[start..i].to_string());
}
i += 1;
}
None
}
#[derive(Debug, PartialEq, Eq)]
enum CheckOutcome {
Match { version: String },
Drift { pinned: String, installed: String },
Unpinned { installed: String },
Missing { pinned: String },
Neither,
}
fn classify(pinned: Option<String>, installed: Option<String>) -> CheckOutcome {
match (pinned, installed) {
(Some(p), Some(i)) if p == i => CheckOutcome::Match { version: p },
(Some(p), Some(i)) => CheckOutcome::Drift {
pinned: p,
installed: i,
},
(None, Some(i)) => CheckOutcome::Unpinned { installed: i },
(Some(p), None) => CheckOutcome::Missing { pinned: p },
(None, None) => CheckOutcome::Neither,
}
}
struct CheckReport {
has_issues: bool,
}
fn print_check_line(descriptor: &ToolDescriptor, outcome: &CheckOutcome) {
match outcome {
CheckOutcome::Match { version } => {
println!(
" {} {} pinned={} installed={}",
"[ok]".green().bold(),
descriptor.display,
version,
version
);
}
CheckOutcome::Drift { pinned, installed } => {
println!(
" {} {} pinned={} installed={} {}",
"[drift]".yellow().bold(),
descriptor.display,
pinned,
installed,
t!("cli.tools_check_drift_hint").dimmed()
);
}
CheckOutcome::Unpinned { installed } => {
println!(
" {} {} installed={} ({})",
"[unpinned]".dimmed(),
descriptor.display,
installed,
t!("cli.tools_check_unpinned_hint")
);
}
CheckOutcome::Missing { pinned } => {
println!(
" {} {} pinned={} {}",
"[missing]".yellow().bold(),
descriptor.display,
pinned,
t!("cli.tools_check_missing_hint")
);
}
CheckOutcome::Neither => { }
}
}
pub fn check_command(config: &LintConfig, strict: bool) -> Result<bool> {
println!("{}", t!("cli.tools_check_header").bold());
let report = run_check(config);
if report.has_issues {
let msg = t!("cli.tools_check_issues_found");
if strict {
eprintln!("\n{} {}", "[error]".red().bold(), msg);
return Ok(true);
} else {
eprintln!("\n{} {}", "[warn]".yellow().bold(), msg);
eprintln!(" {}", t!("cli.tools_check_strict_hint").dimmed());
}
} else {
println!(
"\n{} {}",
"[ok]".green().bold(),
t!("cli.tools_check_all_aligned")
);
}
Ok(report.has_issues)
}
fn run_check(config: &LintConfig) -> CheckReport {
let mut has_issues = false;
for desc in DESCRIPTORS {
let pinned = config_version_for(config, desc.toml_key);
let installed = detect_installed(desc.invocations);
let outcome = classify(pinned, installed);
if matches!(
outcome,
CheckOutcome::Drift { .. } | CheckOutcome::Missing { .. }
) {
has_issues = true;
}
print_check_line(desc, &outcome);
}
CheckReport { has_issues }
}
pub fn detect_command(config_path: Option<&Path>, write: bool) -> Result<()> {
println!("{}", t!("cli.tools_detect_header").bold());
let mut detected: Vec<(&ToolDescriptor, String)> = Vec::new();
for desc in DESCRIPTORS {
match detect_installed(desc.invocations) {
Some(version) => {
println!(
" {} {} = {}",
"[found]".green().bold(),
desc.display,
version
);
detected.push((desc, version));
}
None => {
println!(
" {} {} {}",
"[skip]".dimmed(),
desc.display,
t!("cli.tools_detect_not_on_path").dimmed()
);
}
}
}
if detected.is_empty() {
println!("\n{}", t!("cli.tools_detect_none_found"));
return Ok(());
}
let mut snippet = String::from("[tool_versions]\n");
for (desc, version) in &detected {
snippet.push_str(&format!("{} = \"{}\"\n", desc.toml_key, version));
}
if write {
let target = config_path
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(".agnix.toml"));
write_tool_versions(&target, &detected)?;
println!(
"\n{} {}",
t!("cli.tools_detect_wrote").green().bold(),
target.display()
);
} else {
println!("\n{}", t!("cli.tools_detect_snippet_header").bold());
println!("{snippet}");
println!("{}", t!("cli.tools_detect_write_hint").dimmed());
}
Ok(())
}
fn write_tool_versions(path: &Path, detected: &[(&ToolDescriptor, String)]) -> Result<()> {
let existing = if path.exists() {
std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?
} else {
String::new()
};
let mut updated = apply_tool_versions_section(&existing, detected);
if !updated.ends_with('\n') {
updated.push('\n');
}
if updated == existing {
return Ok(());
}
std::fs::write(path, updated).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn strip_inline_comment(line: &str) -> &str {
match line.find('#') {
Some(idx) => line[..idx].trim_end(),
None => line.trim_end(),
}
.trim_start()
}
fn is_section_header(line: &str, expected: &str) -> bool {
strip_inline_comment(line) == expected
}
fn is_any_section_header(line: &str) -> bool {
let stripped = strip_inline_comment(line);
stripped.starts_with('[') && stripped.ends_with(']') && stripped.len() >= 2
}
fn detect_line_ending(content: &str) -> &'static str {
let crlf = content.matches("\r\n").count();
let total = content.matches('\n').count();
if total > 0 && crlf * 2 >= total {
"\r\n"
} else {
"\n"
}
}
fn apply_tool_versions_section(content: &str, detected: &[(&ToolDescriptor, String)]) -> String {
let line_ending = detect_line_ending(content);
let lines: Vec<&str> = content.lines().collect();
let section_start = lines
.iter()
.position(|line| is_section_header(line, "[tool_versions]"));
let section_end = section_start.map(|start| {
lines[start + 1..]
.iter()
.position(|line| is_any_section_header(line))
.map(|offset| start + 1 + offset)
.unwrap_or(lines.len())
});
let detected_keys: std::collections::HashSet<&str> =
detected.iter().map(|(d, _)| d.toml_key).collect();
let mut block: Vec<String> = vec!["[tool_versions]".to_string()];
if let (Some(start), Some(end)) = (section_start, section_end) {
for line in &lines[start + 1..end] {
if let Some((k, _)) = parse_toml_key(line)
&& detected_keys.contains(k.as_str())
{
continue; }
block.push((*line).to_string());
}
}
for (desc, version) in detected {
block.push(format!("{} = \"{}\"", desc.toml_key, version));
}
match (section_start, section_end) {
(Some(start), Some(end)) => {
let mut out_lines: Vec<&str> = lines[..start].to_vec();
let block_refs: Vec<&str> = block.iter().map(|s| s.as_str()).collect();
out_lines.extend(block_refs);
out_lines.extend(&lines[end..]);
out_lines.join(line_ending)
}
_ => {
let mut out = content.trim_end_matches(&['\r', '\n'][..]).to_string();
if !out.is_empty() {
out.push_str(line_ending);
out.push_str(line_ending);
}
out.push_str(&block.join(line_ending));
out.push_str(line_ending);
out
}
}
}
fn parse_toml_key(line: &str) -> Option<(String, String)> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('[') {
return None;
}
let (k, v) = trimmed.split_once('=')?;
Some((k.trim().to_string(), v.trim().to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_version_plain_semver() {
assert_eq!(extract_version("2.1.119").as_deref(), Some("2.1.119"));
}
#[test]
fn extract_version_with_prefix() {
assert_eq!(
extract_version("Claude Code v2.1.119 (build abc)").as_deref(),
Some("2.1.119")
);
}
#[test]
fn extract_version_with_prerelease() {
assert_eq!(
extract_version("codex 0.125.0-beta.3").as_deref(),
Some("0.125.0-beta.3")
);
}
#[test]
fn extract_version_with_build_metadata() {
assert_eq!(
extract_version("cursor 3.2.11+1234").as_deref(),
Some("3.2.11+1234")
);
}
#[test]
fn extract_version_ignores_two_segment_versions() {
assert_eq!(
extract_version("node v20.11 (claude 2.1.119)").as_deref(),
Some("2.1.119")
);
}
#[test]
fn extract_version_returns_none_on_empty() {
assert_eq!(extract_version("").as_deref(), None);
assert_eq!(extract_version("no version here").as_deref(), None);
}
#[test]
fn classify_match() {
let r = classify(Some("1.0.0".into()), Some("1.0.0".into()));
assert!(matches!(r, CheckOutcome::Match { .. }));
}
#[test]
fn classify_drift() {
let r = classify(Some("1.0.0".into()), Some("1.0.1".into()));
assert!(matches!(r, CheckOutcome::Drift { .. }));
}
#[test]
fn classify_unpinned() {
let r = classify(None, Some("1.0.0".into()));
assert!(matches!(r, CheckOutcome::Unpinned { .. }));
}
#[test]
fn classify_missing() {
let r = classify(Some("1.0.0".into()), None);
assert!(matches!(r, CheckOutcome::Missing { .. }));
}
#[test]
fn classify_neither() {
let r = classify(None, None);
assert!(matches!(r, CheckOutcome::Neither));
}
#[test]
fn apply_tool_versions_section_appends_to_empty_file() {
let detected: Vec<(&ToolDescriptor, String)> = vec![(&DESCRIPTORS[0], "2.1.119".into())];
let result = apply_tool_versions_section("", &detected);
assert!(result.contains("[tool_versions]"));
assert!(result.contains("claude_code = \"2.1.119\""));
}
#[test]
fn apply_tool_versions_section_appends_to_existing_content() {
let existing = "[rules]\nxml = true\n";
let detected: Vec<(&ToolDescriptor, String)> = vec![(&DESCRIPTORS[0], "2.1.119".into())];
let result = apply_tool_versions_section(existing, &detected);
assert!(
result.contains("[rules]\nxml = true"),
"must preserve existing [rules] section, got: {result}"
);
assert!(result.contains("[tool_versions]\nclaude_code = \"2.1.119\""));
}
#[test]
fn apply_tool_versions_section_replaces_existing_keys() {
let existing = "[tool_versions]\nclaude_code = \"1.0.0\"\ncodex = \"0.1.0\"\n";
let detected: Vec<(&ToolDescriptor, String)> = vec![(&DESCRIPTORS[0], "2.1.119".into())];
let result = apply_tool_versions_section(existing, &detected);
assert!(
result.contains("claude_code = \"2.1.119\""),
"claude_code should be updated, got: {result}"
);
assert!(
result.contains("codex = \"0.1.0\""),
"codex entry should be preserved, got: {result}"
);
}
#[test]
fn apply_tool_versions_section_preserves_comments_in_section() {
let existing = "\
[tool_versions]
# Pinned per team standard
claude_code = \"1.0.0\"
codex = \"0.1.0\"
";
let detected: Vec<(&ToolDescriptor, String)> = vec![(&DESCRIPTORS[0], "2.1.119".into())];
let result = apply_tool_versions_section(existing, &detected);
assert!(
result.contains("# Pinned per team standard"),
"comment should survive, got: {result}"
);
assert!(result.contains("claude_code = \"2.1.119\""));
assert!(result.contains("codex = \"0.1.0\""));
}
#[test]
fn apply_tool_versions_section_preserves_trailing_sections() {
let existing = "\
[tool_versions]
claude_code = \"1.0.0\"
[rules]
xml = true
";
let detected: Vec<(&ToolDescriptor, String)> = vec![(&DESCRIPTORS[0], "2.1.119".into())];
let result = apply_tool_versions_section(existing, &detected);
assert!(result.contains("[rules]\nxml = true"));
assert!(result.contains("claude_code = \"2.1.119\""));
}
#[test]
fn parse_toml_key_basic() {
assert_eq!(
parse_toml_key("key = \"value\""),
Some(("key".into(), "\"value\"".into()))
);
}
#[test]
fn parse_toml_key_with_indent() {
assert_eq!(
parse_toml_key(" key=\"value\""),
Some(("key".into(), "\"value\"".into()))
);
}
#[test]
fn parse_toml_key_rejects_comments_and_headers() {
assert_eq!(parse_toml_key("# comment"), None);
assert_eq!(parse_toml_key("[section]"), None);
assert_eq!(parse_toml_key(""), None);
}
#[test]
fn extract_version_accepts_prerelease_plus_build_metadata() {
assert_eq!(
extract_version("1.2.3-alpha+build").as_deref(),
Some("1.2.3-alpha+build")
);
assert_eq!(
extract_version("release v1.2.3-rc.1+sha.5114f85").as_deref(),
Some("1.2.3-rc.1+sha.5114f85")
);
}
#[test]
fn is_section_header_tolerates_inline_comment_on_target() {
assert!(is_section_header("[tool_versions]", "[tool_versions]"));
assert!(is_section_header(
"[tool_versions] # pinned per team",
"[tool_versions]"
));
assert!(is_section_header(" [tool_versions] ", "[tool_versions]"));
assert!(!is_section_header("[tools]", "[tool_versions]"));
}
#[test]
fn is_any_section_header_tolerates_inline_comment() {
assert!(is_any_section_header("[rules]"));
assert!(is_any_section_header("[rules] # category block"));
assert!(!is_any_section_header("key = \"value\""));
assert!(!is_any_section_header("# just a comment"));
assert!(!is_any_section_header(""));
}
#[test]
fn apply_tool_versions_section_handles_inline_comment_on_header() {
let existing = "\
[tool_versions] # pinned per team
claude_code = \"1.0.0\"
";
let detected: Vec<(&ToolDescriptor, String)> = vec![(&DESCRIPTORS[0], "2.1.119".into())];
let result = apply_tool_versions_section(existing, &detected);
assert_eq!(
result.matches("[tool_versions]").count(),
1,
"must not append duplicate section, got: {result}"
);
assert!(result.contains("claude_code = \"2.1.119\""));
}
#[test]
fn apply_tool_versions_section_stops_at_trailing_section_with_inline_comment() {
let existing = "\
[tool_versions]
claude_code = \"1.0.0\"
[rules] # category gate
xml = true
";
let detected: Vec<(&ToolDescriptor, String)> = vec![(&DESCRIPTORS[0], "2.1.119".into())];
let result = apply_tool_versions_section(existing, &detected);
assert!(
result.contains("[rules] # category gate"),
"trailing section with inline comment must survive, got: {result}"
);
assert!(
result.contains("xml = true"),
"keys after the trailing section must survive, got: {result}"
);
}
#[test]
fn detect_line_ending_prefers_crlf_when_dominant() {
let crlf = "a = 1\r\nb = 2\r\n";
assert_eq!(detect_line_ending(crlf), "\r\n");
}
#[test]
fn detect_line_ending_prefers_lf_when_dominant() {
let lf = "a = 1\nb = 2\n";
assert_eq!(detect_line_ending(lf), "\n");
}
#[test]
fn detect_line_ending_defaults_to_lf_on_empty() {
assert_eq!(detect_line_ending(""), "\n");
assert_eq!(detect_line_ending("no newline at all"), "\n");
}
#[test]
fn apply_tool_versions_section_preserves_crlf() {
let existing = "[tool_versions]\r\nclaude_code = \"1.0.0\"\r\n";
let detected: Vec<(&ToolDescriptor, String)> = vec![(&DESCRIPTORS[0], "2.1.119".into())];
let result = apply_tool_versions_section(existing, &detected);
assert!(
result.contains("\r\n"),
"CRLF input must produce CRLF output, got: {result:?}"
);
assert!(result.contains("claude_code = \"2.1.119\""));
}
}