use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use anyhow::Context;
use color_print::cformat;
use worktrunk::config::{
ProjectConfig, UserConfig, default_system_config_path, find_unknown_project_keys,
find_unknown_user_keys, system_config_path,
};
use worktrunk::git::Repository;
use worktrunk::path::format_path_for_display;
use worktrunk::shell::{Shell, scan_for_detection_details};
use worktrunk::shell_exec::Cmd;
use worktrunk::styling::{
error_message, format_bash_with_gutter, format_heading, format_toml, format_with_gutter,
hint_message, info_message, success_message, warning_message,
};
use super::state::require_user_config_path;
use crate::cli::{SwitchFormat, version_str};
use crate::commands::configure_shell::{ConfigAction, scan_shell_configs};
use crate::commands::list::ci_status::{CiPlatform, CiToolsStatus, platform_for_repo};
use crate::help_pager::show_help_in_pager;
use crate::llm::test_commit_generation;
use crate::output;
pub fn handle_config_show(full: bool, format: SwitchFormat) -> anyhow::Result<()> {
if format == SwitchFormat::Json {
return handle_config_show_json();
}
let mut show_output = String::new();
let has_system_config = render_system_config(&mut show_output)?;
if has_system_config {
show_output.push('\n');
}
render_user_config(&mut show_output, has_system_config)?;
show_output.push('\n');
render_project_config(&mut show_output)?;
show_output.push('\n');
render_shell_status(&mut show_output)?;
if is_claude_available() {
show_output.push('\n');
render_claude_code_status(&mut show_output)?;
}
if is_opencode_available() {
show_output.push('\n');
render_opencode_status(&mut show_output)?;
}
if full {
show_output.push('\n');
render_diagnostics(&mut show_output)?;
}
show_output.push('\n');
render_runtime_info(&mut show_output)?;
if let Err(e) = show_help_in_pager(&show_output, true) {
log::debug!("Pager invocation failed: {}", e);
worktrunk::styling::eprintln!("{}", show_output);
}
Ok(())
}
fn handle_config_show_json() -> anyhow::Result<()> {
let user_path = require_user_config_path()?;
let user_exists = user_path.exists();
let user_config = if user_exists {
Some(serde_json::to_value(&UserConfig::load()?)?)
} else {
None
};
let (project_path, project_config) = if let Ok(repo) = Repository::current() {
let path = repo.current_worktree().root()?.join(".config/wt.toml");
let config = repo.load_project_config()?;
(
Some(path),
config.map(|c| serde_json::to_value(&c)).transpose()?,
)
} else {
(None, None)
};
let system_path = system_config_path().or_else(default_system_config_path);
let system_exists = system_path.as_ref().is_some_and(|p| p.exists());
let output = serde_json::json!({
"user": {
"path": user_path,
"exists": user_exists,
"config": user_config,
},
"project": {
"path": project_path,
"exists": project_path.as_ref().is_some_and(|p| p.exists()),
"config": project_config,
},
"system": {
"path": system_path,
"exists": system_exists,
},
});
worktrunk::styling::println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
pub(super) fn is_claude_available() -> bool {
if let Ok(val) = std::env::var("WORKTRUNK_TEST_CLAUDE_INSTALLED") {
return val == "1";
}
which::which("claude").is_ok()
}
pub(super) fn home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.map(PathBuf::from)
.or_else(dirs::home_dir)
}
pub(super) fn is_plugin_installed() -> bool {
let Some(home) = home_dir() else {
return false;
};
let plugins_file = home.join(".claude/plugins/installed_plugins.json");
let Ok(content) = std::fs::read_to_string(&plugins_file) else {
return false;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
return false;
};
json.get("plugins")
.and_then(|p| p.get("worktrunk@worktrunk"))
.is_some()
}
pub(super) fn is_statusline_configured() -> bool {
let Some(home) = home_dir() else {
return false;
};
let settings_file = home.join(".claude/settings.json");
let Ok(content) = std::fs::read_to_string(&settings_file) else {
return false;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
return false;
};
json.get("statusLine")
.and_then(|s| s.get("command"))
.and_then(|c| c.as_str())
.is_some_and(|cmd| cmd.contains("wt "))
}
fn git_version() -> Option<String> {
let output = Cmd::new("git").arg("--version").run().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.trim()
.strip_prefix("git version ")
.map(|s| s.to_string())
}
fn check_zsh_compinit_missing() -> bool {
if std::env::var("WORKTRUNK_TEST_COMPINIT_CONFIGURED").is_ok() {
return false; }
if std::env::var("WORKTRUNK_TEST_COMPINIT_MISSING").is_ok() {
return true; }
let Ok(output) = Cmd::new("zsh")
.args(["--no-globalrcs", "-ic", "(( $+functions[compdef] ))"])
.env("ZSH_DISABLE_COMPFIX", "true")
.run()
else {
return false; };
!output.status.success()
}
fn render_claude_code_status(out: &mut String) -> anyhow::Result<()> {
writeln!(out, "{}", format_heading("CLAUDE CODE", None))?;
let plugin_installed = is_plugin_installed();
if plugin_installed {
writeln!(out, "{}", success_message("Plugin installed"))?;
} else {
writeln!(
out,
"{}",
hint_message(cformat!(
"Plugin not installed. To install, run <underline>wt config plugins claude install</>"
))
)?;
}
let statusline_configured = is_statusline_configured();
if statusline_configured {
writeln!(out, "{}", success_message("Statusline configured"))?;
} else {
writeln!(
out,
"{}",
hint_message(cformat!(
"Statusline not configured. To configure, run <underline>wt config plugins claude install-statusline</>"
))
)?;
}
Ok(())
}
fn is_opencode_available() -> bool {
if let Ok(val) = std::env::var("WORKTRUNK_TEST_OPENCODE_INSTALLED") {
return val == "1";
}
which::which("opencode").is_ok()
}
fn render_opencode_status(out: &mut String) -> anyhow::Result<()> {
writeln!(out, "{}", format_heading("OPENCODE", None))?;
let plugin_installed = super::opencode::is_plugin_installed();
let plugin_exists = super::opencode::plugin_file_exists();
if plugin_installed {
writeln!(out, "{}", success_message("Plugin installed"))?;
} else if plugin_exists {
writeln!(
out,
"{}",
hint_message(cformat!(
"Plugin outdated. To update, run <underline>wt config plugins opencode install</>"
))
)?;
} else {
writeln!(
out,
"{}",
hint_message(cformat!(
"Plugin not installed. To install, run <underline>wt config plugins opencode install</>"
))
)?;
}
Ok(())
}
fn render_runtime_info(out: &mut String) -> anyhow::Result<()> {
let cmd = crate::binary_name();
let version = version_str();
writeln!(out, "{}", format_heading("OTHER", None))?;
writeln!(
out,
"{}",
info_message(cformat!("{cmd}: <bold>{version}</>"))
)?;
if let Some(git_version) = git_version() {
writeln!(
out,
"{}",
info_message(cformat!("git: <bold>{git_version}</>"))
)?;
}
let hyperlinks_supported =
worktrunk::styling::supports_hyperlinks(worktrunk::styling::Stream::Stderr);
let status = if hyperlinks_supported {
"active"
} else {
"inactive"
};
writeln!(
out,
"{}",
info_message(cformat!("Hyperlinks: <bold>{status}</>"))
)?;
Ok(())
}
fn render_diagnostics(out: &mut String) -> anyhow::Result<()> {
writeln!(out, "{}", format_heading("DIAGNOSTICS", None))?;
let repo = Repository::current()?;
let platform = platform_for_repo(&repo, None);
match platform {
Some(CiPlatform::GitHub) => {
let ci_tools = CiToolsStatus::detect(None);
render_ci_tool_status(
out,
"gh",
"GitHub",
ci_tools.gh_installed,
ci_tools.gh_authenticated,
)?;
}
Some(CiPlatform::GitLab) => {
let ci_tools = CiToolsStatus::detect(None);
render_ci_tool_status(
out,
"glab",
"GitLab",
ci_tools.glab_installed,
ci_tools.glab_authenticated,
)?;
}
None => {
writeln!(
out,
"{}",
hint_message("CI status requires GitHub or GitLab remote")
)?;
}
}
render_version_check(out)?;
let config = UserConfig::load()?;
let project_id = Repository::current()
.ok()
.and_then(|r| r.project_identifier().ok());
let commit_config = config.commit_generation(project_id.as_deref());
if !commit_config.is_configured() {
writeln!(out, "{}", hint_message("Commit generation not configured"))?;
} else {
let command_display = commit_config.command.as_ref().unwrap().clone();
match test_commit_generation(&commit_config) {
Ok(message) => {
writeln!(
out,
"{}",
success_message(cformat!(
"Commit generation working (<bold>{command_display}</>)"
))
)?;
writeln!(out, "{}", format_with_gutter(&message, None))?;
}
Err(e) => {
writeln!(
out,
"{}",
error_message(cformat!(
"Commit generation failed (<bold>{command_display}</>)"
))
)?;
writeln!(out, "{}", format_with_gutter(&e.to_string(), None))?;
}
}
}
Ok(())
}
fn render_system_config(out: &mut String) -> anyhow::Result<bool> {
let Some(system_path) = system_config_path() else {
return Ok(false);
};
writeln!(
out,
"{}",
format_heading(
"SYSTEM CONFIG",
Some(&format!("@ {}", format_path_for_display(&system_path)))
)
)?;
let contents =
std::fs::read_to_string(&system_path).context("Failed to read system config file")?;
if contents.trim().is_empty() {
writeln!(out, "{}", hint_message("Empty file (no system defaults)"))?;
return Ok(true);
}
if let Err(e) = toml::from_str::<UserConfig>(&contents) {
writeln!(out, "{}", error_message("Invalid config"))?;
writeln!(out, "{}", format_with_gutter(&e.to_string(), None))?;
} else {
out.push_str(&warn_unknown_keys::<UserConfig>(&find_unknown_user_keys(
&contents,
)));
}
writeln!(out, "{}", format_toml(&contents))?;
Ok(true)
}
fn render_user_config(out: &mut String, has_system_config: bool) -> anyhow::Result<()> {
let config_path = require_user_config_path()?;
writeln!(
out,
"{}",
format_heading(
"USER CONFIG",
Some(&format!("@ {}", format_path_for_display(&config_path)))
)
)?;
if !config_path.exists() {
writeln!(
out,
"{}",
hint_message(cformat!(
"Not found; to create one, run <underline>wt config create</>"
))
)?;
return Ok(());
}
let contents = std::fs::read_to_string(&config_path).context("Failed to read config file")?;
if contents.trim().is_empty() {
writeln!(out, "{}", hint_message("Empty file (using defaults)"))?;
return Ok(());
}
let has_deprecations = if let Ok(result) = worktrunk::config::check_and_migrate(
&config_path,
&contents,
true,
"User config",
None,
false, ) {
if let Some(info) = result.info {
out.push_str(&worktrunk::config::format_deprecation_details(&info));
true
} else {
false
}
} else {
false
};
if let Err(e) = toml::from_str::<UserConfig>(&contents) {
writeln!(out, "{}", error_message("Invalid config"))?;
writeln!(out, "{}", format_with_gutter(&e.to_string(), None))?;
} else {
out.push_str(&warn_unknown_keys::<UserConfig>(&find_unknown_user_keys(
&contents,
)));
}
if has_deprecations {
writeln!(out, "{}", info_message("Current config:"))?;
}
writeln!(out, "{}", format_toml(&contents))?;
if !has_system_config {
render_system_config_hint(out)?;
}
Ok(())
}
fn render_system_config_hint(out: &mut String) -> anyhow::Result<()> {
if let Some(path) = default_system_config_path() {
writeln!(
out,
"{}",
hint_message(cformat!(
"Optional system config not found @ <dim>{}</>",
format_path_for_display(&path)
))
)?;
}
Ok(())
}
pub(super) fn warn_unknown_keys<C: worktrunk::config::WorktrunkConfig>(
unknown_keys: &HashMap<String, toml::Value>,
) -> String {
let mut out = String::new();
let mut keys: Vec<_> = unknown_keys.keys().collect();
keys.sort();
for key in keys {
let msg = match worktrunk::config::classify_unknown_key::<C>(key) {
worktrunk::config::UnknownKeyKind::DeprecatedHandled => continue,
worktrunk::config::UnknownKeyKind::DeprecatedWrongConfig {
other_description,
canonical_display,
} => {
cformat!("Key <bold>{key}</> belongs in {other_description} as {canonical_display}")
}
worktrunk::config::UnknownKeyKind::WrongConfig { other_description } => {
cformat!("Key <bold>{key}</> belongs in {other_description} (will be ignored)")
}
worktrunk::config::UnknownKeyKind::Unknown => {
cformat!("Unknown key <bold>{key}</> will be ignored")
}
};
let _ = writeln!(out, "{}", warning_message(msg));
}
out
}
fn render_project_config(out: &mut String) -> anyhow::Result<()> {
let repo = match Repository::current() {
Ok(repo) => repo,
Err(_) => {
writeln!(
out,
"{}",
cformat!(
"<dim>{}</>",
format_heading("PROJECT CONFIG", Some("Not in a git repository"))
)
)?;
return Ok(());
}
};
let config_path = match repo.project_config_path() {
Ok(Some(path)) => path,
_ => {
writeln!(
out,
"{}",
cformat!(
"<dim>{}</>",
format_heading("PROJECT CONFIG", Some("Not in a git repository"))
)
)?;
return Ok(());
}
};
writeln!(
out,
"{}",
format_heading(
"PROJECT CONFIG",
Some(&format!("@ {}", format_path_for_display(&config_path)))
)
)?;
if !config_path.exists() {
writeln!(out, "{}", hint_message("Not found"))?;
return Ok(());
}
let contents = std::fs::read_to_string(&config_path).context("Failed to read config file")?;
if contents.trim().is_empty() {
writeln!(out, "{}", hint_message("Empty file"))?;
return Ok(());
}
let is_main_worktree = !repo.current_worktree().is_linked().unwrap_or(true);
let has_deprecations = if let Ok(result) = worktrunk::config::check_and_migrate(
&config_path,
&contents,
is_main_worktree,
"Project config",
Some(&repo),
false, ) {
if let Some(info) = result.info {
out.push_str(&worktrunk::config::format_deprecation_details(&info));
true
} else {
false
}
} else {
false
};
if let Err(e) = toml::from_str::<ProjectConfig>(&contents) {
writeln!(out, "{}", error_message("Invalid config"))?;
writeln!(out, "{}", format_with_gutter(&e.to_string(), None))?;
} else {
out.push_str(&warn_unknown_keys::<ProjectConfig>(
&find_unknown_project_keys(&contents),
));
}
if has_deprecations {
writeln!(out, "{}", info_message("Current config:"))?;
}
writeln!(out, "{}", format_toml(&contents))?;
Ok(())
}
fn render_shell_status(out: &mut String) -> anyhow::Result<()> {
writeln!(out, "{}", format_heading("SHELL INTEGRATION", None))?;
let shell_active = output::is_shell_integration_active();
if shell_active {
writeln!(out, "{}", info_message("Shell integration active"))?;
} else {
writeln!(out, "{}", warning_message("Shell integration not active"))?;
let invocation = crate::invocation_path();
let is_git_subcommand = crate::is_git_subcommand();
let mut debug_lines = vec![cformat!("Invoked as: <bold>{invocation}</>")];
if let Ok(exe_path) = std::env::current_exe() {
let exe_display = format_path_for_display(&exe_path);
let invocation_canonical = std::fs::canonicalize(&invocation).ok();
let exe_canonical = std::fs::canonicalize(&exe_path).ok();
if invocation_canonical != exe_canonical {
debug_lines.push(cformat!("Running from: <bold>{exe_display}</>"));
}
}
let shell_env = std::env::var("SHELL").ok().filter(|s| !s.is_empty());
if let Some(shell_env) = &shell_env {
debug_lines.push(cformat!("$SHELL: <bold>{shell_env}</>"));
} else if let Some(detected) = worktrunk::shell::current_shell() {
debug_lines.push(cformat!(
"Detected shell: <bold>{detected}</> (via PSModulePath)"
));
}
if is_git_subcommand {
debug_lines.push("Git subcommand: yes (GIT_EXEC_PATH set)".to_string());
}
writeln!(out, "{}", format_with_gutter(&debug_lines.join("\n"), None))?;
}
writeln!(out)?;
let cmd = crate::binary_name();
let scan_result = match scan_shell_configs(None, true, &cmd) {
Ok(r) => r,
Err(e) => {
writeln!(
out,
"{}",
hint_message(format!("Could not determine shell status: {e}"))
)?;
return Ok(());
}
};
let detection_results = scan_for_detection_details(&cmd).unwrap_or_default();
let legacy_fish_conf_d = Shell::legacy_fish_conf_d_path(&cmd).ok();
let legacy_fish_has_integration = legacy_fish_conf_d.as_ref().is_some_and(|legacy_path| {
detection_results
.iter()
.any(|d| d.path == *legacy_path && !d.matched_lines.is_empty())
});
let mut any_not_configured = false;
let mut has_any_unmatched = false;
for result in &scan_result.configured {
let shell = result.shell;
let path = format_path_for_display(&result.path);
let what = if matches!(shell, Shell::Fish) {
"shell extension"
} else {
"shell extension & completions"
};
match result.action {
ConfigAction::AlreadyExists => {
let detection = detection_results
.iter()
.find(|d| d.path == result.path && !d.matched_lines.is_empty());
let location = if let Some(det) = detection {
if let Some(first_line) = det.matched_lines.first() {
format!("{}:{}", path, first_line.line_number)
} else {
path.to_string()
}
} else {
path.to_string()
};
writeln!(
out,
"{}",
info_message(cformat!(
"<bold>{shell}</>: Already configured {what} @ {location}"
))
)?;
if let Some(det) = detection {
for detected in &det.matched_lines {
writeln!(out, "{}", format_bash_with_gutter(detected.content.trim()))?;
}
let uses_exe = det.matched_lines.iter().any(|m| m.content.contains(".exe"));
if uses_exe {
writeln!(
out,
"{}",
hint_message(cformat!(
"Creates shell function <bold>{cmd}</>. Aliases should use <underline>{cmd}</>, not <underline>{cmd}.exe</>"
))
)?;
}
}
if matches!(shell, Shell::Zsh) && check_zsh_compinit_missing() {
writeln!(
out,
"{}",
warning_message(
"Completions won't work; add to ~/.zshrc before the wt line:"
)
)?;
writeln!(
out,
"{}",
format_with_gutter("autoload -Uz compinit && compinit", None)
)?;
}
if matches!(shell, Shell::Fish)
&& let Ok(completion_path) = shell.completion_path(&cmd)
{
let completion_display = format_path_for_display(&completion_path);
if completion_path.exists() {
writeln!(
out,
"{}",
info_message(cformat!(
"<bold>{shell}</>: Already configured completions @ {completion_display}"
))
)?;
} else {
any_not_configured = true;
writeln!(
out,
"{}",
hint_message(format!("{shell}: Not configured completions"))
)?;
}
}
if !shell_active {
let verify_cmd = match shell {
Shell::PowerShell => format!("Get-Command {cmd}"),
_ => format!("type {cmd}"),
};
let hint = hint_message(cformat!(
"To verify wrapper loaded: <underline>{verify_cmd}</>"
));
writeln!(out, "{hint}")?;
}
}
ConfigAction::WouldAdd | ConfigAction::WouldCreate => {
if matches!(shell, Shell::Fish) && legacy_fish_has_integration {
let legacy_path = legacy_fish_conf_d
.as_ref()
.map(|p| format_path_for_display(p))
.unwrap_or_default();
writeln!(
out,
"{}",
info_message(cformat!(
"Fish integration found in deprecated location @ <bold>{legacy_path}</>"
))
)?;
let canonical_path = Shell::Fish
.config_paths(&cmd)
.ok()
.and_then(|p| p.into_iter().next())
.map(|p| format_path_for_display(&p))
.unwrap_or_else(|| "~/.config/fish/functions/".to_string());
writeln!(
out,
"{}",
hint_message(cformat!(
"To migrate to <underline>{canonical_path}</>, run <underline>{cmd} config shell install fish</>"
))
)?;
} else if shell.is_wrapper_based()
&& matches!(result.action, ConfigAction::WouldAdd)
{
any_not_configured = true;
let warning = warning_message(cformat!(
"<bold>{shell}</>: Outdated shell extension @ {path}"
));
let hint = hint_message(cformat!(
"To update, run <underline>{cmd} config shell install {shell}</>"
));
writeln!(out, "{warning}\n{hint}")?;
} else if shell_active && Some(shell) == worktrunk::shell::current_shell() {
writeln!(
out,
"{}",
info_message(cformat!(
"<bold>{shell}</>: Configured {what} (not found in {path})"
))
)?;
} else {
any_not_configured = true;
writeln!(
out,
"{}",
hint_message(format!("{shell}: Not configured {what}"))
)?;
}
}
_ => {} }
}
for (shell, path) in &scan_result.skipped {
if matches!(shell, Shell::Fish) && legacy_fish_has_integration {
let legacy_path = legacy_fish_conf_d
.as_ref()
.map(|p| format_path_for_display(p))
.unwrap_or_default();
let canonical_path = Shell::Fish
.config_paths(&cmd)
.ok()
.and_then(|p| p.into_iter().next())
.map(|p| format_path_for_display(&p))
.unwrap_or_else(|| "~/.config/fish/functions/".to_string());
writeln!(
out,
"{}",
info_message(cformat!(
"Fish integration found in deprecated location @ <bold>{legacy_path}</>"
))
)?;
writeln!(
out,
"{}",
hint_message(cformat!(
"To migrate to <underline>{canonical_path}</>, run <underline>{cmd} config shell install fish</>"
))
)?;
continue;
}
let path = format_path_for_display(path);
writeln!(
out,
"{}",
info_message(cformat!("<dim>{shell}: Skipped; {path} not found</>"))
)?;
}
if any_not_configured {
writeln!(
out,
"{}",
hint_message(cformat!(
"To configure, run <underline>{cmd} config shell install</>"
))
)?;
}
let confirmed_paths: HashSet<&Path> = scan_result
.configured
.iter()
.filter(|r| {
r.shell.is_wrapper_based() || matches!(r.action, ConfigAction::AlreadyExists)
})
.map(|r| r.path.as_path())
.collect();
for detection in &detection_results {
if !detection.unmatched_candidates.is_empty()
&& detection.matched_lines.is_empty()
&& !confirmed_paths.contains(detection.path.as_path())
{
has_any_unmatched = true;
let path = format_path_for_display(&detection.path);
let location = if let Some(first) = detection.unmatched_candidates.first() {
format!("{}:{}", path, first.line_number)
} else {
path.to_string()
};
writeln!(
out,
"{}",
warning_message(cformat!(
"Found <bold>{cmd}</> in <bold>{location}</> but not detected as integration:"
))
)?;
for detected in &detection.unmatched_candidates {
writeln!(out, "{}", format_bash_with_gutter(detected.content.trim()))?;
}
let uses_exe = detection
.unmatched_candidates
.iter()
.any(|m| m.content.contains(".exe"));
if uses_exe {
writeln!(
out,
"{}",
hint_message(cformat!(
"Note: <bold>{cmd}.exe</> creates shell function <bold>{cmd}</>. \
Aliases should use <underline>{cmd}</>, not <underline>{cmd}.exe</>"
))
)?;
}
}
}
for detection in &detection_results {
for alias in &detection.bypass_aliases {
let path = format_path_for_display(&detection.path);
let location = format!("{}:{}", path, alias.line_number);
writeln!(
out,
"{}",
warning_message(cformat!(
"Alias <bold>{}</> bypasses shell integration — won't auto-cd",
alias.alias_name
))
)?;
writeln!(out, "{}", format_bash_with_gutter(alias.content.trim()))?;
writeln!(
out,
"{}",
hint_message(cformat!(
"Change to <underline>alias {}=\"{cmd}\"</> @ {location}",
alias.alias_name
))
)?;
}
}
let has_any_configured = scan_result
.configured
.iter()
.any(|r| matches!(r.action, ConfigAction::AlreadyExists));
if has_any_unmatched && !has_any_configured {
let unmatched_summary: Vec<_> = detection_results
.iter()
.filter(|r| {
!r.unmatched_candidates.is_empty()
&& r.matched_lines.is_empty()
&& !confirmed_paths.contains(r.path.as_path())
})
.flat_map(|r| {
r.unmatched_candidates
.iter()
.map(|d| d.content.trim().to_string())
})
.collect();
let body = format!(
"Shell integration not detected despite config containing `{cmd}`.\n\n\
**Unmatched lines:**\n```\n{}\n```\n\n\
**Expected behavior:** These lines should be detected as shell integration.",
unmatched_summary.join("\n")
);
let issue_url = format!(
"https://github.com/max-sixty/worktrunk/issues/new?title={}&body={}",
urlencoding::encode("Shell integration detection false negative"),
urlencoding::encode(&body)
);
let quoted = if unmatched_summary.len() == 1 {
format!("`{}`", unmatched_summary[0])
} else {
format!(
"`{}` (and {} more)",
unmatched_summary[0],
unmatched_summary.len() - 1
)
};
writeln!(
out,
"{}",
hint_message(format!(
"If {quoted} is shell integration, report a false negative: {issue_url}"
))
)?;
}
Ok(())
}
pub(super) fn render_ci_tool_status(
out: &mut String,
tool: &str,
platform: &str,
installed: bool,
authenticated: bool,
) -> anyhow::Result<()> {
if installed {
if authenticated {
writeln!(
out,
"{}",
success_message(cformat!("<bold>{tool}</> installed & authenticated"))
)?;
} else {
writeln!(
out,
"{}",
warning_message(cformat!(
"<bold>{tool}</> installed but not authenticated; run <bold>{tool} auth login</>"
))
)?;
}
} else {
writeln!(
out,
"{}",
hint_message(cformat!(
"<bold>{tool}</> not found ({platform} CI status unavailable)"
))
)?;
}
Ok(())
}
fn render_version_check(out: &mut String) -> anyhow::Result<()> {
match fetch_latest_version() {
Ok(latest) => {
let current = crate::cli::version_str();
let current_semver = env!("CARGO_PKG_VERSION");
if is_newer_version(&latest, current_semver) {
writeln!(
out,
"{}",
info_message(cformat!(
"Update available: <bold>{latest}</> (current: {current})"
))
)?;
} else {
writeln!(
out,
"{}",
success_message(cformat!("Up to date (<bold>{current}</>)"))
)?;
}
}
Err(e) => {
log::debug!("Version check failed: {e}");
writeln!(out, "{}", hint_message("Version check unavailable"))?;
}
}
Ok(())
}
fn fetch_latest_version() -> anyhow::Result<String> {
if let Ok(version) = std::env::var("WORKTRUNK_TEST_LATEST_VERSION") {
if version == "error" {
anyhow::bail!("simulated fetch failure");
}
return Ok(version);
}
let user_agent = format!(
"worktrunk/{} (https://worktrunk.dev)",
env!("CARGO_PKG_VERSION")
);
let output = Cmd::new("curl")
.args([
"--silent",
"--fail",
"--max-time",
"5",
"--header",
&format!("User-Agent: {user_agent}"),
"https://api.github.com/repos/max-sixty/worktrunk/releases/latest",
])
.run()?;
if !output.status.success() {
anyhow::bail!("GitHub API request failed");
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
let tag = json["tag_name"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing tag_name in response"))?;
Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
}
fn is_newer_version(latest: &str, current: &str) -> bool {
let parse = |s: &str| -> Option<(u32, u32, u32)> {
let mut parts = s.splitn(3, '.');
Some((
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
))
};
match (parse(latest), parse(current)) {
(Some(l), Some(c)) => l > c,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_git_version_returns_version() {
let version = git_version();
assert!(version.is_some());
let version = version.unwrap();
assert!(version.chars().next().unwrap().is_ascii_digit());
assert!(version.contains('.'));
}
#[test]
fn test_is_newer_version() {
assert!(is_newer_version("0.24.0", "0.23.2"));
assert!(is_newer_version("1.0.0", "0.99.99"));
assert!(is_newer_version("0.23.3", "0.23.2"));
assert!(is_newer_version("0.23.2", "0.23.1"));
assert!(!is_newer_version("0.23.2", "0.23.2"));
assert!(!is_newer_version("0.23.1", "0.23.2"));
assert!(!is_newer_version("0.22.0", "0.23.2"));
assert!(!is_newer_version("invalid", "0.23.2"));
assert!(!is_newer_version("0.23.2", "invalid"));
}
}