use std::path::Path;
use crate::config::ConfigFile;
pub(crate) type Version = (u32, u32, u32);
struct StaleTool {
name: &'static str,
installed_version: Version,
}
const VERSION_SCAN_LINES: usize = 20;
pub(crate) fn read_version_marker(path: &Path) -> Option<Version> {
let content = std::fs::read_to_string(path).ok()?;
parse_version_from_content(&content)
}
fn parse_version_from_content(content: &str) -> Option<Version> {
for line in content.lines().take(VERSION_SCAN_LINES) {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("version:") {
let ver_str = rest.trim().trim_matches('"');
if let Some(v) = parse_version(ver_str) {
return Some(v);
}
}
if let Some(rest) = trimmed.strip_prefix("Version:") {
let ver_str = rest.trim();
if let Some(v) = parse_version(ver_str) {
return Some(v);
}
}
if let Some(inner) = trimmed
.strip_prefix("<!-- agentchrome-version:")
.and_then(|s| s.strip_suffix("-->"))
{
let ver_str = inner.trim();
if let Some(v) = parse_version(ver_str) {
return Some(v);
}
}
}
None
}
fn parse_version(s: &str) -> Option<Version> {
let mut parts = s.splitn(3, '.');
let major = parts.next()?.parse::<u32>().ok()?;
let minor = parts.next()?.parse::<u32>().ok()?;
let patch = parts.next()?.trim().parse::<u32>().ok()?;
Some((major, minor, patch))
}
fn binary_version() -> Version {
parse_version(env!("CARGO_PKG_VERSION"))
.expect("CARGO_PKG_VERSION is always a valid X.Y.Z triple")
}
fn stale_tools() -> Vec<StaleTool> {
let bin_ver = binary_version();
let mut result = Vec::with_capacity(crate::skill::TOOLS.len());
for tool in crate::skill::TOOLS {
let template = crate::skill::path_template(tool);
let Ok(path) = crate::skill::resolve_path(template) else {
continue;
};
let Some(installed_ver) = read_version_marker(&path) else {
continue;
};
if installed_ver < bin_ver {
result.push(StaleTool {
name: tool.name,
installed_version: installed_ver,
});
}
}
result
}
fn format_version(v: Version) -> String {
format!("{}.{}.{}", v.0, v.1, v.2)
}
fn format_notice(stale: &[StaleTool]) -> Option<String> {
if stale.is_empty() {
return None;
}
let bin_ver = format_version(binary_version());
if stale.len() == 1 {
let tool = &stale[0];
let installed_ver = format_version(tool.installed_version);
let name = tool.name;
Some(format!(
"note: installed agentchrome skill for {name} is v{installed_ver} but binary is v{bin_ver} \
— run 'agentchrome skill update' to refresh"
))
} else {
let names: Vec<&str> = stale.iter().map(|t| t.name).collect();
let name_list = names.join(", ");
let oldest_ver = stale
.iter()
.map(|t| t.installed_version)
.min()
.expect("stale is non-empty");
let oldest_str = format_version(oldest_ver);
Some(format!(
"note: installed agentchrome skills for {name_list} are stale (oldest v{oldest_str}, binary v{bin_ver}) \
— run 'agentchrome skill update' to refresh"
))
}
}
pub fn emit_stale_notice_if_any(config: &ConfigFile) {
if std::env::var("AGENTCHROME_NO_SKILL_CHECK").as_deref() == Ok("1") {
return;
}
if config.skill.check_enabled == Some(false) {
return;
}
let stale = stale_tools();
if let Some(notice) = format_notice(&stale) {
eprintln!("{notice}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_yaml_frontmatter_quoted() {
let content = "---\nname: agentchrome\nversion: \"1.42.0\"\n---\n";
assert_eq!(parse_version_from_content(content), Some((1, 42, 0)));
}
#[test]
fn parses_yaml_frontmatter_unquoted() {
let content = "---\nname: agentchrome\nversion: 1.42.0\n---\n";
assert_eq!(parse_version_from_content(content), Some((1, 42, 0)));
}
#[test]
fn parses_legacy_version_heading() {
let content = "# agentchrome\n\nVersion: 1.40.0\n\nSome content.\n";
assert_eq!(parse_version_from_content(content), Some((1, 40, 0)));
}
#[test]
fn parses_html_comment_marker() {
let content =
"<!-- agentchrome:start -->\n<!-- agentchrome-version: 1.38.2 -->\n\nContent.\n";
assert_eq!(parse_version_from_content(content), Some((1, 38, 2)));
}
#[test]
fn garbage_input_returns_none() {
let content = "no version here at all\nrandom text\n";
assert_eq!(parse_version_from_content(content), None);
}
#[test]
fn empty_content_returns_none() {
assert_eq!(parse_version_from_content(""), None);
}
#[test]
fn version_beyond_20_lines_is_ignored() {
let mut lines: Vec<String> = (0..25).map(|i| format!("line {i}")).collect();
lines.push("version: 1.0.0".to_string());
let content = lines.join("\n");
assert_eq!(parse_version_from_content(&content), None);
}
#[test]
fn missing_file_returns_none() {
let path = Path::new("/nonexistent/path/to/SKILL.md");
assert_eq!(read_version_marker(path), None);
}
#[test]
fn parse_version_valid() {
assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("0.0.0"), Some((0, 0, 0)));
assert_eq!(parse_version("100.200.300"), Some((100, 200, 300)));
}
#[test]
fn parse_version_invalid() {
assert_eq!(parse_version("not.a.version"), None);
assert_eq!(parse_version("1.2"), None);
assert_eq!(parse_version(""), None);
assert_eq!(parse_version("1.2.3.4"), None); }
#[test]
fn format_notice_empty_stale_returns_none() {
let stale: Vec<StaleTool> = vec![];
assert!(format_notice(&stale).is_none());
}
#[test]
fn format_notice_single_tool() {
let stale = vec![StaleTool {
name: "claude-code",
installed_version: (1, 40, 0),
}];
let bin_ver = format_version(binary_version());
let notice = format_notice(&stale).unwrap();
assert!(
notice.contains("claude-code"),
"notice must name the stale tool"
);
assert!(
notice.contains("v1.40.0"),
"notice must show installed version"
);
assert!(
notice.contains(&format!("v{bin_ver}")),
"notice must show binary version"
);
assert!(
notice.contains("agentchrome skill update"),
"notice must mention the fix command"
);
}
#[test]
fn format_notice_multi_tool() {
let stale = vec![
StaleTool {
name: "claude-code",
installed_version: (1, 40, 0),
},
StaleTool {
name: "cursor",
installed_version: (1, 38, 0),
},
];
let notice = format_notice(&stale).unwrap();
assert!(notice.contains("claude-code"), "must list tool 1");
assert!(notice.contains("cursor"), "must list tool 2");
assert!(notice.contains("stale"), "must use 'stale' grammar");
assert!(
notice.contains("v1.38.0"),
"must report oldest installed version"
);
}
#[test]
fn suppressed_by_config_flag() {
use crate::config::{ConfigFile, SkillConfigFile};
let config = ConfigFile {
skill: SkillConfigFile {
check_enabled: Some(false),
},
..ConfigFile::default()
};
emit_stale_notice_if_any(&config);
}
#[test]
fn not_stale_when_installed_equals_binary() {
let bin_ver = binary_version();
let content = format!(
"---\nversion: \"{}.{}.{}\"\n---\n",
bin_ver.0, bin_ver.1, bin_ver.2
);
let parsed = parse_version_from_content(&content).expect("binary version parses");
assert!(parsed >= bin_ver, "equal version must not be stale");
}
#[test]
fn newer_installed_version_not_stale() {
let bin_ver = binary_version();
let newer = (bin_ver.0 + 1, 0, 0);
assert!(newer >= bin_ver, "newer version must not be stale");
}
}