use std::collections::HashMap;
use std::io::{self, IsTerminal};
use std::sync::LazyLock;
use color_print::cformat;
use worktrunk::config::UserConfig;
use worktrunk::styling::{eprintln, format_toml, hint_message, info_message, success_message};
use super::prompt::{PromptResponse, prompt_yes_no_preview};
const CONFIG_EXAMPLE: &str = include_str!("../../dev/config.example.toml");
static RECOMMENDED_COMMANDS: LazyLock<HashMap<String, String>> =
LazyLock::new(|| parse_recommended_commands(CONFIG_EXAMPLE));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmTool {
Claude,
Codex,
OpenCode,
}
impl LlmTool {
pub fn command_name(&self) -> &'static str {
match self {
LlmTool::Claude => "claude",
LlmTool::Codex => "codex",
LlmTool::OpenCode => "opencode",
}
}
fn config_heading(&self) -> &'static str {
match self {
LlmTool::Claude => "Claude Code",
LlmTool::Codex => "Codex",
LlmTool::OpenCode => "OpenCode",
}
}
pub fn recommended_config(&self) -> &str {
&RECOMMENDED_COMMANDS[self.config_heading()]
}
}
impl std::fmt::Display for LlmTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.command_name())
}
}
fn parse_recommended_commands(config: &str) -> HashMap<String, String> {
let mut commands = HashMap::new();
let mut current_heading: Option<String> = None;
for line in config.lines() {
if let Some(heading) = line.strip_prefix("# ### ") {
current_heading = Some(heading.trim().to_string());
continue;
}
if let Some(toml_part) = line.strip_prefix("# ")
&& toml_part.starts_with("command = ")
&& let Some(heading) = current_heading.take()
{
let table: toml::Table = toml_part.parse().unwrap();
let cmd = table["command"].as_str().unwrap().to_string();
commands.insert(heading, cmd);
}
}
commands
}
fn command_exists(cmd: &str) -> bool {
#[cfg(windows)]
let check_cmd = "where";
#[cfg(not(windows))]
let check_cmd = "which";
std::process::Command::new(check_cmd)
.arg(cmd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn format_command_for_display(command: &str) -> String {
toml::Value::String(command.to_string()).to_string()
}
fn detect_llm_tool_with(checker: impl Fn(&str) -> bool) -> Option<LlmTool> {
if checker("claude") {
Some(LlmTool::Claude)
} else if checker("codex") {
Some(LlmTool::Codex)
} else if checker("opencode") {
Some(LlmTool::OpenCode)
} else {
None
}
}
pub fn detect_llm_tool() -> Option<LlmTool> {
detect_llm_tool_with(command_exists)
}
pub fn prompt_commit_generation(config: &mut UserConfig) -> anyhow::Result<bool> {
let is_tty = io::stdin().is_terminal() && io::stderr().is_terminal();
if config
.commit_generation(None)
.command
.as_ref()
.is_some_and(|s| !s.trim().is_empty())
{
return Ok(false);
}
if config.skip_commit_generation_prompt {
return Ok(false);
}
if !is_tty {
return Ok(false);
}
let Some(tool) = detect_llm_tool() else {
let _ = config.set_skip_commit_generation_prompt(None);
return Ok(false);
};
let command = tool.recommended_config();
let formatted_command = format_command_for_display(command);
let config_preview = format!("[commit.generation]\ncommand = {formatted_command}");
let response = prompt_yes_no_preview(
&cformat!("Configure <bold>{tool}</> for commit messages?"),
|| {
eprintln!(
"{}",
info_message(cformat!(
"Would add to <bold>~/.config/worktrunk/config.toml</>:"
))
);
eprintln!("{}", format_toml(&config_preview));
eprintln!();
},
)?;
match response {
PromptResponse::Accepted => {
let command = command.to_string();
if let Err(e) = config.set_commit_generation_command(command.clone(), None) {
log::error!("Failed to save config: {}", e);
eprintln!(
"{}",
hint_message(cformat!(
"Config save failed; add manually to <underline>~/.config/worktrunk/config.toml</>"
))
);
return Ok(false);
}
eprintln!("{}", success_message(cformat!("Added to user config:")));
eprintln!("{}", format_toml(&config_preview));
eprintln!(
"{}",
hint_message(cformat!("View config: <underline>wt config show</>"))
);
eprintln!();
Ok(true)
}
PromptResponse::Declined => {
let _ = config.set_skip_commit_generation_prompt(None);
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
#[test]
fn test_llm_tool_command_name() {
assert_eq!(LlmTool::Claude.command_name(), "claude");
assert_eq!(LlmTool::Codex.command_name(), "codex");
assert_eq!(LlmTool::OpenCode.command_name(), "opencode");
}
#[test]
fn test_llm_tool_recommended_config() {
assert_snapshot!(LlmTool::Claude.recommended_config(), @"CLAUDECODE= MAX_THINKING_TOKENS=0 claude -p --no-session-persistence --model=haiku --tools='' --disable-slash-commands --setting-sources='' --system-prompt=''");
assert_snapshot!(LlmTool::Codex.recommended_config(), @r#"codex exec -m gpt-5.1-codex-mini -c model_reasoning_effort='low' -c system_prompt='' --sandbox=read-only --json - | jq -sr '[.[] | select(.item.type? == "agent_message")] | last.item.text'"#);
assert_snapshot!(LlmTool::OpenCode.recommended_config(), @"opencode run -m anthropic/claude-haiku-4.5 --variant fast");
}
#[test]
fn test_parse_recommended_commands() {
let config = "\
# ### MyTool
#
# [commit.generation]
# command = \"echo hello\"
#
# ### OtherTool
#
# [commit.generation]
# command = \"jq -sr '[.[] | select(.type? == \\\"msg\\\")]'\"
";
let commands = parse_recommended_commands(config);
assert_eq!(commands.len(), 2);
assert_eq!(commands["MyTool"], "echo hello");
assert_eq!(
commands["OtherTool"],
r#"jq -sr '[.[] | select(.type? == "msg")]'"#
);
}
#[test]
fn test_parse_recommended_commands_ignores_non_command_lines() {
let config = "\
# ### ToolA
#
# [commit.generation]
# template = \"not a command\"
# ### ToolB
# command = \"real command\"
";
let commands = parse_recommended_commands(config);
assert_eq!(commands.len(), 1);
assert_eq!(commands["ToolB"], "real command");
}
#[test]
fn test_llm_tool_display() {
assert_eq!(format!("{}", LlmTool::Claude), "claude");
assert_eq!(format!("{}", LlmTool::Codex), "codex");
assert_eq!(format!("{}", LlmTool::OpenCode), "opencode");
}
#[test]
fn test_format_command_produces_valid_toml() {
let result = format_command_for_display("echo hello");
assert_eq!(result, "\"echo hello\"");
let cmd = LlmTool::Claude.recommended_config();
let result = format_command_for_display(cmd);
assert_snapshot!(result, @r#""CLAUDECODE= MAX_THINKING_TOKENS=0 claude -p --no-session-persistence --model=haiku --tools='' --disable-slash-commands --setting-sources='' --system-prompt=''""#);
}
#[test]
fn test_format_command_special_chars() {
let result = format_command_for_display(r#"echo "hello""#);
assert_snapshot!(result, @r#"'echo "hello"'"#);
}
#[test]
fn test_command_exists_known_command() {
#[cfg(not(windows))]
assert!(command_exists("which"));
#[cfg(windows)]
assert!(command_exists("where"));
}
#[test]
fn test_command_exists_nonexistent() {
assert!(!command_exists("__nonexistent_command_12345__"));
}
#[test]
fn test_detect_llm_tool_priority() {
assert_eq!(detect_llm_tool_with(|_| true), Some(LlmTool::Claude));
assert_eq!(
detect_llm_tool_with(|cmd| cmd != "claude"),
Some(LlmTool::Codex),
);
assert_eq!(
detect_llm_tool_with(|cmd| cmd == "opencode"),
Some(LlmTool::OpenCode),
);
assert_eq!(detect_llm_tool_with(|_| false), None);
}
}