use std::process;
use ansi_str::AnsiStr;
use clap::ColorChoice;
use clap::error::ErrorKind;
use worktrunk::docs::convert_dollar_console_to_terminal;
use worktrunk::styling::{eprintln, println};
use crate::cli;
pub fn maybe_handle_help_with_pager(alias_help_context: Option<crate::commands::HelpContext>) {
let args: Vec<String> = std::env::args().collect();
let use_pager = args.iter().any(|a| a == "--help");
if args.iter().any(|a| a == "--help-page") {
let plain = args.iter().any(|a| a == "--plain");
handle_help_page(&args, plain);
process::exit(0);
}
if args.iter().any(|a| a == "--help-description") {
handle_help_description(&args);
process::exit(0);
}
if args.iter().any(|a| a == "--help-md") {
let mut cmd = cli::build_command();
cmd = crate::completion::inject_hook_subcommands(cmd);
cmd = cmd.color(ColorChoice::Never);
let filtered_args: Vec<String> = args
.iter()
.map(|a| {
if a == "--help-md" {
"--help".to_string()
} else {
a.clone()
}
})
.collect();
if let Err(err) = cmd.try_get_matches_from_mut(filtered_args)
&& matches!(
err.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
)
{
let output = err
.render()
.to_string()
.replace("```text\n", "```\n")
.replace("```console\n", "```bash\n");
print!("{output}");
process::exit(0);
}
}
let mut cmd = cli::build_command();
cmd = crate::completion::inject_hook_subcommands(cmd);
cmd = cmd.color(clap::ColorChoice::Always);
if let Err(err) = cmd.try_get_matches_from_mut(&args) {
match err.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => {
let clap_output = err.render().ansi().to_string();
let clap_output = match alias_help_context {
Some(ctx) => crate::commands::augment_help(&clap_output, ctx),
None => clap_output,
};
let width = worktrunk::styling::terminal_width();
let help =
crate::md_help::render_markdown_in_help_with_width(&clap_output, Some(width));
if let Err(e) = crate::help_pager::show_help_in_pager(&help, use_pager) {
log::debug!("Pager invocation failed: {}", e);
println!("{}", help);
}
process::exit(0);
}
ErrorKind::DisplayVersion => {
print!("{}", err);
process::exit(0);
}
_ => {
}
}
}
}
fn help_reference_with_color(
command_path: &[&str],
width: Option<usize>,
color: ColorChoice,
) -> String {
let output = help_reference_inner(command_path, width, color);
if matches!(color, ColorChoice::Always) {
worktrunk::styling::strip_osc8_hyperlinks(&output)
} else {
output
}
}
fn help_reference_inner(command_path: &[&str], width: Option<usize>, color: ColorChoice) -> String {
let mut args: Vec<String> = vec!["wt".to_string()];
args.extend(command_path.iter().map(|s| s.to_string()));
args.push("--help".to_string());
let mut cmd = cli::build_command();
cmd = crate::completion::inject_hook_subcommands(cmd);
cmd = cmd.color(color);
if let Some(w) = width {
cmd = cmd.term_width(w);
}
let help_block = if let Err(err) = cmd.try_get_matches_from_mut(args)
&& matches!(
err.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
) {
let rendered = err.render();
let text = if matches!(color, ColorChoice::Always) {
rendered.ansi().to_string()
} else {
rendered.to_string()
};
text.replace("```text\n", "```\n")
.replace("```console\n", "```bash\n")
} else {
return String::new();
};
if let Some(after_help_start) = find_after_help_start(&help_block) {
help_block[..after_help_start].trim_end().to_string()
} else {
help_block
}
}
fn find_after_help_start(help: &str) -> Option<usize> {
let mut past_global_options = false;
let mut saw_blank_after_options = false;
let mut blank_offset = None;
let mut offset = 0;
for line in help.lines() {
let plain_line = strip_ansi_codes(line);
if plain_line.starts_with("Global Options:") {
past_global_options = true;
offset += line.len() + 1;
continue;
}
if past_global_options {
if plain_line.is_empty() {
saw_blank_after_options = true;
blank_offset = Some(offset);
} else if saw_blank_after_options && !plain_line.starts_with(' ') {
return blank_offset;
} else if plain_line.starts_with(' ') {
saw_blank_after_options = false;
}
}
offset += line.len() + 1;
}
None
}
fn strip_ansi_codes(s: &str) -> String {
s.ansi_strip().into_owned()
}
fn extract_about_and_subtitle(cmd: &clap::Command) -> (Option<String>, Option<String>) {
let about = cmd.get_about().map(|s| s.to_string());
let long_about = cmd.get_long_about().map(|s| s.to_string());
let subtitle = match (&about, &long_about) {
(Some(short), Some(long)) if long.starts_with(short) => {
let rest = long[short.len()..].trim_start();
if rest.is_empty() {
None
} else {
Some(rest.to_string())
}
}
_ => None,
};
(about, subtitle)
}
fn handle_help_description(args: &[String]) {
let mut cmd = cli::build_command();
cmd = crate::completion::inject_hook_subcommands(cmd);
cmd = cmd.color(ColorChoice::Never);
let subcommand = args
.iter()
.filter(|a| *a != "--help-description" && !a.starts_with('-') && !a.ends_with("/wt"))
.find(|a| !a.contains("target/") && *a != "wt");
let Some(subcommand) = subcommand else {
eprintln!("Usage: wt <command> --help-description");
return;
};
let Some(sub) = cmd.find_subcommand(subcommand) else {
eprintln!("Unknown command: {subcommand}");
return;
};
let (about, subtitle) = extract_about_and_subtitle(sub);
let description = match (&about, &subtitle) {
(Some(def), Some(sub)) => format!("{def}. {sub}"),
(Some(def), None) => format!("{def}."),
_ => String::new(),
};
print!("{description}");
}
fn handle_help_page(args: &[String], plain: bool) {
let mut cmd = cli::build_command();
cmd = crate::completion::inject_hook_subcommands(cmd);
cmd = cmd.color(ColorChoice::Never);
let subcommand = args
.iter()
.filter(|a| *a != "--help-page" && !a.starts_with('-') && !a.ends_with("/wt"))
.find(|a| {
!a.contains("target/") && *a != "wt"
});
let Some(subcommand) = subcommand else {
eprintln!(
"Usage: wt <command> --help-page
Commands with pages: merge, switch, remove, list"
);
return;
};
let sub = cmd.find_subcommand(subcommand);
let Some(sub) = sub else {
eprintln!("Unknown command: {subcommand}");
return;
};
let parent_name = format!("wt {}", subcommand);
let raw_help = combine_command_docs(sub);
let raw_help = if plain {
raw_help
} else {
convert_dollar_console_to_terminal(&raw_help)
};
let raw_help = raw_help.replace("```console\n", "```bash\n");
let subdoc_marker = "<!-- subdoc:";
let (main_content, subdoc_content) = if let Some(pos) = raw_help.find(subdoc_marker) {
(&raw_help[..pos], Some(&raw_help[pos..]))
} else {
(raw_help.as_str(), None)
};
let main_help = if plain {
strip_demo_placeholders(main_content)
} else {
let text = expand_demo_placeholders(main_content);
post_process_for_html(&text)
};
let reference_block = help_reference_with_color(
&[subcommand],
Some(100),
if plain {
ColorChoice::Never
} else {
ColorChoice::Always
},
);
if plain {
std::println!("# wt {subcommand}");
std::println!();
} else {
std::println!(
"<!-- ⚠️ AUTO-GENERATED from `wt {subcommand} --help-page` — edit cli.rs to update -->"
);
std::println!();
}
std::println!("{}", main_help.trim());
std::println!();
std::println!("## Command reference");
std::println!();
std::println!("```");
std::print!("{}", reference_block.trim());
std::println!();
std::println!("```");
if let Some(subdocs) = subdoc_content {
let subdocs = if plain {
subdocs.to_string()
} else {
post_process_for_html(subdocs)
};
let subdocs_expanded = expand_subdoc_placeholders(&subdocs, sub, &parent_name, plain);
std::println!();
std::println!("# Subcommands");
std::println!();
std::println!("{}", subdocs_expanded.trim());
}
if !plain {
std::println!();
std::println!("<!-- END AUTO-GENERATED from `wt {subcommand} --help-page` -->");
}
}
fn post_process_for_html(text: &str) -> String {
let text = move_experimental_from_headings(text);
text
.replace("`●` green", "<span style='color:#0a0'>●</span> green")
.replace("`●` blue", "<span style='color:#00a'>●</span> blue")
.replace("`●` red", "<span style='color:#a00'>●</span> red")
.replace("`●` yellow", "<span style='color:#a60'>●</span> yellow")
.replace("`⚠` yellow", "<span style='color:#a60'>⚠</span> yellow")
.replace("`●` gray", "<span style='color:#888'>●</span> gray")
.replace(
"[experimental]",
"<span class=\"badge-experimental\"></span>",
)
.replace(
"Open an issue at https://github.com/max-sixty/worktrunk.",
"[Open an issue](https://github.com/max-sixty/worktrunk/issues).",
)
.replace(
"routing — https://worktrunk.dev/tips-patterns/#dev-server-per-worktree",
"routing — see [Tips & Patterns](@/tips-patterns.md#dev-server-per-worktree)",
)
.replace(
"hooks reference — https://worktrunk.dev/tips-patterns/#database-per-worktree",
"hooks reference — see [Tips & Patterns](@/tips-patterns.md#database-per-worktree)",
)
.replace(
"depends on the copy — https://worktrunk.dev/tips-patterns/#eliminate-cold-starts",
"depends on the copy — see [Tips & Patterns](@/tips-patterns.md#eliminate-cold-starts)",
)
.replace(
"expensive tests and builds in `pre-merge` — https://worktrunk.dev/tips-patterns/#progressive-validation",
"expensive tests and builds in `pre-merge` — see [Tips & Patterns](@/tips-patterns.md#progressive-validation)",
)
.replace(
"per-environment deploys — https://worktrunk.dev/tips-patterns/#target-specific-hooks",
"per-environment deploys — see [Tips & Patterns](@/tips-patterns.md#target-specific-hooks)",
)
.replace(
"```\n\
▲ repo needs approval to execute 3 commands:\n\
\n\
○ pre-start install:\n\
\x20\x20\x20npm ci\n\
○ pre-start build:\n\
\x20\x20\x20cargo build --release\n\
○ pre-start env:\n\
\x20\x20\x20echo 'PORT={{ branch | hash_port }}' > .env.local\n\
\n\
❯ Allow and remember? [y/N]\n\
```",
"{% terminal() %}\n\
<span class=\"y\">▲ <b>repo</b> needs approval to execute <b>3</b> commands:</span>\n\
\n\
<span class=\"d\">○</span> pre-start <b>install</b>:\n\
<span style='background:var(--bright-white,#fff)'> </span> <span class=\"d\"><span class=\"b\">npm</span> ci</span>\n\
<span class=\"d\">○</span> pre-start <b>build</b>:\n\
<span style='background:var(--bright-white,#fff)'> </span> <span class=\"d\"><span class=\"b\">cargo</span> build <span class=\"c\">--release</span></span>\n\
<span class=\"d\">○</span> pre-start <b>env</b>:\n\
<span style='background:var(--bright-white,#fff)'> </span> <span class=\"d\"><span class=\"b\">echo</span> <span class=\"g\">'PORT={{ branch | hash_port }}'</span> <span class=\"c\">></span> .env.local</span>\n\
\n\
<span class=\"c\">❯</span> Allow and remember? <b>[y/N]</b>\n\
{% end %}",
)
}
fn move_experimental_from_headings(text: &str) -> String {
if !text.contains(" [experimental]") {
return text.to_string();
}
let mut result = String::with_capacity(text.len());
let mut in_code_block = false;
for line in text.lines() {
if line.trim_start().starts_with("```") {
in_code_block = !in_code_block;
}
if !in_code_block
&& line.starts_with('#')
&& let Some(heading) = line.strip_suffix(" [experimental]")
{
result.push_str(heading);
result.push_str("\n\n[experimental]");
} else {
result.push_str(line);
}
result.push('\n');
}
if !text.ends_with('\n') {
result.pop();
}
result
}
fn increase_heading_levels(content: &str) -> String {
let mut result = Vec::new();
let mut in_code_block = false;
for line in content.lines() {
if line.trim_start().starts_with("```") {
in_code_block = !in_code_block;
result.push(line.to_string());
continue;
}
if !in_code_block && line.starts_with('#') {
result.push(format!("#{}", line));
} else {
result.push(line.to_string());
}
}
let mut output = result.join("\n");
if content.ends_with('\n') {
output.push('\n');
}
output
}
fn expand_subdoc_placeholders(
text: &str,
parent_cmd: &clap::Command,
parent_name: &str,
plain: bool,
) -> String {
const PREFIX: &str = "<!-- subdoc: ";
const SUFFIX: &str = " -->";
let mut result = text.to_string();
while let Some(start) = result.find(PREFIX) {
let after_prefix = start + PREFIX.len();
if let Some(end_offset) = result[after_prefix..].find(SUFFIX) {
let subcommand_name = result[after_prefix..after_prefix + end_offset].trim();
let end = after_prefix + end_offset + SUFFIX.len();
let replacement = if let Some(sub) = parent_cmd
.get_subcommands()
.find(|s| s.get_name() == subcommand_name)
{
format_subcommand_section(sub, parent_name, subcommand_name, plain)
} else {
format!(
"<!-- subdoc error: subcommand '{}' not found -->",
subcommand_name
)
};
result.replace_range(start..end, &replacement);
} else {
break;
}
}
result
}
fn combine_command_docs(cmd: &clap::Command) -> String {
let (about, subtitle) = extract_about_and_subtitle(cmd);
let after_long_help = cmd
.get_after_long_help()
.map(|s| s.to_string())
.unwrap_or_default();
match (&about, &subtitle) {
(Some(def), Some(sub)) => format!("{def}. {sub}\n\n{after_long_help}"),
(Some(def), None) => format!("{def}.\n\n{after_long_help}"),
(None, Some(sub)) => format!("{sub}\n\n{after_long_help}"),
(None, None) => after_long_help,
}
}
fn format_subcommand_section(
sub: &clap::Command,
parent_name: &str,
subcommand_name: &str,
plain: bool,
) -> String {
let full_command = format!("{} {}", parent_name, subcommand_name);
let raw_help = combine_command_docs(sub);
let raw_help = if plain {
raw_help
} else {
convert_dollar_console_to_terminal(&raw_help)
};
let raw_help = raw_help.replace("```console\n", "```bash\n");
let (has_experimental, raw_help) = if let Some(rest) = raw_help.strip_prefix("[experimental] ")
{
(true, rest.to_string())
} else {
(false, raw_help)
};
let subdoc_marker = "<!-- subdoc:";
let (main_content, subdoc_content) = if let Some(pos) = raw_help.find(subdoc_marker) {
(&raw_help[..pos], Some(&raw_help[pos..]))
} else {
(raw_help.as_str(), None)
};
let main_help = if plain {
let text = increase_heading_levels(main_content);
strip_demo_placeholders(&text)
} else {
let text = increase_heading_levels(main_content);
post_process_for_html(&text)
};
let command_path: Vec<&str> = parent_name
.strip_prefix("wt ")
.unwrap_or(parent_name)
.split_whitespace()
.chain(std::iter::once(subcommand_name))
.collect();
let reference_block = help_reference_with_color(
&command_path,
Some(100),
if plain {
ColorChoice::Never
} else {
ColorChoice::Always
},
);
let mut section = format!("## {full_command}\n\n");
if has_experimental {
if plain {
section.push_str("[experimental]\n\n");
} else {
section.push_str("<span class=\"badge-experimental\"></span>\n\n");
}
}
if !main_help.is_empty() {
section.push_str(main_help.trim());
section.push_str("\n\n");
}
section.push_str("### Command reference\n\n```\n");
section.push_str(reference_block.trim());
section.push_str("\n```\n");
if let Some(subdocs) = subdoc_content {
let subdocs = if plain {
subdocs.to_string()
} else {
post_process_for_html(subdocs)
};
let subdocs_expanded = expand_subdoc_placeholders(&subdocs, sub, &full_command, plain);
section.push('\n');
section.push_str(subdocs_expanded.trim());
section.push('\n');
}
section
}
fn expand_demo_placeholders(text: &str) -> String {
const PREFIX: &str = "<!-- demo: ";
const SUFFIX: &str = " -->";
let mut result = text.to_string();
while let Some(start) = result.find(PREFIX) {
let after_prefix = start + PREFIX.len();
if let Some(end_offset) = result[after_prefix..].find(SUFFIX) {
let content = &result[after_prefix..after_prefix + end_offset];
let mut parts = content.split_whitespace();
let filename = parts.next().unwrap_or("");
let dimensions = parts.next();
let alt_text = filename.trim_end_matches(".gif").replace('-', " ");
let dim_attrs = dimensions
.and_then(|d| d.split_once('x'))
.map(|(w, h)| format!(" width=\"{w}\" height=\"{h}\""))
.unwrap_or_default();
let replacement = format!(
"<figure class=\"demo\">\n<picture>\n <source srcset=\"/assets/docs/dark/{filename}\" media=\"(prefers-color-scheme: dark)\">\n <img src=\"/assets/docs/light/{filename}\" alt=\"{alt_text} demo\"{dim_attrs}>\n</picture>\n</figure>\n"
);
let end = after_prefix + end_offset + SUFFIX.len();
result.replace_range(start..end, &replacement);
} else {
break;
}
}
result
}
fn strip_demo_placeholders(text: &str) -> String {
const PREFIX: &str = "<!-- demo: ";
const SUFFIX: &str = " -->";
let mut result = text.to_string();
while let Some(start) = result.find(PREFIX) {
let after_prefix = start + PREFIX.len();
if let Some(end_offset) = result[after_prefix..].find(SUFFIX) {
let end = after_prefix + end_offset + SUFFIX.len();
let end = if result[end..].starts_with('\n') {
end + 1
} else {
end
};
result.replace_range(start..end, "");
} else {
break;
}
}
result
}