worktrunk 0.50.0

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
//! # Hook announcement format
//!
//! Background hook execution emits a single `Running ...` line covering every
//! hook type that fires from one event. Separators form a precedence hierarchy
//! from tightest to loosest:
//!
//! ```text
//! &  : concurrent commands within a step          (tightest)
//! ,  : serial steps within a pipeline
//! ;  : source pipelines within a hook type, AND   (loosest)
//!      hook-type clauses within an announce
//! ```
//!
//! `;` is overloaded across the two outermost tiers. The reader disambiguates
//! by lookahead — `;` followed by a `<hook-type>:` (e.g. `post-start:`) is a
//! cross-type boundary; otherwise it's a cross-source boundary within the
//! current hook-type clause. In practice the two often coexist on one line.
//!
//! ## Grammar
//!
//! ```text
//! Running <clauses> [@ <path>]
//!
//! <clauses>     := <hook-clause> ("; " <hook-clause>)*
//! <hook-clause> := <hook-type> [" for " <branch>] ": " <pipelines>
//! <pipelines>   := <pipeline> | <labeled> ("; " <labeled>)*
//! <labeled>     := <pipeline> " (" <source> ")"       # all-named or mixed
//!               |  <source> [" ×" N]                  # all-unnamed (no parens)
//! <pipeline>    := <step> (", " <step>)*
//! <step>        := <command> (" & " <command>)*
//! <command>     := <name>                             # named
//!               |  "…"                                # unnamed run of 1
//!               |  "…×" N                             # unnamed run of N≥2
//! ```
//!
//! The source label appears **once per pipeline** as a suffix annotation
//! (`sync, push (user)`), not once per command. The all-unnamed degenerate
//! case has no body to attach the suffix to, so it stays bare (`user` or
//! `user ×N`).
//!
//! ## Examples
//!
//! | Pipeline shape (single source) | Output |
//! |---|---|
//! | one named command | `notify (user)` |
//! | two serial named | `sync, push (user)` |
//! | one concurrent step | `build & lint (user)` |
//! | serial then concurrent | `install, build & lint (user)` |
//! | mixed unnamed + named | `…, bg (user)` (or `…×2, bg (user)`) |
//! | all unnamed | `user ×2` |
//!
//! Multi-source for one hook type: `sync, push (user); build (project)`.
//!
//! Multi-type bundle (e.g., `wt merge` firing four hook types):
//! `Running post-commit: mark (user); post-remove: cleanup (user); post-switch: notify (user); post-merge: sync (user) @ ~/repo`.
//!
//! ## Implementation
//!
//! - [`format_pipeline_summary_from_names`] produces the bare `<pipeline>`
//!   body (handles `&` / `,` and unnamed-flush). Shared with alias announces.
//! - [`format_pipeline_summary`] wraps it with the per-pipeline source label,
//!   collapsing the all-unnamed case to the bare `<source>` / `<source> ×N`
//!   form.
//! - `run_hooks_background` (in `hooks.rs`) joins source summaries with `;`
//!   within each hook-type clause, then joins clauses with `;` into one
//!   announce line with the path suffix at the end.

use color_print::cformat;

use super::command_executor::PreparedStep;
use super::hook_filter::HookSource;

/// A pipeline step with source information, for pipeline-aware execution.
///
/// Used by both hook and alias dispatch as the source-tagged shape that feeds
/// `sourced_steps_to_foreground`. Per-pipeline metadata (`hook_type`,
/// `display_path` for hooks; `name` for aliases) lives on `PipelineKind`,
/// supplied at conversion time so this struct stays neutral. The two fields
/// here are genuinely per-step: alias flows mix steps from both sources into
/// one flat vec, and a config using `[[hook.x]]` on one side with `[hook.x]`
/// on the other can produce mixed `is_pipeline` values within a single hook
/// run.
pub struct SourcedStep {
    pub step: PreparedStep,
    pub source: HookSource,
    /// Whether `Concurrent` steps run concurrently. For hooks: derived from
    /// `is_pipeline()` (deprecated single-table form runs serially). For
    /// aliases: always true — no deprecated form.
    pub is_pipeline: bool,
}

/// Extract the per-step command name lists from a `CommandConfig`.
///
/// Shared by the formatters that describe alias / hook pipelines — `Single`
/// steps become one-element inner vecs, `Concurrent` steps become multi-element
/// vecs, each slot carrying the optional command name. Feeds directly into
/// [`format_pipeline_summary_from_names`].
pub(crate) fn step_names_from_config(
    cfg: &worktrunk::config::CommandConfig,
) -> Vec<Vec<Option<&str>>> {
    cfg.steps()
        .iter()
        .map(|step| match step {
            worktrunk::config::HookStep::Single(cmd) => vec![cmd.name.as_deref()],
            worktrunk::config::HookStep::Concurrent(cmds) => {
                cmds.iter().map(|c| c.name.as_deref()).collect()
            }
        })
        .collect()
}

/// Format the bare `<pipeline>` body from per-step command names — see the
/// module-level grammar.
///
/// `step_names[i]` is the list of commands in step `i`; `Some(name)` for named
/// commands, `None` for unnamed. Serial steps join with `, `; concurrent
/// commands within a step join with ` & `. Contiguous runs of unnamed commands
/// (across steps, until the next named command) collapse into a single
/// `label_unnamed(count)` entry; return `None` from that closure to drop
/// unnamed commands entirely.
///
/// Shared by hook and alias announcements. The caller wraps the body with any
/// surrounding context (source prefix for hooks, alias name for aliases).
///
/// Note: unnamed commands within a `Concurrent` step aren't reachable from
/// config today — TOML named tables always produce all-named commands, and
/// anonymous strings only appear as `Single` steps. The unnamed-flush logic
/// therefore only fires across step boundaries in practice.
pub(crate) fn format_pipeline_summary_from_names(
    step_names: &[Vec<Option<&str>>],
    label_named: impl Fn(&str) -> String,
    label_unnamed: impl Fn(usize) -> Option<String>,
) -> String {
    let mut parts: Vec<String> = Vec::new();
    let mut unnamed_count: usize = 0;

    for step in step_names {
        let mut named = Vec::new();
        for entry in step {
            match entry {
                Some(name) => named.push(label_named(name)),
                None => unnamed_count += 1,
            }
        }

        if !named.is_empty() {
            // Flush any pending unnamed count before named labels.
            if unnamed_count > 0
                && let Some(s) = label_unnamed(unnamed_count)
            {
                parts.push(s);
            }
            unnamed_count = 0;
            parts.push(named.join(" & "));
        }
    }

    // Flush trailing unnamed count.
    if unnamed_count > 0
        && let Some(s) = label_unnamed(unnamed_count)
    {
        parts.push(s);
    }

    parts.join(", ")
}

/// Format a `<labeled>` source pipeline for the announce line — see the
/// module-level grammar.
///
/// Returns `<body> (<source>)` for named/mixed pipelines, or the bare
/// `<source>` / `<source> ×N` form when every command is unnamed (no body to
/// attach the suffix to). Unnamed runs inside a mixed pipeline render as `…`
/// (1) or `…×N` (≥2).
pub(crate) fn format_pipeline_summary(steps: &[SourcedStep]) -> String {
    // All steps in a group share the same source.
    let source_label = steps[0].source.to_string();

    let step_names: Vec<Vec<Option<&str>>> = steps
        .iter()
        .map(|step| match &step.step {
            PreparedStep::Single(cmd) => vec![cmd.name.as_deref()],
            PreparedStep::Concurrent(cmds) => cmds.iter().map(|c| c.name.as_deref()).collect(),
        })
        .collect();

    let total_unnamed: usize = step_names.iter().flatten().filter(|n| n.is_none()).count();
    let any_named = step_names.iter().flatten().any(|n| n.is_some());

    // All-unnamed degenerate case: no names to list, so skip the colon.
    if !any_named {
        return if total_unnamed == 1 {
            source_label
        } else {
            format!("{source_label} ×{total_unnamed}")
        };
    }

    let body = format_pipeline_summary_from_names(
        &step_names,
        |name| cformat!("<bold>{name}</>"),
        |count| {
            Some(if count == 1 {
                "".to_string()
            } else {
                format!("…×{count}")
            })
        },
    );
    format!("{body} ({source_label})")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::command_executor::PreparedCommand;
    use ansi_str::AnsiStr;
    use insta::assert_snapshot;

    fn make_sourced_step(step: PreparedStep) -> SourcedStep {
        SourcedStep {
            step,
            source: HookSource::User,
            is_pipeline: false,
        }
    }

    fn make_cmd(name: Option<&str>, expanded: &str) -> PreparedCommand {
        let label = match name {
            Some(n) => format!("user:{n}"),
            None => "user".to_string(),
        };
        PreparedCommand {
            name: name.map(String::from),
            expanded: expanded.to_string(),
            context_json: "{}".to_string(),
            lazy_template: None,
            label,
            log_label: None,
        }
    }

    #[test]
    fn test_format_pipeline_summary_named() {
        let steps = vec![
            make_sourced_step(PreparedStep::Single(make_cmd(
                Some("install"),
                "npm install",
            ))),
            make_sourced_step(PreparedStep::Concurrent(vec![
                make_cmd(Some("build"), "npm run build"),
                make_cmd(Some("lint"), "npm run lint"),
            ])),
        ];
        let summary = format_pipeline_summary(&steps);
        assert_snapshot!(summary.ansi_strip(), @"install, build & lint (user)");
    }

    #[test]
    fn test_format_pipeline_summary_unnamed() {
        let steps = vec![
            make_sourced_step(PreparedStep::Single(make_cmd(None, "npm install"))),
            make_sourced_step(PreparedStep::Single(make_cmd(None, "npm run build"))),
        ];
        let summary = format_pipeline_summary(&steps);
        assert_snapshot!(summary.ansi_strip(), @"user ×2");
    }

    #[test]
    fn test_format_pipeline_summary_mixed_named_unnamed() {
        let steps = vec![
            make_sourced_step(PreparedStep::Single(make_cmd(None, "npm install"))),
            make_sourced_step(PreparedStep::Single(make_cmd(Some("bg"), "npm run dev"))),
        ];
        let summary = format_pipeline_summary(&steps);
        assert_snapshot!(summary.ansi_strip(), @"…, bg (user)");
    }

    #[test]
    fn test_format_pipeline_summary_single_unnamed() {
        let steps = vec![make_sourced_step(PreparedStep::Single(make_cmd(
            None,
            "npm install",
        )))];
        let summary = format_pipeline_summary(&steps);
        assert_snapshot!(summary.ansi_strip(), @"user");
    }

    #[test]
    fn test_format_pipeline_summary_concurrent_then_concurrent() {
        // The canonical pipeline: two concurrent groups in sequence.
        // post-start = [
        //     { install = "npm install", setup = "setup-db" },
        //     { build = "npm run build", lint = "npm run lint" },
        // ]
        let steps = vec![
            make_sourced_step(PreparedStep::Concurrent(vec![
                make_cmd(Some("install"), "npm install"),
                make_cmd(Some("setup"), "setup-db"),
            ])),
            make_sourced_step(PreparedStep::Concurrent(vec![
                make_cmd(Some("build"), "npm run build"),
                make_cmd(Some("lint"), "npm run lint"),
            ])),
        ];
        let summary = format_pipeline_summary(&steps);
        assert_snapshot!(summary.ansi_strip(), @"install & setup, build & lint (user)");
    }
}