use std::path::PathBuf;
use ansi_str::AnsiStr;
use anyhow::Context;
use color_print::cformat;
use minijinja::{Environment, context};
use worktrunk::git::Repository;
use worktrunk::path::format_path_for_display;
use worktrunk::shell_exec::Cmd;
use worktrunk::styling::{eprintln, hint_message, info_message, warning_message};
use crate::cli::version_str;
use crate::output;
const REPORT_TEMPLATE: &str = r#"## Diagnostic Report
**Generated:** {{ timestamp }}
**Command:** `{{ command }}`
**Result:** {{ context }}
<details>
<summary>Environment</summary>
```
wt {{ version }} ({{ os }} {{ arch }})
git {{ git_version }}
Shell integration: {{ shell_integration }}
```
</details>
<details>
<summary>Worktrees</summary>
```
{{ worktree_list }}
```
</details>
{%- if config_show %}
<details>
<summary>Config</summary>
```
{{ config_show }}
```
</details>
{%- endif %}
{%- if verbose_log %}
<details>
<summary>Verbose log</summary>
```
{{ verbose_log }}
```
</details>
{%- endif %}
"#;
pub(crate) struct DiagnosticReport {
content: String,
}
impl DiagnosticReport {
pub fn collect(repo: &Repository, command: &str, context: String) -> Self {
let content = Self::format_report(repo, command, &context);
Self { content }
}
fn format_report(repo: &Repository, command: &str, context: &str) -> String {
let context = strip_ansi_codes(context);
let timestamp = worktrunk::utils::now_iso8601();
let version = version_str();
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let git_version = git_version().unwrap_or_else(|_| "(unknown)".to_string());
let shell_integration = if output::is_shell_integration_active() {
"active"
} else {
"inactive"
};
let worktree_list = repo
.run_command(&["worktree", "list", "--porcelain"])
.map(|s| s.trim_end().to_string())
.unwrap_or_else(|_| "(failed to get worktree list)".to_string());
let config_show = config_show_output(repo);
let verbose_log = crate::verbose_log::log_file_path()
.and_then(|path| std::fs::read_to_string(&path).ok())
.map(|content| truncate_log(content.trim()))
.filter(|s| !s.is_empty());
let env = Environment::new();
let tmpl = env.template_from_str(REPORT_TEMPLATE).unwrap();
tmpl.render(context! {
timestamp,
command,
context,
version,
os,
arch,
git_version,
shell_integration,
worktree_list,
config_show,
verbose_log,
})
.unwrap()
}
pub fn write_diagnostic_file(&self, repo: &Repository) -> Option<PathBuf> {
let log_dir = repo.wt_logs_dir();
std::fs::create_dir_all(&log_dir).ok()?;
let path = log_dir.join("diagnostic.md");
std::fs::write(&path, &self.content).ok()?;
Some(path)
}
}
pub(crate) fn issue_hint() -> String {
cformat!("To create a diagnostic file, run with <underline>-vv</>")
}
pub(crate) fn write_if_verbose(verbose: u8, command_line: &str, error_msg: Option<&str>) {
if verbose < 2 {
return;
}
let Ok(repo) = Repository::current() else {
return;
};
if repo.current_worktree().git_dir().is_err() {
return;
}
let context = match error_msg {
Some(msg) => format!("Command failed: {msg}"),
None => "Command completed successfully".to_string(),
};
let report = DiagnosticReport::collect(&repo, command_line, context);
match report.write_diagnostic_file(&repo) {
Some(path) => {
let path_display = format_path_for_display(&path);
eprintln!(
"{}",
info_message(format!("Diagnostic saved: {path_display}"))
);
if is_gh_installed() {
let path_str = format_path_for_display(&path);
let issue_url = "https://github.com/max-sixty/worktrunk/issues/new?body=%23%23%20Gist%0A%0A%5BPaste%20gist%20URL%5D%0A%0A%23%23%20Description%0A%0A%5BDescribe%20the%20issue%5D";
eprintln!(
"{}",
hint_message(cformat!(
"To report a bug, create a secret gist with <underline>gh gist create --web {path_str}</> and reference it from an issue at <underline>{issue_url}</>"
))
);
}
}
None => {
eprintln!("{}", warning_message("Failed to write diagnostic file"));
}
}
}
fn is_gh_installed() -> bool {
Cmd::new("gh")
.arg("--version")
.run()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn strip_ansi_codes(s: &str) -> String {
s.ansi_strip().into_owned()
}
fn truncate_log(content: &str) -> String {
const MAX_LOG_SIZE: usize = 50 * 1024;
if content.len() <= MAX_LOG_SIZE {
return content.to_string();
}
let start = content.len() - MAX_LOG_SIZE;
let start = content[start..]
.find('\n')
.map(|i| start + i + 1)
.unwrap_or(start);
format!("(log truncated to last ~50KB)\n{}", &content[start..])
}
fn git_version() -> anyhow::Result<String> {
let output = Cmd::new("git")
.arg("--version")
.run()
.context("Failed to run git --version")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let version = stdout
.trim()
.strip_prefix("git version ")
.unwrap_or(stdout.trim())
.to_string();
Ok(version)
}
fn config_show_output(repo: &Repository) -> Option<String> {
let mut output = String::new();
if let Some(user_config_path) = worktrunk::config::config_path() {
output.push_str(&format_config_section(&user_config_path, "User config"));
}
if let Ok(Some(project_config_path)) = repo.project_config_path() {
output.push_str(&format!(
"\n{}",
format_config_section(&project_config_path, "Project config")
));
}
if output.is_empty() {
None
} else {
Some(output.trim().to_string())
}
}
fn format_config_section(path: &std::path::Path, label: &str) -> String {
let mut output = format!("{}: {}\n", label, path.display());
if path.exists() {
match std::fs::read_to_string(path) {
Ok(content) if content.trim().is_empty() => output.push_str("(empty file)\n"),
Ok(content) => {
let content = if content.len() > 4000 {
format!("{}...\n(truncated)", &content[..4000])
} else {
content
};
output.push_str(&content);
if !output.ends_with('\n') {
output.push('\n');
}
}
Err(e) => output.push_str(&format!("(read failed: {})\n", e)),
}
} else {
output.push_str("(file not found)\n");
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_format_config_section_file_not_found() {
let result = format_config_section(std::path::Path::new("/nonexistent/path.toml"), "Test");
insta::assert_snapshot!(result, @"
Test: /nonexistent/path.toml
(file not found)
");
}
#[test]
fn test_format_config_section_empty_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("empty.toml");
std::fs::write(&path, "").unwrap();
let result = format_config_section(&path, "Test");
assert!(result.contains("(empty file)"));
}
#[test]
fn test_format_config_section_with_content() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
std::fs::write(&path, "key = \"value\"\n").unwrap();
let result = format_config_section(&path, "Test");
assert!(result.contains("key = \"value\""));
}
#[test]
fn test_format_config_section_adds_trailing_newline() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
std::fs::write(&path, "no-newline").unwrap();
let result = format_config_section(&path, "Test");
assert!(result.ends_with('\n'));
}
#[test]
fn test_format_config_section_truncates_long_content() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("big.toml");
let content = "x".repeat(5000);
std::fs::write(&path, &content).unwrap();
let result = format_config_section(&path, "Test");
assert!(result.contains("(truncated)"));
assert!(result.len() < 5000);
}
#[test]
fn test_strip_ansi_codes() {
let esc = '\x1b';
let input = format!("{esc}[31mred{esc}[0m and {esc}[32mgreen{esc}[0m");
let result = strip_ansi_codes(&input);
assert_eq!(result, "red and green");
}
#[test]
fn test_truncate_log_small_content() {
let content = "small log content";
let result = truncate_log(content);
assert_eq!(result, content);
}
#[test]
fn test_truncate_log_large_content() {
let content = "x".repeat(60 * 1024); let result = truncate_log(&content);
assert!(result.starts_with("(log truncated to last ~50KB)"));
assert!(result.len() < 55 * 1024);
}
}