use anstyle::{AnsiColor, Color, Color as AnsiStyleColor, Style};
use crossterm::style::Attribute;
use termimad::{CompoundStyle, MadSkin, TableBorderChars};
use unicode_width::UnicodeWidthStr;
use worktrunk::styling::{
DEFAULT_HELP_WIDTH, format_bash_with_gutter, format_toml, format_with_gutter, wrap_styled_text,
};
static HELP_TABLE_BORDERS: TableBorderChars = TableBorderChars {
horizontal: '─',
vertical: ' ',
top_left_corner: ' ',
top_right_corner: ' ',
bottom_right_corner: ' ',
bottom_left_corner: ' ',
top_junction: ' ',
right_junction: ' ',
bottom_junction: ' ',
left_junction: ' ',
cross: ' ', };
fn help_table_skin() -> MadSkin {
let mut skin = MadSkin::no_style();
skin.table_border_chars = &HELP_TABLE_BORDERS;
skin.inline_code = CompoundStyle::with_attr(Attribute::Dim);
skin
}
pub(crate) fn render_markdown_in_help_with_width(help: &str, width: Option<usize>) -> String {
let green = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)));
let mut result = String::new();
let mut in_code_block = false;
let mut code_block_lang = String::new();
let mut code_block_lines: Vec<&str> = Vec::new();
let mut table_lines: Vec<&str> = Vec::new();
let lines: Vec<&str> = help.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim_start();
if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
i += 1;
continue;
}
if let Some(after_fence) = trimmed.strip_prefix("```") {
if !in_code_block {
code_block_lang = after_fence.trim().to_string();
code_block_lines.clear();
in_code_block = true;
} else {
let content = code_block_lines.join("\n");
let formatted = match code_block_lang.as_str() {
"toml" => format_toml(&content),
"console" => {
let stripped = content
.lines()
.map(|l| l.strip_prefix("$ ").unwrap_or(l))
.collect::<Vec<_>>()
.join("\n");
format_bash_with_gutter(&stripped)
}
"bash" | "sh" => format_bash_with_gutter(&content),
_ => {
let dim = Style::new().dimmed();
let dimmed = code_block_lines
.iter()
.map(|l| format!("{dim}{l}{dim:#}"))
.collect::<Vec<_>>()
.join("\n");
format_with_gutter(&dimmed, None)
}
};
result.push_str(&formatted);
result.push('\n');
in_code_block = false;
}
i += 1;
continue;
}
if in_code_block {
code_block_lines.push(line);
i += 1;
continue;
}
if trimmed.starts_with('|') && trimmed.ends_with('|') {
table_lines.clear();
while i < lines.len() {
let tl = lines[i].trim_start();
if tl.starts_with('|') && tl.ends_with('|') {
table_lines.push(lines[i]);
i += 1;
} else {
break;
}
}
result.push_str(&render_table(&table_lines, width));
continue;
}
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
let dimmed = Style::new().dimmed();
let rule_width = width.unwrap_or(40);
let rule: String = "─".repeat(rule_width);
result.push_str(&format!("{dimmed}{rule}{dimmed:#}\n"));
i += 1;
continue;
}
if let Some(header_text) = trimmed.strip_prefix("#### ") {
let bold = Style::new().bold();
result.push_str(&format!("{bold}{header_text}{bold:#}\n"));
} else if let Some(header_text) = trimmed.strip_prefix("### ") {
result.push_str(&format!("{green}{header_text}{green:#}\n"));
} else if let Some(header_text) = trimmed.strip_prefix("## ") {
let bold_green = Style::new()
.bold()
.fg_color(Some(Color::Ansi(AnsiColor::Green)));
result.push_str(&format!("{bold_green}{header_text}{bold_green:#}\n"));
} else if let Some(header_text) = trimmed.strip_prefix("# ") {
result.push_str(&format!("{green}{}{green:#}\n", header_text.to_uppercase()));
} else {
let formatted = render_inline_formatting(line);
if let Some(w) = width {
for wrapped_line in wrap_styled_text(&formatted, w) {
result.push_str(&wrapped_line);
result.push('\n');
}
} else {
result.push_str(&formatted);
result.push('\n');
}
}
i += 1;
}
colorize_status_symbols(&result)
}
fn render_table(lines: &[&str], max_width: Option<usize>) -> String {
render_table_with_termimad(lines, "", max_width)
}
pub(crate) fn render_data_table(headers: &[&str], rows: &[Vec<String>]) -> String {
let header_line = format!("| {} |", headers.join(" | "));
let separator = format!("|{}|", vec!["---"; headers.len()].join("|"));
let row_lines: Vec<String> = rows
.iter()
.map(|row| format!("| {} |", row.join(" | ")))
.collect();
let mut lines: Vec<&str> = Vec::with_capacity(rows.len() + 2);
lines.push(&header_line);
lines.push(&separator);
lines.extend(row_lines.iter().map(|s| s.as_str()));
render_table_with_termimad(&lines, "", None)
}
fn render_table_with_termimad(lines: &[&str], indent: &str, max_width: Option<usize>) -> String {
if lines.is_empty() {
return String::new();
}
let processed: Vec<String> = lines
.iter()
.map(|line| unescape_table_pipes(&strip_markdown_links(line)))
.collect();
let markdown = processed.join("\n");
let width = max_width
.map(|w| w.saturating_sub(indent.width()))
.unwrap_or(DEFAULT_HELP_WIDTH);
let skin = help_table_skin();
let rendered = skin.text(&markdown, Some(width)).to_string();
if indent.is_empty() {
rendered
} else {
rendered
.lines()
.map(|line| format!("{indent}{line}"))
.collect::<Vec<_>>()
.join("\n")
+ "\n"
}
}
fn unescape_table_pipes(line: &str) -> String {
line.replace(r"\|", "|")
}
fn strip_markdown_links(line: &str) -> String {
let mut result = String::new();
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '[' {
let mut link_text = String::new();
let mut found_close = false;
let mut bracket_depth = 0;
for c in chars.by_ref() {
if c == '[' {
bracket_depth += 1;
link_text.push(c);
} else if c == ']' {
if bracket_depth == 0 {
found_close = true;
break;
}
bracket_depth -= 1;
link_text.push(c);
} else {
link_text.push(c);
}
}
if found_close && chars.peek() == Some(&'(') {
chars.next(); for c in chars.by_ref() {
if c == ')' {
break;
}
}
result.push_str(&link_text);
} else {
result.push('[');
result.push_str(&link_text);
if found_close {
result.push(']');
}
}
} else {
result.push(ch);
}
}
result
}
fn render_inline_formatting(line: &str) -> String {
let line = strip_markdown_links(line);
let bold = Style::new().bold();
let code = Style::new().dimmed();
let mut result = String::new();
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '`' {
let mut code_content = String::new();
for c in chars.by_ref() {
if c == '`' {
break;
}
code_content.push(c);
}
result.push_str(&format!("{code}{code_content}{code:#}"));
} else if ch == '*' && chars.peek() == Some(&'*') {
chars.next(); let mut bold_content = String::new();
while let Some(c) = chars.next() {
if c == '*' && chars.peek() == Some(&'*') {
chars.next(); break;
}
bold_content.push(c);
}
let processed_content = render_inline_formatting(&bold_content);
result.push_str(&format!("{bold}{processed_content}{bold:#}"));
} else {
result.push(ch);
}
}
result
}
fn colorize_status_symbols(text: &str) -> String {
let error = Style::new().fg_color(Some(AnsiStyleColor::Ansi(AnsiColor::Red)));
let warning = Style::new().fg_color(Some(AnsiStyleColor::Ansi(AnsiColor::Yellow)));
let success = Style::new().fg_color(Some(AnsiStyleColor::Ansi(AnsiColor::Green)));
let progress = Style::new().fg_color(Some(AnsiStyleColor::Ansi(AnsiColor::Blue)));
let disabled = Style::new().fg_color(Some(AnsiStyleColor::Ansi(AnsiColor::BrightBlack)));
let working_tree = Style::new().fg_color(Some(AnsiStyleColor::Ansi(AnsiColor::Cyan)));
let dim = Style::new().dimmed();
let replace_dim = |text: String, sym: &str, style: Style| -> String {
let dimmed = format!("{dim}{sym}{dim:#}");
let colored = format!("{style}{sym}{style:#}");
text.replace(&dimmed, &colored)
};
let mut result = text.to_string();
result = replace_dim(result, "+", working_tree);
result = replace_dim(result, "!", working_tree);
result = replace_dim(result, "?", working_tree);
result = replace_dim(result, "✘", error);
result = replace_dim(result, "⤴", warning);
result = replace_dim(result, "⤵", warning);
result = replace_dim(result, "✗", warning);
result = replace_dim(result, "⚑", error);
result = replace_dim(result, "⊟", warning);
result = replace_dim(result, "⊞", warning);
let dimmed_bullet = format!("{dim}●{dim:#}");
result = result
.replace(
&format!("{dimmed_bullet} green"),
&format!("{success}●{success:#} green"),
)
.replace(
&format!("{dimmed_bullet} blue"),
&format!("{progress}●{progress:#} blue"),
)
.replace(
&format!("{dimmed_bullet} red"),
&format!("{error}●{error:#} red"),
)
.replace(
&format!("{dimmed_bullet} yellow"),
&format!("{warning}●{warning:#} yellow"),
)
.replace(
&format!("{dimmed_bullet} gray"),
&format!("{disabled}●{disabled:#} gray"),
)
.replace(
&format!("{dim}⚠{dim:#} yellow"),
&format!("{warning}⚠{warning:#} yellow"),
);
result = result
.replace("● passed", &format!("{success}●{success:#} passed"))
.replace("● running", &format!("{progress}●{progress:#} running"))
.replace("● failed", &format!("{error}●{error:#} failed"))
.replace("● conflicts", &format!("{warning}●{warning:#} conflicts"))
.replace("● no-ci", &format!("{disabled}●{disabled:#} no-ci"));
result
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
fn render_markdown_in_help(help: &str) -> String {
render_markdown_in_help_with_width(help, None)
}
#[test]
fn test_render_inline_formatting_strips_links() {
assert_eq!(render_inline_formatting("[text](url)"), "text");
assert_eq!(
render_inline_formatting("See [wt hook](@/hook.md) for details"),
"See wt hook for details"
);
}
#[test]
fn test_render_inline_formatting_nested_brackets() {
assert_eq!(
render_inline_formatting("[text [with brackets]](url)"),
"text [with brackets]"
);
}
#[test]
fn test_render_inline_formatting_multiple_links() {
assert_eq!(render_inline_formatting("[a](b) and [c](d)"), "a and c");
}
#[test]
fn test_render_inline_formatting_malformed_links() {
assert_eq!(render_inline_formatting("[text]"), "[text]");
assert_eq!(render_inline_formatting("[text"), "[text");
assert_eq!(render_inline_formatting("[text] more"), "[text] more");
}
#[test]
fn test_render_inline_formatting_preserves_bold_and_code() {
assert_eq!(
render_inline_formatting("**bold** and `code`"),
"\u{1b}[1mbold\u{1b}[0m and \u{1b}[2mcode\u{1b}[0m"
);
}
#[test]
fn test_unescape_table_pipes() {
assert_eq!(unescape_table_pipes(r"a \| b"), "a | b");
assert_eq!(
unescape_table_pipes(r"\| start \| end \|"),
"| start | end |"
);
assert_eq!(unescape_table_pipes("no pipes here"), "no pipes here");
assert_eq!(unescape_table_pipes("a | b"), "a | b");
}
#[test]
fn test_render_inline_formatting_styles() {
let code = render_inline_formatting("`code`");
assert_snapshot!(code, @"[2mcode[0m");
let bold = render_inline_formatting("**bold**");
assert_snapshot!(bold, @"[1mbold[0m");
let bold_code = render_inline_formatting("**`wt list`:**");
assert_snapshot!(bold_code, @"[1m[2mwt list[0m:[0m");
let mixed = render_inline_formatting("text `code` more **bold** end");
assert_snapshot!(mixed, @"text [2mcode[0m more [1mbold[0m end");
let link_code = render_inline_formatting("See [`wt hook`](@/hook.md) for details");
assert_snapshot!(link_code, @"See [2mwt hook[0m for details");
let unclosed_code = render_inline_formatting("`unclosed");
assert_snapshot!(unclosed_code, @"[2munclosed[0m");
let unclosed_bold = render_inline_formatting("**unclosed");
assert_snapshot!(unclosed_bold, @"[1munclosed[0m");
}
#[test]
fn test_render_markdown_in_help_headers() {
let md = "# Title\n## Section\n### Subsection\n#### Nested";
let result = render_markdown_in_help(md);
assert_snapshot!(result, @"
[32mTITLE[0m
[1m[32mSection[0m
[32mSubsection[0m
[1mNested[0m
");
}
#[test]
fn test_render_markdown_in_help_horizontal_rule() {
let result = render_markdown_in_help("before\n\n---\n\n## Section");
assert_snapshot!(result, @"
before
[2m────────────────────────────────────────[0m
[1m[32mSection[0m
");
}
#[test]
fn test_render_markdown_in_help_console_strips_dollar() {
let result = render_markdown_in_help("```console\n$ wt step deploy\n$ wt list\n```");
let stripped = ansi_str::AnsiStr::ansi_strip(&result);
assert!(
!stripped.contains("$ "),
"terminal output should not contain '$ ' prompt: {stripped}"
);
assert!(stripped.contains("wt step deploy"));
assert!(stripped.contains("wt list"));
}
#[test]
fn test_render_markdown_in_help_code_block() {
let result = render_markdown_in_help("```\ncode here\n```\nafter");
assert_snapshot!(result, @"
[107m [0m [2mcode here[0m
after
");
}
#[test]
fn test_render_markdown_in_help_toml_code_block() {
let result = render_markdown_in_help("```toml\n[section]\nkey = \"value\"\n```\nafter");
assert_snapshot!(result, @r#"
[107m [0m [2m[36m[section][0m
[107m [0m [2mkey = [0m[2m[32m"value"[0m
after
"#);
}
#[test]
fn test_render_markdown_in_help_html_comment() {
let result = render_markdown_in_help("<!-- comment -->\nvisible");
assert_snapshot!(result, @"visible");
}
#[test]
fn test_render_markdown_in_help_plain_text() {
let result = render_markdown_in_help("Just plain text");
assert_snapshot!(result, @"Just plain text");
}
#[test]
fn test_render_markdown_in_help_table() {
let result = render_markdown_in_help("| A | B |\n| - | - |\n| 1 | 2 |");
assert_snapshot!(result, @"
A B
─── ───
1 2
");
}
#[test]
fn test_render_data_table_basic() {
let rows = vec![
vec!["Alice".into(), "42".into()],
vec!["Bob".into(), "7".into()],
];
let result = render_data_table(&["Name", "Score"], &rows);
assert_snapshot!(result, @"
Name Score
───── ─────
Alice 42
Bob 7
");
}
#[test]
fn test_render_data_table_empty_rows() {
let result = render_data_table(&["A", "B"], &[]);
assert_snapshot!(result, @"
A B
─── ───
");
}
#[test]
fn test_colorize_status_symbols() {
let dim = Style::new().dimmed();
let working_tree = colorize_status_symbols(&format!("{dim}+{dim:#} staged"));
assert_snapshot!(working_tree, @"[36m+[0m staged");
let conflicts = colorize_status_symbols(&format!("{dim}✘{dim:#} conflicts"));
assert_snapshot!(conflicts, @"[31m✘[0m conflicts");
let git_ops = colorize_status_symbols(&format!("{dim}⤴{dim:#} rebase"));
assert_snapshot!(git_ops, @"[33m⤴[0m rebase");
let ci_passed = colorize_status_symbols("● passed");
assert_snapshot!(ci_passed, @"[32m●[0m passed");
let ci_failed = colorize_status_symbols("● failed");
assert_snapshot!(ci_failed, @"[31m●[0m failed");
let ci_running = colorize_status_symbols("● running");
assert_snapshot!(ci_running, @"[34m●[0m running");
}
#[test]
fn test_colorize_status_symbols_no_change() {
let input = "plain text here";
let result = colorize_status_symbols(input);
assert_eq!(result, input);
}
#[test]
fn test_render_table_escaped_pipe() {
let lines = vec![
"| Category | Symbol | Meaning |",
"| --- | --- | --- |",
r"| Remote | `\|` | In sync |",
];
let result = render_table(&lines, None);
assert_snapshot!(result, @"
Category Symbol Meaning
──────── ────── ───────
Remote [2m|[0m In sync
");
}
#[test]
fn test_render_table_column_alignment() {
let lines = vec![
"| Short | LongerHeader |",
"| ----- | ------------ |",
"| A | B |",
];
let result = render_table(&lines, None);
assert_snapshot!(result, @"
Short LongerHeader
───── ────────────
A B
");
}
#[test]
fn test_render_table_uneven_columns() {
let lines = vec!["| A | B | C |", "| --- | --- | --- |", "| 1 | 2 |"];
let result = render_table(&lines, None);
assert_snapshot!(result, @"
A B C
─── ─── ───
1 2
");
}
#[test]
fn test_render_table_no_separator() {
let lines = vec!["| A | B |", "| 1 | 2 |"];
let result = render_table(&lines, None);
assert_snapshot!(result, @"
A B
1 2
");
}
#[test]
fn test_render_markdown_in_help_table_wrapping() {
let help = r#"### Other environment variables
| Variable | Purpose |
|----------|---------|
| `WORKTRUNK_BIN` | Override binary path for shell wrappers (useful for testing dev builds) |
| WORKTRUNK_CONFIG_PATH | Override user config file location |
| `WORKTRUNK_MAX_CONCURRENT_COMMANDS` | Max parallel git commands (default: 32). Lower if hitting resource limits. |
| NO_COLOR | Disable colored output (standard) |
"#;
let result = render_markdown_in_help_with_width(help, Some(80));
assert_snapshot!(result, @"
[32mOther environment variables[0m
Variable Purpose
───────────────────────────────── ────────────────────────────────────────────
[2mWORKTRUNK_BIN[0m Override binary path for shell wrappers
(useful for testing dev builds)
WORKTRUNK_CONFIG_PATH Override user config file location
[2mWORKTRUNK_MAX_CONCURRENT_COMMANDS[0m Max parallel git commands (default: 32).
Lower if hitting resource limits.
NO_COLOR Disable colored output (standard)
");
}
}