use std::borrow::Cow;
use std::env;
use std::sync::Arc;
use crate::input::{ParseError, ParseOpts, StatusContext, Tool};
pub(super) mod claude;
pub(super) mod other;
pub(super) mod qwen;
const ENV_TOOL_OVERRIDE: &str = "LINESMITH_TOOL";
pub(super) fn dispatch(
raw: Arc<serde_json::Value>,
opts: &ParseOpts,
) -> Result<StatusContext, ParseError> {
let tool = detect_tool(&raw, opts);
claude::normalize(raw, tool)
}
fn detect_tool(raw: &serde_json::Value, opts: &ParseOpts) -> Tool {
if let Some(tool) = opts.tool.as_ref() {
return tool.clone();
}
if let Some(tool) = tool_from_env() {
return tool;
}
detect_from_shape(raw)
}
fn tool_from_env() -> Option<Tool> {
let raw = match env::var(ENV_TOOL_OVERRIDE) {
Ok(s) => s,
Err(env::VarError::NotPresent) => return None,
Err(env::VarError::NotUnicode(_)) => {
crate::lsm_warn!(
"{ENV_TOOL_OVERRIDE}: value is not valid UTF-8; falling through to heuristic"
);
return None;
}
};
let tool = tool_from_str(&raw)?;
if let Tool::Other(ref name) = tool {
crate::lsm_warn!(
"{ENV_TOOL_OVERRIDE}={name:?}: not a known alias (claude, qwen, codex, copilot); routing as Tool::Other with the trimmed value preserved"
);
}
Some(tool)
}
fn tool_from_str(raw: &str) -> Option<Tool> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let tool = match trimmed.to_ascii_lowercase().as_str() {
"claude" | "claude_code" | "claude-code" | "claudecode" => Tool::ClaudeCode,
"qwen" | "qwen_code" | "qwen-code" | "qwencode" => Tool::QwenCode,
"codex" | "codex_cli" | "codex-cli" | "codexcli" => Tool::CodexCli,
"copilot" | "copilot_cli" | "copilot-cli" | "copilotcli" => Tool::CopilotCli,
_ => Tool::Other(Cow::Owned(trimmed.to_string())),
};
Some(tool)
}
fn has_claude_signature(raw: &serde_json::Value) -> bool {
raw.as_object()
.and_then(|root| root.get("cost"))
.and_then(serde_json::Value::as_object)
.is_some_and(|cost| cost.contains_key("total_api_duration_ms"))
}
#[allow(clippy::if_same_then_else)]
fn detect_from_shape(raw: &serde_json::Value) -> Tool {
if has_claude_signature(raw) {
Tool::ClaudeCode
} else {
crate::lsm_debug!("tool detection: no shape signature matched; falling back to ClaudeCode");
Tool::ClaudeCode
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> ParseOpts {
ParseOpts::default()
}
#[test]
fn detect_falls_back_to_claude_when_no_signal() {
let raw = serde_json::json!({});
assert_eq!(detect_tool(&raw, &opts()), Tool::ClaudeCode);
}
#[test]
fn detect_returns_claude_when_cost_total_api_duration_ms_present() {
let raw = serde_json::json!({
"cost": { "total_api_duration_ms": 0 }
});
assert_eq!(detect_tool(&raw, &opts()), Tool::ClaudeCode);
}
#[test]
fn detect_matches_claude_when_total_api_duration_ms_is_null() {
let raw = serde_json::json!({
"cost": { "total_api_duration_ms": serde_json::Value::Null }
});
assert_eq!(detect_tool(&raw, &opts()), Tool::ClaudeCode);
}
#[test]
fn detect_falls_back_when_cost_is_non_object() {
let raw = serde_json::json!({ "cost": "broken" });
assert_eq!(detect_tool(&raw, &opts()), Tool::ClaudeCode);
}
#[test]
fn detect_opts_override_wins_over_heuristic() {
let raw = serde_json::json!({
"cost": { "total_api_duration_ms": 0 }
});
let opts = ParseOpts::default().with_tool(Tool::QwenCode);
assert_eq!(detect_tool(&raw, &opts), Tool::QwenCode);
}
#[test]
fn detect_opts_override_used_when_env_unset() {
let raw = serde_json::json!({});
let opts = ParseOpts::default().with_tool(Tool::CodexCli);
assert_eq!(detect_tool(&raw, &opts), Tool::CodexCli);
}
#[test]
fn tool_from_str_parses_canonical_aliases() {
for (input, want) in [
("claude", Tool::ClaudeCode),
("claude_code", Tool::ClaudeCode),
("claudecode", Tool::ClaudeCode),
("qwen", Tool::QwenCode),
("qwen_code", Tool::QwenCode),
("qwencode", Tool::QwenCode),
("codex", Tool::CodexCli),
("codex_cli", Tool::CodexCli),
("codexcli", Tool::CodexCli),
("copilot", Tool::CopilotCli),
("copilot_cli", Tool::CopilotCli),
("copilotcli", Tool::CopilotCli),
] {
assert_eq!(tool_from_str(input), Some(want.clone()), "input={input:?}");
}
}
#[test]
fn tool_from_str_is_case_insensitive() {
assert_eq!(tool_from_str("CLAUDE"), Some(Tool::ClaudeCode));
assert_eq!(tool_from_str("Qwen"), Some(Tool::QwenCode));
assert_eq!(tool_from_str("CoPiLoT_CLI"), Some(Tool::CopilotCli));
}
#[test]
fn tool_from_str_trims_surrounding_whitespace() {
assert_eq!(tool_from_str(" qwen "), Some(Tool::QwenCode));
}
#[test]
fn tool_from_str_returns_none_for_empty_and_whitespace() {
assert_eq!(tool_from_str(""), None);
assert_eq!(tool_from_str(" "), None);
assert_eq!(tool_from_str("\t\n "), None);
}
#[test]
fn tool_from_str_unknown_value_routes_to_tool_other_with_trimmed_input() {
let got = tool_from_str(" gemini ").expect("non-empty input");
assert_eq!(got, Tool::Other(Cow::Owned("gemini".to_string())));
}
#[test]
fn tool_from_str_accepts_dash_separated_aliases() {
assert_eq!(tool_from_str("claude-code"), Some(Tool::ClaudeCode));
assert_eq!(tool_from_str("qwen-code"), Some(Tool::QwenCode));
assert_eq!(tool_from_str("codex-cli"), Some(Tool::CodexCli));
assert_eq!(tool_from_str("copilot-cli"), Some(Tool::CopilotCli));
}
#[test]
fn tool_from_str_unknown_with_known_substring_stays_other() {
assert_eq!(
tool_from_str("claude-haiku"),
Some(Tool::Other(Cow::Owned("claude-haiku".to_string())))
);
}
#[test]
fn tool_from_str_every_canonical_variant_has_all_four_spellings() {
for (base, suffix, expected) in [
("claude", "code", Tool::ClaudeCode),
("qwen", "code", Tool::QwenCode),
("codex", "cli", Tool::CodexCli),
("copilot", "cli", Tool::CopilotCli),
] {
for spelling in [
base.to_string(),
format!("{base}_{suffix}"),
format!("{base}-{suffix}"),
format!("{base}{suffix}"),
] {
assert_eq!(
tool_from_str(&spelling),
Some(expected.clone()),
"spelling {spelling:?} did not fold to {expected:?}"
);
}
}
}
}