use std::path::Path;
use std::process::Command;
use semver::Version;
use crate::toolspec::{HookSet, PathSpec, ToolSpec, ToolSpecError, VersionRange};
pub fn parse_semver_from_output(output: &[u8]) -> Result<Version, ToolSpecError> {
let text = String::from_utf8_lossy(output);
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if !chars[i].is_ascii_digit() {
i += 1;
continue;
}
let start = i;
while i < len && chars[i].is_ascii_digit() {
i += 1;
}
if i >= len || chars[i] != '.' {
continue;
}
i += 1; if i >= len || !chars[i].is_ascii_digit() {
continue;
}
while i < len && chars[i].is_ascii_digit() {
i += 1;
}
if i >= len || chars[i] != '.' {
continue;
}
i += 1; if i >= len || !chars[i].is_ascii_digit() {
continue;
}
let patch_start = i;
while i < len && chars[i].is_ascii_digit() {
i += 1;
}
if i == patch_start {
continue;
}
let candidate: String = chars[start..i].iter().collect();
return Version::parse(&candidate).map_err(|source| ToolSpecError::InvalidVersion {
version: candidate,
source,
});
}
Err(ToolSpecError::InvalidVersion {
version: String::from_utf8_lossy(output)
.chars()
.take(80)
.collect::<String>(),
source: Version::parse("not-a-version").unwrap_err(),
})
}
fn run_version_cmd(binary: &Path) -> Result<Version, ToolSpecError> {
let output = Command::new(binary)
.arg("--version")
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ToolSpecError::BinaryNotFound(binary.display().to_string())
} else {
ToolSpecError::Io(e)
}
})?;
if let Ok(v) = parse_semver_from_output(&output.stdout) {
return Ok(v);
}
parse_semver_from_output(&output.stderr)
}
fn detect_version_claude(binary: &Path) -> Result<Version, ToolSpecError> {
run_version_cmd(binary)
}
static CLAUDE_CONFIG_PATH: PathSpec = PathSpec {
linux: &["~/.claude/settings.json"],
macos: &["~/.claude/settings.json"],
windows: &[],
};
static CLAUDE_TRANSCRIPT_PATH: PathSpec = PathSpec {
linux: &["~/.claude/projects/"],
macos: &["~/.claude/projects/"],
windows: &[],
};
static CLAUDE_HOOKSET_V1: HookSet = HookSet {
session_start: "SessionStart",
session_end: "Stop",
pre_compact: Some("PreCompact"),
user_prompt_submit: Some("UserPromptSubmit"),
stop: Some("Stop"),
};
static CLAUDE_HOOKS: [(VersionRange, HookSet); 1] = [(
VersionRange {
min: Version::new(1, 0, 0),
max: None,
},
CLAUDE_HOOKSET_V1,
)];
pub static CLAUDE: ToolSpec = ToolSpec {
name: "claude",
detect_binary: &["claude", "claude-code"],
detect_version: detect_version_claude,
config_paths: &[CLAUDE_CONFIG_PATH],
transcript_paths: &[CLAUDE_TRANSCRIPT_PATH],
hooks_by_version: &CLAUDE_HOOKS,
};
fn detect_version_cursor(binary: &Path) -> Result<Version, ToolSpecError> {
run_version_cmd(binary)
}
static CURSOR_CONFIG_PATH: PathSpec = PathSpec {
linux: &["~/.cursor/hooks.json"],
macos: &["~/.cursor/hooks.json"],
windows: &[],
};
static CURSOR_TRANSCRIPT_PATH: PathSpec = PathSpec {
linux: &["~/.config/Cursor/User/globalStorage/state.vscdb"],
macos: &["~/Library/Application Support/Cursor/User/globalStorage/state.vscdb"],
windows: &[],
};
static CURSOR_WORKSPACE_STORAGE_PATH: PathSpec = PathSpec {
linux: &["~/.config/Cursor/User/workspaceStorage"],
macos: &["~/Library/Application Support/Cursor/User/workspaceStorage"],
windows: &[],
};
static CURSOR_HOOKSET_V040: HookSet = HookSet {
session_start: "beforeSubmitPrompt",
session_end: "stop",
pre_compact: None,
user_prompt_submit: None,
stop: Some("stop"),
};
static CURSOR_HOOKS: [(VersionRange, HookSet); 1] = [(
VersionRange {
min: Version::new(0, 40, 0),
max: None,
},
CURSOR_HOOKSET_V040,
)];
pub static CURSOR: ToolSpec = ToolSpec {
name: "cursor",
detect_binary: &["cursor"],
detect_version: detect_version_cursor,
config_paths: &[CURSOR_CONFIG_PATH],
transcript_paths: &[CURSOR_TRANSCRIPT_PATH, CURSOR_WORKSPACE_STORAGE_PATH],
hooks_by_version: &CURSOR_HOOKS,
};
fn detect_version_codex(binary: &Path) -> Result<Version, ToolSpecError> {
run_version_cmd(binary)
}
static CODEX_CONFIG_PATH: PathSpec = PathSpec {
linux: &["~/.codex/config.toml"],
macos: &["~/.codex/config.toml"],
windows: &[],
};
static CODEX_HISTORY_PATH: PathSpec = PathSpec {
linux: &["~/.codex/history.jsonl"],
macos: &["~/.codex/history.jsonl"],
windows: &[],
};
static CODEX_TRANSCRIPT_PATH: PathSpec = PathSpec {
linux: &["~/.codex/sessions/"],
macos: &["~/.codex/sessions/"],
windows: &[],
};
static CODEX_HOOKSET_V020: HookSet = HookSet {
session_start: "SessionStart",
session_end: "Stop",
pre_compact: None,
user_prompt_submit: None,
stop: Some("Stop"),
};
static CODEX_HOOKS: [(VersionRange, HookSet); 1] = [(
VersionRange {
min: Version::new(0, 20, 0),
max: None,
},
CODEX_HOOKSET_V020,
)];
pub static CODEX: ToolSpec = ToolSpec {
name: "codex",
detect_binary: &["codex"],
detect_version: detect_version_codex,
config_paths: &[CODEX_CONFIG_PATH],
transcript_paths: &[CODEX_TRANSCRIPT_PATH, CODEX_HISTORY_PATH],
hooks_by_version: &CODEX_HOOKS,
};
pub static ALL_TOOLS: &[&ToolSpec] = &[&CLAUDE, &CURSOR, &CODEX];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_spec_resolves_known_version() {
let v = Version::parse("2.0.0").unwrap();
let (hookset, kind) = CLAUDE.resolve_hookset(&v).unwrap();
assert_eq!(kind, crate::toolspec::FallbackKind::Exact);
assert_eq!(hookset.session_start, "SessionStart");
assert_eq!(hookset.pre_compact, Some("PreCompact"));
}
#[test]
fn cursor_spec_resolves_known_version() {
let v = Version::parse("0.50.0").unwrap();
let (hookset, kind) = CURSOR.resolve_hookset(&v).unwrap();
assert_eq!(kind, crate::toolspec::FallbackKind::Exact);
assert_eq!(hookset.session_start, "beforeSubmitPrompt");
assert!(hookset.pre_compact.is_none());
}
#[test]
fn codex_spec_resolves_known_version() {
let v = Version::parse("0.30.0").unwrap();
let (hookset, kind) = CODEX.resolve_hookset(&v).unwrap();
assert_eq!(kind, crate::toolspec::FallbackKind::Exact);
assert_eq!(hookset.session_start, "SessionStart");
assert_eq!(hookset.stop, Some("Stop"));
}
#[test]
fn parse_semver_from_output_finds_version() {
let input = b"claude version 1.2.3 (build abc)";
let v = parse_semver_from_output(input).unwrap();
assert_eq!(v, Version::new(1, 2, 3));
}
#[test]
fn parse_semver_from_output_handles_malformed() {
let input = b"no version here";
let result = parse_semver_from_output(input);
assert!(matches!(result, Err(ToolSpecError::InvalidVersion { .. })));
}
#[test]
fn all_tools_table_lists_three_tools() {
assert_eq!(ALL_TOOLS.len(), 3);
}
#[test]
fn each_tool_has_at_least_one_hookset() {
for spec in ALL_TOOLS {
assert!(
!spec.hooks_by_version.is_empty(),
"tool `{}` has no hookset entries",
spec.name
);
}
}
}