worktrunk 0.47.0

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
//! Documentation text transformations.
//!
//! Converts `$ `‐prefixed `console` code blocks into Zola `{% terminal() %}`
//! shortcodes. Used by both the `--help-page` generator (CLI source → web docs)
//! and the doc sync test (hand-written docs → web docs).

/// Empty span; the displayed "EXPERIMENTAL" text comes from the CSS `::after`
/// rule so the anchor-link slug generated by Zola ignores it.
///
/// Lives here (rather than in `src/help.rs`) so the producer (the `--help-page`
/// post-processor that emits this in place of `[experimental]`) and the
/// consumer (the skill-file generator that strips it back to `[experimental]`)
/// stay in lockstep — changing the badge in one place would otherwise let
/// stale HTML leak silently into skill files.
pub const BADGE_EXPERIMENTAL_HTML: &str = "<span class=\"badge-experimental\"></span>";

/// HTML-comment marker that includes a subcommand's `--help-page` body inline
/// in its parent's `after_long_help`. Expanded into an H2 section by
/// `expand_subdoc_placeholders`. Trailing space included so `find()` and
/// `strip_prefix()` agree on the boundary.
pub const SUBDOC_MARKER_PREFIX: &str = "<!-- subdoc: ";

/// HTML-comment marker that pulls a demo GIF from `worktrunk-assets` into the
/// docs site (rendered as a `<picture>` figure). Stripped from skill output.
pub const DEMO_MARKER_PREFIX: &str = "<!-- demo: ";

/// Open-marker prefix for an auto-generated region in a docs file.
/// Followed by `<id> — edit <source> to update -->`. Both `--help-page` (the
/// producer in `src/help.rs`) and the doc-sync test (the consumer in
/// `tests/integration_tests/readme_sync.rs`) reference this so the literal
/// can't drift between sides.
pub const MARKER_OPEN_PREFIX: &str = "<!-- ⚠️ AUTO-GENERATED from ";

/// Close marker for an auto-generated region. Paired with `MARKER_OPEN_PREFIX`
/// via non-greedy regex matching; the sync test
/// `test_no_nested_auto_generated_markers` enforces the no-nesting precondition
/// that makes that pairing safe.
pub const MARKER_CLOSE: &str = "<!-- END AUTO-GENERATED -->";

/// Convert `$ `‐prefixed console blocks into `{% terminal() %}` shortcodes.
///
/// All shell commands in `console` blocks use `$ ` prefix. This function detects
/// them and emits the appropriate shortcode form:
///
/// - Commands without output: `{{ terminal(cmd="...") }}` (Syntect highlighting)
/// - Commands with output: `{% terminal(cmd="...") %}output{% end %}`
/// - Multiple commands/comments: `|||`‐delimited in `cmd` parameter
///
/// `{{ }}` in commands (e.g., Jinja2 template expressions) are replaced with
/// placeholders so Tera doesn't interpret them. The terminal shortcode template
/// replaces them back before Syntect highlighting. Double quotes are also
/// replaced with a placeholder since they'd close the `cmd="..."` parameter
/// (Tera has no backslash-escape mechanism for string literals).
///
/// Blocks without `$ ` are left unchanged for the `console` → `bash` replacement.
pub fn convert_dollar_console_to_terminal(text: &str) -> String {
    let mut result = String::with_capacity(text.len());
    let mut lines = text.lines().peekable();

    while let Some(line) = lines.next() {
        if line.trim_start() == "```console" {
            // Collect the block, then decide whether to convert
            let mut block_lines = Vec::new();
            for content_line in lines.by_ref() {
                let stripped = content_line.trim_start();
                if stripped.starts_with("```")
                    && (stripped.len() == 3 || !stripped.as_bytes()[3].is_ascii_alphabetic())
                {
                    break;
                }
                block_lines.push(content_line);
            }

            // Only convert if the block contains $ commands
            let commands: Vec<_> = block_lines
                .iter()
                .filter_map(|l| l.strip_prefix("$ "))
                .collect();

            if commands.is_empty() {
                // No $ lines — emit unchanged as console block
                result.push_str(line);
                result.push('\n');
                for bl in &block_lines {
                    result.push_str(bl);
                    result.push('\n');
                }
                result.push_str("```\n");
                continue;
            }

            // Classify each line into the cmd= stream (rendered with prompt
            // prefix and Syntect highlighting, joined by `|||`) or the output
            // body. `$ <cmd>` lines feed cmd= verbatim (with `"` and `{{`/`}}`
            // escaped past Tera); `#` lines feed cmd= so the template styles
            // them as bash-comment section headers (used by the `wt list` jq
            // recipes block). Blank lines belong to whichever side has content
            // — to cmd= when the block is command-only (visual spacing between
            // recipe groups), to body otherwise.
            let has_output = block_lines
                .iter()
                .any(|l| !l.is_empty() && !l.starts_with("$ ") && !l.starts_with('#'));
            let mut cmd_value: Vec<String> = Vec::new();
            let mut body_lines: Vec<&&str> = Vec::new();
            for line in &block_lines {
                if let Some(cmd) = line.strip_prefix("$ ") {
                    cmd_value.push(
                        cmd.replace('"', "__WT_QUOT__")
                            .replace("{{", "__WT_OPEN__")
                            .replace("}}", "__WT_CLOSE__"),
                    );
                } else if line.starts_with('#') || (line.is_empty() && !has_output) {
                    cmd_value.push((*line).to_string());
                } else {
                    body_lines.push(line);
                }
            }

            let cmd_str = cmd_value.join("|||");
            if body_lines.is_empty() {
                result.push_str(&format!("{{{{ terminal(cmd=\"{cmd_str}\") }}}}\n"));
            } else {
                result.push_str(&format!("{{% terminal(cmd=\"{cmd_str}\") %}}\n"));
                for bl in &body_lines {
                    result.push_str(bl);
                    result.push('\n');
                }
                result.push_str("{% end %}\n");
            }
            continue;
        }
        result.push_str(line);
        result.push('\n');
    }

    // .lines() strips the trailing newline; match original
    if !text.ends_with('\n') {
        result.pop();
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;
    use insta::assert_snapshot;

    #[test]
    fn test_convert_dollar_console_to_terminal() {
        // Command+output with {{ }} → cmd parameter with placeholders
        assert_snapshot!(convert_dollar_console_to_terminal(
            "```console\n$ wt step eval '{{ branch | hash_port }}'\n16066\n```"
        ), @r#"
        {% terminal(cmd="wt step eval '__WT_OPEN__ branch | hash_port __WT_CLOSE__'") %}
        16066
        {% end %}
        "#);

        // Command only (no $) → unchanged
        assert_snapshot!(convert_dollar_console_to_terminal(
            "```console\nwt step commit --stage=tracked\n```"
        ), @r"
        ```console
        wt step commit --stage=tracked
        ```
        ");

        // Multi-line output, no {{ }} → cmd parameter
        assert_snapshot!(convert_dollar_console_to_terminal(
            "```console\n$ wt step eval --dry-run 'test'\nbranch=feature/auth\nResult: feature/auth\n```"
        ), @r#"
        {% terminal(cmd="wt step eval --dry-run 'test'") %}
        branch=feature/auth
        Result: feature/auth
        {% end %}
        "#);

        // Single command with output, no {{ }} → cmd parameter
        assert_snapshot!(convert_dollar_console_to_terminal(
            "```console\n$ echo 'PORT=8080' > .env\noutput\n```"
        ), @r#"
        {% terminal(cmd="echo 'PORT=8080' > .env") %}
        output
        {% end %}
        "#);

        // Command only (single, no output) → self-closing shortcode
        assert_snapshot!(convert_dollar_console_to_terminal(
            "```console\n$ wt remove\n```"
        ), @r#"{{ terminal(cmd="wt remove") }}
        "#);

        // Multiple commands → |||‐delimited cmd with Syntect highlighting
        assert_snapshot!(convert_dollar_console_to_terminal(
            "```console\n$ wt step push\n$ wt step push develop\n```"
        ), @r#"{{ terminal(cmd="wt step push|||wt step push develop") }}
        "#);

        // Comment before $ commands → included in cmd, Syntect highlights as comment
        assert_snapshot!(convert_dollar_console_to_terminal(
            "```console\n# Recent commands\n$ tail -5 log.jsonl | jq .\n\n# Failed\n$ jq 'select(.exit != 0)' log.jsonl\n```"
        ), @r##"{{ terminal(cmd="# Recent commands|||tail -5 log.jsonl | jq .||||||# Failed|||jq 'select(.exit != 0)' log.jsonl") }}
        "##);

        // Blank line in mixed-mode block (cmd + output) stays in body, doesn't
        // leak into cmd= as a stray `$ ` prompt.
        assert_snapshot!(convert_dollar_console_to_terminal(
            "```console\n$ wt list\n  Branch  Status\n@ main\n\n○ Showing 1 worktree\n```"
        ), @r#"
        {% terminal(cmd="wt list") %}
          Branch  Status
        @ main

        ○ Showing 1 worktree
        {% end %}
        "#);
    }
}