mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
3016 lines
119 KiB
Rust
3016 lines
119 KiB
Rust
//! README and config synchronization tests
|
||
//!
|
||
//! Verifies that README.md examples stay in sync with their source snapshots and help output.
|
||
//! Also syncs default templates from src/llm.rs to dev/config.example.toml
|
||
//! and generates dev/wt.example.toml from the project config section.
|
||
//! Automatically updates sections when out of sync.
|
||
//!
|
||
//! Run with: `cargo test --test integration readme_sync`
|
||
//!
|
||
//! Skipped on Windows: These tests verify documentation sync using help output which has
|
||
//! platform-specific formatting differences (clap markdown rendering, line endings).
|
||
//!
|
||
//! ## Architecture
|
||
//!
|
||
//! The sync system uses a unified pipeline:
|
||
//!
|
||
//! 1. **Parsing**: `parse_snapshot_raw()` extracts content from snapshot files
|
||
//! 2. **Placeholders**: `replace_placeholders()` normalizes test paths to display paths
|
||
//! 3. **Formatting**: `OutputFormat` enum controls the final output (plain text vs HTML)
|
||
//! 4. **Updating**: `update_section()` finds markers and replaces content
|
||
#![cfg(not(windows))]
|
||
|
||
use crate::common::wt_command;
|
||
use ansi_str::AnsiStr;
|
||
use ansi_to_html::convert as ansi_to_html;
|
||
use regex::Regex;
|
||
use std::collections::BTreeMap;
|
||
use std::fs;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::LazyLock;
|
||
|
||
use worktrunk::docs::{MARKER_CLOSE, MARKER_OPEN_PREFIX};
|
||
|
||
/// Wrap a body in `<!-- ⚠️ AUTO-GENERATED ... -->` markers.
|
||
/// Inner whitespace ("\n\n{body}\n\n") matches the historical layout that
|
||
/// downstream regexes and visual review depend on.
|
||
fn wrap_in_marker(id: &str, source_label: &str, body: &str) -> String {
|
||
format!(
|
||
"{MARKER_OPEN_PREFIX}{id} — edit {source_label} to update -->\n\n{body}\n\n{MARKER_CLOSE}"
|
||
)
|
||
}
|
||
|
||
/// Unified pattern for all AUTO-GENERATED markers.
|
||
/// Format: `<!-- ⚠️ AUTO-GENERATED from <id> — edit <source> to update -->`
|
||
/// ID types: path.snap (snapshot), `cmd` (help), path#anchor (section).
|
||
/// Content may be wrapped in ```console``` (snapshots) or unwrapped (help/sections).
|
||
static MARKER_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(&format!(
|
||
r"(?s){}([^\n]+?) — edit [^\n]+ to update -->\n+([\s\S]*?)\n*{}",
|
||
regex::escape(MARKER_OPEN_PREFIX),
|
||
regex::escape(MARKER_CLOSE),
|
||
))
|
||
.unwrap()
|
||
});
|
||
|
||
/// Regex for literal bracket notation (as stored in snapshots) - used by literal_to_escape
|
||
static ANSI_LITERAL_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[[0-9;]*m").unwrap());
|
||
|
||
/// Regex to find snapshot-driven terminal-shortcode markers in standalone docs files
|
||
/// (worktrunk.md, llm-commits.md, etc.) for in-place refresh. The `.snap` ID
|
||
/// requirement and `{% terminal() %}` body together are specific enough to
|
||
/// exclude command-page help-region markers and other AUTO-GENERATED users.
|
||
static DOCS_SNAPSHOT_MARKER_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(&format!(
|
||
r"(?s){}([^\s]+\.snap) — edit source to update -->\n+\{{% terminal\([^)]*\) %\}}\n(.*?)\{{% end %\}}\n+{}",
|
||
regex::escape(MARKER_OPEN_PREFIX),
|
||
regex::escape(MARKER_CLOSE),
|
||
))
|
||
.unwrap()
|
||
});
|
||
|
||
/// Regex for HASH placeholder (used by shell_wrapper tests)
|
||
static HASH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[HASH\]").unwrap());
|
||
|
||
/// Regex for TMPDIR paths with branch suffix (e.g., [TMPDIR]/repo.fix-auth)
|
||
static TMPDIR_BRANCH_REGEX: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"\[TMPDIR\]/repo\.([^\s/]+)").unwrap());
|
||
|
||
/// Regex for TMPDIR paths without branch suffix (e.g., [TMPDIR]/repo at end or followed by space/newline)
|
||
/// Matches [TMPDIR]/repo when followed by end-of-string, whitespace, or non-word character (but not dot)
|
||
static TMPDIR_MAIN_REGEX: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"\[TMPDIR\]/repo(\s|$)").unwrap());
|
||
|
||
/// Regex for REPO placeholder
|
||
static REPO_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[REPO\]").unwrap());
|
||
|
||
/// Regex for _REPO_ placeholder (used in insta-cmd snapshots)
|
||
/// Matches _REPO_ followed by optional .branch suffix
|
||
static REPO_UNDERSCORE_REGEX: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"_REPO_(\.([a-zA-Z0-9_-]+))?").unwrap());
|
||
|
||
/// Regex to extract user config section from src/cli/mod.rs
|
||
/// Matches content between USER_CONFIG_START and USER_CONFIG_END markers
|
||
static USER_CONFIG_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"(?s)<!-- USER_CONFIG_START -->\n(.*?)\n<!-- USER_CONFIG_END -->").unwrap()
|
||
});
|
||
|
||
/// Regex to extract project config section from src/cli/mod.rs
|
||
/// Matches content between PROJECT_CONFIG_START and PROJECT_CONFIG_END markers
|
||
static PROJECT_CONFIG_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"(?s)<!-- PROJECT_CONFIG_START -->\n(.*?)\n<!-- PROJECT_CONFIG_END -->").unwrap()
|
||
});
|
||
|
||
/// Regex to find DEFAULT_TEMPLATE marker in user config section (markdown format)
|
||
static DEFAULT_TEMPLATE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"(?s)(<!-- DEFAULT_TEMPLATE_START -->\n).*?(<!-- DEFAULT_TEMPLATE_END -->)")
|
||
.unwrap()
|
||
});
|
||
|
||
/// Regex to find DEFAULT_SQUASH_TEMPLATE marker in user config section (markdown format)
|
||
static SQUASH_TEMPLATE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(
|
||
r"(?s)(<!-- DEFAULT_SQUASH_TEMPLATE_START -->\n).*?(<!-- DEFAULT_SQUASH_TEMPLATE_END -->)",
|
||
)
|
||
.unwrap()
|
||
});
|
||
|
||
/// Regex to extract Rust raw string constants (single pound)
|
||
static RUST_RAW_STRING_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r##"(?s)const (DEFAULT_TEMPLATE|DEFAULT_SQUASH_TEMPLATE): &str = r#"(.*?)"#;"##)
|
||
.unwrap()
|
||
});
|
||
|
||
/// Regex to convert Zola internal links to full URLs
|
||
/// Matches: [text](@/page.md) or [text](@/page.md#anchor)
|
||
///
|
||
/// Link text tolerates `]` characters when they appear inside a backticked
|
||
/// code span (e.g. `[[block]]`), alternating "a `...` code span" with "any
|
||
/// non-`]`-non-backtick char". Bare backticks are forbidden so the regex
|
||
/// can't bridge across two unrelated code spans on the same line.
|
||
static ZOLA_LINK_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"\[((?:`[^`]*`|[^\]`])+)\]\(@/([^)#]+)\.md(#[^)]*)?\)").unwrap());
|
||
|
||
/// Regex guardrail: any leftover `](@/...md...)` link that the transform
|
||
/// above failed to rewrite. Used by [`assert_no_untransformed_zola_links`] to
|
||
/// fail loudly on stray Zola internal links in generated content.
|
||
static UNTRANSFORMED_ZOLA_LINK_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"\]\(@/[^)]+\.md").unwrap());
|
||
|
||
/// Guardrail for every generated surface that rewrites Zola internal links.
|
||
///
|
||
/// A `@/page.md` target is meaningful only to Zola: it resolves when the docs
|
||
/// site builds and is dead text anywhere else. Both link rewrites here are
|
||
/// regexes over link *text*, so their failure mode is silence — an
|
||
/// unanticipated character makes the pattern decline to match and the raw
|
||
/// markdown survives into the generated file. Neither sync test can see it,
|
||
/// because each compares the generated file against the same transform that
|
||
/// produced it, so an unconverted link is "in sync" by construction.
|
||
///
|
||
/// So each surface asserts the negative afterwards: no `](@/…md` may remain.
|
||
/// `surface` names the generated content for the failure message.
|
||
fn assert_no_untransformed_zola_links(content: &str, surface: &str) {
|
||
if let Some(m) = UNTRANSFORMED_ZOLA_LINK_PATTERN.find(content) {
|
||
let snippet_start = content[..m.start()].rfind('\n').map_or(0, |i| i + 1);
|
||
let snippet_end = content[m.end()..]
|
||
.find('\n')
|
||
.map_or(content.len(), |i| m.end() + i);
|
||
panic!(
|
||
"Failed to transform a Zola internal link in {surface} — likely an \
|
||
unsupported character in the link text. Offending line:\n{}",
|
||
&content[snippet_start..snippet_end]
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Regex to convert Zola rawcode shortcode to HTML pre tags
|
||
/// Matches: {% rawcode() %}...{% end %}
|
||
static ZOLA_RAWCODE_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"(?s)\{% rawcode\(\) %\}(.*?)\{% end %\}").unwrap());
|
||
|
||
/// Regex to convert Zola figure/picture elements to simple markdown images
|
||
/// Matches: <figure class="demo">...<img src="/assets/X.gif" alt="Y"...>...</figure>
|
||
/// Extracts: src path and alt text from the <img> tag
|
||
/// Note: Maps /assets/X to assets/X in the worktrunk-assets repo
|
||
static ZOLA_FIGURE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(
|
||
r#"(?s)<figure class="demo">\s*<picture>.*?<img src="/assets/([^"]+)" alt="([^"]*)"[^>]*>.*?</picture>.*?</figure>"#,
|
||
)
|
||
.unwrap()
|
||
});
|
||
|
||
// =============================================================================
|
||
// Unified Template Infrastructure
|
||
// =============================================================================
|
||
|
||
/// Output format for section updates
|
||
enum OutputFormat {
|
||
/// Docs: HTML with ANSI colors in {% terminal() %} shortcode
|
||
DocsHtml,
|
||
/// Unwrapped: raw markdown content (help commands, doc sections)
|
||
Unwrapped,
|
||
}
|
||
|
||
/// Marker ID type, detected from the ID string
|
||
#[derive(Clone, Copy)]
|
||
enum MarkerType {
|
||
/// Snapshot (.snap extension) - content wrapped in ```console```
|
||
Snapshot,
|
||
/// Help command (backticks) - unwrapped content
|
||
Help,
|
||
/// Doc section (#anchor) - unwrapped content
|
||
Section,
|
||
}
|
||
|
||
impl MarkerType {
|
||
/// Detect marker type from ID string
|
||
fn from_id(id: &str) -> Self {
|
||
if id.starts_with('`') && id.ends_with('`') {
|
||
Self::Help
|
||
} else if id.contains('#') {
|
||
Self::Section
|
||
} else {
|
||
Self::Snapshot
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Parse a snapshot file, returning the user-facing output content
|
||
///
|
||
/// Handles:
|
||
/// - YAML front matter removal
|
||
/// - insta_cmd stdout/stderr section extraction (both streams, in terminal order)
|
||
/// - Malformed snapshots (returns raw content rather than erroring)
|
||
fn parse_snapshot_raw(content: &str) -> String {
|
||
// Remove YAML front matter
|
||
let content = if content.starts_with("---") {
|
||
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
||
if parts.len() >= 3 {
|
||
parts[2].trim().to_string()
|
||
} else {
|
||
content.to_string()
|
||
}
|
||
} else {
|
||
content.to_string()
|
||
};
|
||
|
||
// Handle insta_cmd format with stdout/stderr sections. Show both streams
|
||
// in terminal order (stdout, then stderr) — a command like `wt list` puts
|
||
// the table on stdout and the summary/warnings on stderr, and the docs
|
||
// block should read like the terminal.
|
||
if content.contains("----- stdout -----") {
|
||
let stdout = extract_section(&content, "----- stdout -----\n", "----- stderr -----");
|
||
let stderr = extract_section(&content, "----- stderr -----\n", "----- ");
|
||
return match (stdout.is_empty(), stderr.is_empty()) {
|
||
(false, false) => format!("{stdout}\n{stderr}"),
|
||
(true, _) => stderr, // both-empty also lands here, returning ""
|
||
(false, true) => stdout,
|
||
};
|
||
}
|
||
|
||
// Plain content (PTY-based tests without section markers)
|
||
content
|
||
}
|
||
|
||
/// Extract a section between start marker and end marker
|
||
///
|
||
/// Returns empty string if start marker not found.
|
||
/// If end marker missing, returns content from start marker to EOF.
|
||
fn extract_section(content: &str, start_marker: &str, end_marker: &str) -> String {
|
||
if let Some(start) = content.find(start_marker) {
|
||
let after_header = &content[start + start_marker.len()..];
|
||
if let Some(end) = after_header.find(end_marker) {
|
||
after_header[..end].trim_end().to_string()
|
||
} else {
|
||
after_header.trim_end().to_string()
|
||
}
|
||
} else {
|
||
String::new()
|
||
}
|
||
}
|
||
|
||
/// Extract command line from snapshot YAML header
|
||
///
|
||
/// Parses the YAML front matter to extract program and args, returning the command line.
|
||
/// Returns None if the snapshot doesn't have command info (e.g., non-insta_cmd snapshots).
|
||
fn extract_command_from_snapshot(content: &str) -> Option<String> {
|
||
// Extract YAML front matter
|
||
if !content.starts_with("---") {
|
||
return None;
|
||
}
|
||
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
||
if parts.len() < 3 {
|
||
return None;
|
||
}
|
||
let yaml = parts[1];
|
||
|
||
// Extract program (line: " program: wt")
|
||
let program = yaml
|
||
.lines()
|
||
.find(|l| l.trim().starts_with("program:"))
|
||
.map(|l| l.trim().strip_prefix("program:").unwrap().trim())?;
|
||
|
||
// Extract args (lines: " args:\n - switch\n - --create\n - feature")
|
||
let args_start = yaml.find("args:")?;
|
||
let args_section = &yaml[args_start..];
|
||
let args: Vec<&str> = args_section
|
||
.lines()
|
||
.skip(1) // Skip "args:" line
|
||
.take_while(|l| l.trim().starts_with("- "))
|
||
.map(|l| l.trim().strip_prefix("- ").unwrap().trim_matches('"'))
|
||
.collect();
|
||
|
||
if args.is_empty() {
|
||
Some(program.to_string())
|
||
} else {
|
||
Some(format!("{} {}", program, args.join(" ")))
|
||
}
|
||
}
|
||
|
||
/// Replace test placeholders with display-friendly values
|
||
///
|
||
/// Transforms:
|
||
/// - `[HASH]` → `a1b2c3d`
|
||
/// - `[TMPDIR]/repo.branch` → `../repo.branch`
|
||
/// - `[TMPDIR]/repo` → `../repo`
|
||
/// - `[REPO]` → `../repo`
|
||
/// - `_REPO_` → `~/repo` (worktree path; tilde so it reads as a path, not a project name)
|
||
/// - `_REPO_.branch` → `~/repo.branch`
|
||
fn replace_placeholders(content: &str) -> String {
|
||
let content = HASH_REGEX.replace_all(content, "a1b2c3d");
|
||
let content = TMPDIR_BRANCH_REGEX.replace_all(&content, "../repo.$1");
|
||
let content = TMPDIR_MAIN_REGEX.replace_all(&content, "../repo$1");
|
||
let content = REPO_REGEX.replace_all(&content, "../repo");
|
||
// Handle _REPO_.branch -> ~/repo.branch and _REPO_ -> ~/repo
|
||
REPO_UNDERSCORE_REGEX
|
||
.replace_all(&content, |caps: ®ex::Captures| {
|
||
if let Some(branch) = caps.get(2) {
|
||
format!("~/repo.{}", branch.as_str())
|
||
} else {
|
||
"~/repo".to_string()
|
||
}
|
||
})
|
||
.into_owned()
|
||
}
|
||
|
||
/// Format replacement content based on output format. The `wrap_in_marker`
|
||
/// envelope is identical for both; only the body construction varies.
|
||
fn format_replacement(id: &str, content: &str, format: &OutputFormat) -> String {
|
||
let body = match format {
|
||
OutputFormat::DocsHtml => {
|
||
// Extract command from <span class="cmd"> in body to also emit as
|
||
// cmd= parameter (enables giallo syntax highlighting). The span
|
||
// stays in body so sync comparisons remain stable.
|
||
let cmd_re = Regex::new(r#"^<span class="cmd">([^<]+)</span>"#).unwrap();
|
||
let cmd_attr = cmd_re
|
||
.captures(content)
|
||
.map(|c| format!(r#"cmd="{}""#, c.get(1).unwrap().as_str()))
|
||
.unwrap_or_default();
|
||
format!("{{% terminal({cmd_attr}) %}}\n{content}\n{{% end %}}")
|
||
}
|
||
OutputFormat::Unwrapped => content.to_string(),
|
||
};
|
||
wrap_in_marker(id, "source", &body)
|
||
}
|
||
|
||
/// Update sections matching a pattern in content
|
||
///
|
||
/// Unified function for all section types. The `get_replacement` closure
|
||
/// receives (id, current_content) and returns the new content.
|
||
fn update_section(
|
||
content: &str,
|
||
pattern: &Regex,
|
||
format: OutputFormat,
|
||
get_replacement: impl Fn(&str, &str) -> Result<String, String>,
|
||
) -> Result<(String, usize, usize), Vec<String>> {
|
||
let mut result = content.to_string();
|
||
let mut errors = Vec::new();
|
||
let mut updated = 0;
|
||
|
||
// Collect all matches first (to avoid borrowing issues)
|
||
let matches: Vec<_> = pattern
|
||
.captures_iter(content)
|
||
.map(|cap| {
|
||
let full_match = cap.get(0).unwrap();
|
||
let id = cap.get(1).unwrap().as_str().to_string();
|
||
let current = trim_lines(cap.get(2).unwrap().as_str());
|
||
(full_match.start(), full_match.end(), id, current)
|
||
})
|
||
.collect();
|
||
|
||
let total = matches.len();
|
||
|
||
// Process in reverse order to preserve positions
|
||
for (start, end, id, current) in matches.into_iter().rev() {
|
||
let expected = match get_replacement(&id, ¤t) {
|
||
Ok(content) => content,
|
||
Err(e) => {
|
||
errors.push(format!("❌ {}: {}", id, e));
|
||
continue;
|
||
}
|
||
};
|
||
|
||
if current != expected {
|
||
let replacement = format_replacement(&id, &expected, &format);
|
||
result.replace_range(start..end, &replacement);
|
||
updated += 1;
|
||
}
|
||
}
|
||
|
||
if errors.is_empty() {
|
||
Ok((result, updated, total))
|
||
} else {
|
||
Err(errors)
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// End Unified Infrastructure
|
||
// =============================================================================
|
||
|
||
/// Regex to find command placeholder comments in help pages.
|
||
///
|
||
/// A placeholder is an HTML comment `<!-- wt <id> -->` followed by one of three
|
||
/// code-block forms (the form depends on which stage of `--help-page` has run):
|
||
///
|
||
/// - ````bash``` with an optional `$ ` prompt, optionally followed by multi-line
|
||
/// output — this is what plain (`--help-page --plain`) output contains, and
|
||
/// also what `cli/mod.rs` source contains before `convert_dollar_console_to_terminal`.
|
||
/// - `{{ terminal(cmd="...") }}` self-closing Zola shortcode — produced by
|
||
/// `convert_dollar_console_to_terminal` when the source block has only a command.
|
||
/// - `{% terminal(cmd="...") %}…{% end %}` Zola shortcode — produced by
|
||
/// `convert_dollar_console_to_terminal` when the source block has output too.
|
||
///
|
||
/// Capture groups:
|
||
/// 1. placeholder id (e.g. `wt list (markers)`) — drives snapshot lookup
|
||
/// 2. display command when matched in the ```bash form
|
||
/// 3. display command when matched as `{{ terminal() }}`
|
||
/// 4. display command when matched as `{% terminal() %}…{% end %}`
|
||
///
|
||
/// Exactly one of groups 2–4 is non-None per match.
|
||
static COMMAND_PLACEHOLDER_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(
|
||
r#"(?s)<!-- (wt [^>\n]+) -->\n(?:```bash\n(?:\$ )?(wt [^\n]+).*?\n```|\{\{ terminal\(cmd="(wt [^"]+)"\) \}\}|\{% terminal\(cmd="(wt [^"]+)"\) %\}.*?\{% end %\})"#,
|
||
)
|
||
.unwrap()
|
||
});
|
||
|
||
/// Map commands to their snapshot files for help page expansion
|
||
fn command_to_snapshot(command: &str) -> Option<&'static str> {
|
||
match command {
|
||
"wt list" => Some("integration__integration_tests__list__readme_example_list.snap"),
|
||
"wt list --full" => {
|
||
Some("integration__integration_tests__list__readme_example_list_full.snap")
|
||
}
|
||
"wt list --branches --full" => {
|
||
Some("integration__integration_tests__list__readme_example_list_branches.snap")
|
||
}
|
||
"wt list (markers)" => {
|
||
Some("integration__integration_tests__list__readme_example_list_marker.snap")
|
||
}
|
||
// Docs-page example snapshots — drive the static command-output blocks
|
||
// on pages otherwise dominated by GIFs. See comment in merge.rs test
|
||
// section for the convention.
|
||
"wt merge (docs-example)" => {
|
||
Some("integration__integration_tests__merge__docs_merge_pre_merge_hook.snap")
|
||
}
|
||
"wt step commit (docs-example)" => {
|
||
Some("integration__integration_tests__merge__docs_step_commit_llm.snap")
|
||
}
|
||
"wt remove (docs-example)" => {
|
||
Some("integration__integration_tests__remove__docs_remove_pre_remove_hook.snap")
|
||
}
|
||
"wt hook pre-merge (docs-example)" => {
|
||
Some("integration__integration_tests__user_hooks__docs_hook_pre_merge.snap")
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Rendering mode for [`expand_command_placeholders`].
|
||
enum ExpandMode {
|
||
/// HTML docs (`docs/content/*.md`): emit a `{% terminal(cmd="...") %}`
|
||
/// shortcode with an ANSI→HTML body, wrapped in AUTO-GENERATED markers.
|
||
Html,
|
||
/// Skill reference (`skills/worktrunk/reference/*.md`): emit a fenced code
|
||
/// block with a `$ <cmd>` prompt and a plain-text body.
|
||
Plain,
|
||
}
|
||
|
||
/// Expand command placeholders in help page content into rendered snapshot blocks.
|
||
///
|
||
/// Finds `<!-- wt <id> -->` + ```bash\n[$ ]wt <cmd>\n``` blocks, looks up the
|
||
/// snapshot for the placeholder id (e.g. `wt list (markers)`), and replaces
|
||
/// the block with a mode-appropriate rendering (see [`ExpandMode`]).
|
||
///
|
||
/// The placeholder id drives snapshot lookup so disambiguation suffixes like
|
||
/// `(markers)` don't have to appear in the displayed command. Commands without
|
||
/// a snapshot mapping are left unchanged.
|
||
fn expand_command_placeholders(
|
||
content: &str,
|
||
snapshots_dir: &Path,
|
||
mode: ExpandMode,
|
||
) -> Result<String, String> {
|
||
let mut result = content.to_string();
|
||
let mut errors = Vec::new();
|
||
|
||
for cap in COMMAND_PLACEHOLDER_PATTERN.captures_iter(content) {
|
||
let full_match = cap.get(0).unwrap().as_str();
|
||
let placeholder_id = cap.get(1).unwrap().as_str();
|
||
// Exactly one of groups 2–4 matched — pick whichever.
|
||
let display_cmd = cap
|
||
.get(2)
|
||
.or_else(|| cap.get(3))
|
||
.or_else(|| cap.get(4))
|
||
.unwrap()
|
||
.as_str();
|
||
|
||
let Some(snapshot_name) = command_to_snapshot(placeholder_id) else {
|
||
continue;
|
||
};
|
||
|
||
let snapshot_path = snapshots_dir.join(snapshot_name);
|
||
if !snapshot_path.exists() {
|
||
errors.push(format!(
|
||
"Snapshot file not found: {} (for command '{}')",
|
||
snapshot_path.display(),
|
||
placeholder_id
|
||
));
|
||
continue;
|
||
}
|
||
|
||
let snapshot_content = fs::read_to_string(&snapshot_path)
|
||
.map_err(|e| format!("Failed to read {}: {}", snapshot_path.display(), e))?;
|
||
|
||
let replacement = match mode {
|
||
ExpandMode::Html => {
|
||
let html = parse_snapshot_content_for_docs(&snapshot_content)?;
|
||
let normalized = encode_leading_spaces(&trim_lines(&html));
|
||
// cmd= parameter uses the displayed command (enables giallo
|
||
// syntax highlighting on the command line) rather than the
|
||
// placeholder id, so disambiguation suffixes like `(markers)`
|
||
// don't leak into the rendered prompt. Prompt ($) is added
|
||
// via CSS ::before, so not included in HTML.
|
||
//
|
||
// No inner AUTO-GENERATED wrapper — the whole command page
|
||
// regenerates wholesale each sync, so the wrapper is dead
|
||
// weight and would nest inside the outer help-page region's
|
||
// markers (forcing the outer regex to use a tempered match).
|
||
format!("{{% terminal(cmd=\"{display_cmd}\") %}}\n{normalized}\n{{% end %}}")
|
||
}
|
||
ExpandMode::Plain => {
|
||
let plain = trim_lines(&parse_snapshot_content_for_skill(&snapshot_content));
|
||
format!("```\n$ {display_cmd}\n{plain}\n```")
|
||
}
|
||
};
|
||
|
||
result = result.replace(full_match, &replacement);
|
||
}
|
||
|
||
if !errors.is_empty() {
|
||
return Err(errors.join("\n"));
|
||
}
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
/// Convert literal bracket notation [32m to actual escape sequences \x1b[32m
|
||
fn literal_to_escape(text: &str) -> String {
|
||
ANSI_LITERAL_REGEX
|
||
.replace_all(text, |caps: ®ex::Captures| {
|
||
let code = caps.get(0).unwrap().as_str();
|
||
format!("\x1b{code}")
|
||
})
|
||
.to_string()
|
||
}
|
||
|
||
/// Trim trailing whitespace from each line and overall.
|
||
/// Preserves leading spaces (e.g., two-space gutter before table headers in `wt list`).
|
||
fn trim_lines(content: &str) -> String {
|
||
content
|
||
.lines()
|
||
.map(|line| line.trim_end())
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
.trim_end()
|
||
.to_string()
|
||
}
|
||
|
||
/// Encode leading spaces on the first line as ` ` HTML entities.
|
||
/// Zola trims leading whitespace from shortcode bodies, stripping the
|
||
/// two-space gutter that aligns table headers with data rows in `wt list`.
|
||
/// HTML entities survive the trim and render as spaces in `<pre>` blocks.
|
||
fn encode_leading_spaces(content: &str) -> String {
|
||
let first_line = content.lines().next().unwrap_or("");
|
||
let leading = first_line.len() - first_line.trim_start().len();
|
||
if leading == 0 {
|
||
return content.to_string();
|
||
}
|
||
format!("{}{}", " ".repeat(leading), &content[leading..])
|
||
}
|
||
|
||
/// Parse snapshot content for docs (with ANSI to HTML conversion)
|
||
fn parse_snapshot_content_for_docs(content: &str) -> Result<String, String> {
|
||
let content = parse_snapshot_raw(content);
|
||
let content = replace_placeholders(&content);
|
||
let content = literal_to_escape(&content);
|
||
let html = ansi_to_html(&content).map_err(|e| format!("ANSI conversion failed: {e}"))?;
|
||
Ok(clean_ansi_html(&html))
|
||
}
|
||
|
||
/// Parse snapshot content for skill reference files (plain text, ANSI stripped)
|
||
fn parse_snapshot_content_for_skill(content: &str) -> String {
|
||
let content = parse_snapshot_raw(content);
|
||
let content = replace_placeholders(&content);
|
||
let content = literal_to_escape(&content);
|
||
content.ansi_strip().into_owned()
|
||
}
|
||
|
||
/// Ensure each line ends with a reset code, carrying active styles to the next line
|
||
///
|
||
/// Clap resets styles at line breaks when wrapping, so a bold span that wraps across
|
||
/// lines loses its bold on the continuation. This tracks active SGR styles
|
||
/// and re-opens them at the start of each continuation line, producing clean per-line
|
||
/// HTML like `<b>first part</b>\n<b>second part</b>` instead of `<b>first part</b>\nsecond part`.
|
||
fn ensure_line_resets_with_carry(ansi: &str) -> String {
|
||
const RESET: &str = "\x1b[0m";
|
||
// Match SGR sequences: ESC [ <params> m
|
||
static SGR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[([0-9;]*)m").unwrap());
|
||
|
||
let lines: Vec<&str> = ansi.lines().collect();
|
||
let mut result = Vec::with_capacity(lines.len());
|
||
let mut active_styles: Vec<String> = Vec::new();
|
||
|
||
for line in lines {
|
||
// Prepend active styles from previous line
|
||
let line = if active_styles.is_empty() {
|
||
line.to_string()
|
||
} else {
|
||
let prefix: String = active_styles.iter().map(|s| s.as_str()).collect();
|
||
format!("{prefix}{line}")
|
||
};
|
||
|
||
// Track which styles are active at end of this line
|
||
active_styles.clear();
|
||
for cap in SGR_RE.captures_iter(&line) {
|
||
let params = &cap[1];
|
||
if params.is_empty() || params == "0" {
|
||
active_styles.clear();
|
||
} else {
|
||
active_styles.push(format!("\x1b[{params}m"));
|
||
}
|
||
}
|
||
|
||
// Ensure line ends with reset
|
||
if line.ends_with(RESET) {
|
||
result.push(line);
|
||
} else {
|
||
result.push(format!("{line}{RESET}"));
|
||
}
|
||
}
|
||
|
||
result.join("\n")
|
||
}
|
||
|
||
/// Clean up HTML output from ansi-to-html conversion
|
||
fn clean_ansi_html(html: &str) -> String {
|
||
// Regex to remove empty HTML spans (e.g., <span style='opacity:0.67'></span>)
|
||
static EMPTY_SPAN_REGEX: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"<span[^>]*></span>").unwrap());
|
||
|
||
// Strip bare ESC characters left by the library
|
||
let html = html.replace('\x1b', "");
|
||
|
||
// Clean up empty tags generated by reset codes
|
||
let html = html.replace("<b></b>", "");
|
||
let html = EMPTY_SPAN_REGEX.replace_all(&html, "").to_string();
|
||
|
||
// Replace verbose inline styles with CSS classes for cleaner output
|
||
html.replace("<span style='opacity:0.67'>", "<span class=d>")
|
||
.replace("<span style='color:var(--green,#0a0)'>", "<span class=g>")
|
||
.replace("<span style='color:var(--red,#a00)'>", "<span class=r>")
|
||
.replace("<span style='color:var(--cyan,#0aa)'>", "<span class=c>")
|
||
}
|
||
|
||
/// Regex to find command reference code blocks with ANSI content
|
||
/// Matches: ## Command reference\n\n```\n<content with ANSI>\n```
|
||
/// or: ### Command reference\n\n```\n<content with ANSI>\n```
|
||
static COMMAND_REF_BLOCK_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"(?s)(###? Command reference\n\n)```\n(.*?)\n```").unwrap());
|
||
|
||
/// Convert command reference code blocks to terminal shortcodes with HTML
|
||
///
|
||
/// Finds code blocks after "## Command reference" or "### Command reference" headers
|
||
/// and converts ANSI escape codes to HTML, wrapping in {% terminal() %} shortcode.
|
||
fn convert_command_reference_to_html(content: &str) -> Result<String, String> {
|
||
let mut result = content.to_string();
|
||
|
||
// Find all command reference blocks and convert them
|
||
// Process in reverse order to preserve positions
|
||
let matches: Vec<_> = COMMAND_REF_BLOCK_PATTERN
|
||
.captures_iter(content)
|
||
.map(|cap| {
|
||
let full_match = cap.get(0).unwrap();
|
||
let header = cap.get(1).unwrap().as_str();
|
||
let code_content = cap.get(2).unwrap().as_str();
|
||
(full_match.start(), full_match.end(), header, code_content)
|
||
})
|
||
.collect();
|
||
|
||
for (start, end, header, code_content) in matches.into_iter().rev() {
|
||
// Convert ANSI to HTML
|
||
let with_resets = ensure_line_resets_with_carry(code_content);
|
||
let html =
|
||
ansi_to_html(&with_resets).map_err(|e| format!("ANSI conversion failed: {e}"))?;
|
||
let clean_html = clean_ansi_html(&html);
|
||
let trimmed_html = trim_lines(&clean_html);
|
||
|
||
// Build terminal shortcode
|
||
let replacement = format!("{header}{{% terminal() %}}\n{trimmed_html}\n{{% end %}}");
|
||
result.replace_range(start..end, &replacement);
|
||
}
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
/// Get help output for a command
|
||
///
|
||
/// Expected format: `wt <subcommand> --help-md` (ID includes backticks from marker)
|
||
fn help_output(id: &str, project_root: &Path) -> Result<String, String> {
|
||
// Strip backticks from ID (captured by MARKER_PATTERN)
|
||
let command = id.trim_matches('`');
|
||
let args: Vec<&str> = command.split_whitespace().collect();
|
||
if args.is_empty() {
|
||
return Err("Empty command".to_string());
|
||
}
|
||
|
||
// Validate command format
|
||
if args.first() != Some(&"wt") {
|
||
return Err(format!("Command must start with 'wt': {}", command));
|
||
}
|
||
|
||
// Validate it ends with --help-md
|
||
if args.last() != Some(&"--help-md") {
|
||
return Err(format!("Command must end with '--help-md': {}", command));
|
||
}
|
||
|
||
// Use the already-built binary from cargo test (wt_command provides isolation)
|
||
let output = wt_command()
|
||
.env("NO_COLOR", "1") // Plain text for README
|
||
.args(&args[1..]) // Skip "wt" prefix
|
||
.current_dir(project_root)
|
||
.output()
|
||
.map_err(|e| format!("Failed to run command: {}", e))?;
|
||
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
|
||
// Help goes to stdout
|
||
let help_output = if !stdout.is_empty() {
|
||
stdout.to_string()
|
||
} else {
|
||
stderr.to_string()
|
||
};
|
||
|
||
// Trim trailing whitespace from each line and join
|
||
let help_output = help_output
|
||
.lines()
|
||
.map(|line| line.trim_end())
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
.trim()
|
||
.to_string();
|
||
|
||
// Format for README display:
|
||
// 1. Replace " - " with em dash in first line (command description)
|
||
// 2. Split at first ## header - synopsis in code block, rest as markdown
|
||
// 3. Increase heading levels in docs section (## -> ###, ### -> ####)
|
||
// so they become children of the command heading (which is ##)
|
||
let result = if let Some(first_newline) = help_output.find('\n') {
|
||
let (first_line, rest) = help_output.split_at(first_newline);
|
||
// Replace hyphen-minus with em dash in command description
|
||
let first_line = first_line.replacen(" - ", " — ", 1);
|
||
|
||
if let Some(header_pos) = rest.find("\n## ") {
|
||
// Split at first H2 header
|
||
let (synopsis, docs) = rest.split_at(header_pos);
|
||
let docs = docs.trim_start_matches('\n');
|
||
// Increase heading levels so docs headings become children of command heading
|
||
let docs = increase_heading_levels(docs);
|
||
format!("```\n{}{}\n```\n\n{}", first_line, synopsis, docs)
|
||
} else {
|
||
// No documentation section, wrap everything in code block
|
||
format!("```\n{}{}\n```", first_line, rest)
|
||
}
|
||
} else {
|
||
// Single line output
|
||
help_output.replacen(" - ", " — ", 1)
|
||
};
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
/// Increase markdown heading levels by one (## -> ###, ### -> ####, etc.)
|
||
/// This makes help output headings children of the command heading in docs.
|
||
/// Only transforms actual markdown headings, not code block content.
|
||
fn increase_heading_levels(content: &str) -> String {
|
||
let mut result = Vec::new();
|
||
let mut in_code_block = false;
|
||
|
||
for line in content.lines() {
|
||
// Track code block boundaries (``` or ````+)
|
||
if line.trim_start().starts_with("```") {
|
||
in_code_block = !in_code_block;
|
||
result.push(line.to_string());
|
||
continue;
|
||
}
|
||
|
||
// Only transform headings outside code blocks
|
||
if !in_code_block && line.starts_with('#') {
|
||
result.push(format!("#{}", line));
|
||
} else {
|
||
result.push(line.to_string());
|
||
}
|
||
}
|
||
|
||
result.join("\n")
|
||
}
|
||
|
||
/// Extract templates from llm.rs source
|
||
fn extract_templates(content: &str) -> std::collections::HashMap<String, String> {
|
||
RUST_RAW_STRING_PATTERN
|
||
.captures_iter(content)
|
||
.map(|cap| {
|
||
let name = cap.get(1).unwrap().as_str().to_string();
|
||
let template = cap.get(2).unwrap().as_str().to_string();
|
||
(name, template)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
// =============================================================================
|
||
// Docs-to-README Section Sync
|
||
// =============================================================================
|
||
|
||
/// Extract sections from markdown content by anchor range
|
||
///
|
||
/// If `anchor` contains `..`, extracts from start anchor through end anchor (inclusive).
|
||
/// Otherwise extracts a single section.
|
||
fn extract_section_by_anchor(content: &str, anchor: &str) -> Option<String> {
|
||
let (start_anchor, end_anchor) = if let Some((start, end)) = anchor.split_once("..") {
|
||
(start, Some(end))
|
||
} else {
|
||
(anchor, None)
|
||
};
|
||
|
||
let lines: Vec<&str> = content.lines().collect();
|
||
|
||
// Find the start heading
|
||
let start_idx = lines.iter().position(|line| {
|
||
line.strip_prefix("## ")
|
||
.or_else(|| line.strip_prefix("### "))
|
||
.is_some_and(|text| heading_to_anchor(text) == start_anchor)
|
||
})?;
|
||
|
||
// Find the end: either after end_anchor section, or next same-level heading
|
||
let end_idx = if let Some(end_anchor) = end_anchor {
|
||
// Find where end_anchor's section ends
|
||
let end_heading_idx = lines.iter().skip(start_idx + 1).position(|line| {
|
||
line.strip_prefix("## ")
|
||
.or_else(|| line.strip_prefix("### "))
|
||
.is_some_and(|text| heading_to_anchor(text) == end_anchor)
|
||
})? + start_idx
|
||
+ 1;
|
||
|
||
// Find the next ## heading after end_anchor (or EOF)
|
||
lines
|
||
.iter()
|
||
.skip(end_heading_idx + 1)
|
||
.position(|line| line.starts_with("## "))
|
||
.map(|i| i + end_heading_idx + 1)
|
||
.unwrap_or(lines.len())
|
||
} else {
|
||
// Single section: find next ## heading
|
||
lines
|
||
.iter()
|
||
.skip(start_idx + 1)
|
||
.position(|line| line.starts_with("## "))
|
||
.map(|i| i + start_idx + 1)
|
||
.unwrap_or(lines.len())
|
||
};
|
||
|
||
let section = lines[start_idx..end_idx].join("\n").trim().to_string();
|
||
Some(section)
|
||
}
|
||
|
||
/// Convert heading text to anchor format (lowercase, spaces to hyphens)
|
||
fn heading_to_anchor(heading: &str) -> String {
|
||
heading
|
||
.to_lowercase()
|
||
.chars()
|
||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||
.collect::<String>()
|
||
.split('-')
|
||
.filter(|s| !s.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("-")
|
||
}
|
||
|
||
/// Regex to match terminal-shortcode AUTO-GENERATED markers in docs files,
|
||
/// for conversion to plain code blocks when extracting sections into README.
|
||
/// Optionally consumes a preceding ```bash``` block (rendered redundant by
|
||
/// the cmd= parameter on the shortcode).
|
||
/// Matches both `{% terminal() %}` and `{% terminal(cmd="...") %}` forms.
|
||
static TERMINAL_MARKER_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(&format!(
|
||
r"(?s)(?:```bash\n[^\n]+\n```\n+)?{}[^\n]+ -->\n+\{{% terminal\([^)]*\) %\}}\n(.*?)\{{% end %\}}\n+{}",
|
||
regex::escape(MARKER_OPEN_PREFIX),
|
||
regex::escape(MARKER_CLOSE),
|
||
))
|
||
.unwrap()
|
||
});
|
||
|
||
/// Strip HTML tags from content, converting .cmd spans to `$ ` prefixed commands
|
||
fn strip_html(content: &str) -> String {
|
||
// First, convert <span class="cmd">...</span> to "$ ..." (add prompt)
|
||
let cmd_pattern = Regex::new(r#"<span class="cmd">([^<]*)</span>"#).unwrap();
|
||
let result = cmd_pattern.replace_all(content, "$ $1");
|
||
|
||
// Strip remaining HTML tags
|
||
let tag_pattern = Regex::new(r"<[^>]+>").unwrap();
|
||
let result = tag_pattern.replace_all(&result, "");
|
||
|
||
// Decode HTML entities
|
||
result
|
||
.replace("&", "&")
|
||
.replace("<", "<")
|
||
.replace(">", ">")
|
||
.replace(""", "\"")
|
||
.replace("'", "'")
|
||
}
|
||
|
||
/// Transform Zola-flavored markdown to GitHub-flavored markdown
|
||
///
|
||
/// Converts:
|
||
/// - `[text](@/page.md)` → `[text](https://worktrunk.dev/page/)`
|
||
/// - `[text](@/page.md#anchor)` → `[text](https://worktrunk.dev/page/#anchor)`
|
||
/// - `{% rawcode() %}...{% end %}` → `<pre>...</pre>`
|
||
/// - `<figure class="demo">...<img src="/assets/X.gif"...>...</figure>` → ``
|
||
/// - AUTO-GENERATED terminal markers → plain code blocks
|
||
/// - `{{ terminal(cmd="...") }}` → ```bash code blocks
|
||
fn transform_zola_to_github(content: &str) -> String {
|
||
// Transform internal links
|
||
let content = ZOLA_LINK_PATTERN
|
||
.replace_all(content, |caps: ®ex::Captures| {
|
||
let text = caps.get(1).unwrap().as_str();
|
||
let page = caps.get(2).unwrap().as_str();
|
||
let anchor = caps.get(3).map_or("", |m| m.as_str());
|
||
format!("[{text}](https://worktrunk.dev/{page}/{anchor})")
|
||
})
|
||
.into_owned();
|
||
|
||
// Transform rawcode shortcodes to pre tags
|
||
let content = ZOLA_RAWCODE_PATTERN
|
||
.replace_all(&content, |caps: ®ex::Captures| {
|
||
let inner = caps.get(1).unwrap().as_str();
|
||
format!("<pre>{}</pre>", inner)
|
||
})
|
||
.into_owned();
|
||
|
||
// Transform terminal markers to console code blocks for README
|
||
let content = TERMINAL_MARKER_PATTERN
|
||
.replace_all(&content, |caps: ®ex::Captures| {
|
||
let inner = caps.get(1).unwrap().as_str();
|
||
// Strip HTML, converting .cmd spans to "$ ..." (adds prompt)
|
||
let plain = strip_html(inner);
|
||
format!("```console\n{}\n```", plain)
|
||
})
|
||
.into_owned();
|
||
|
||
// Transform self-closing terminal shortcodes to bash code blocks for README
|
||
// These are `{{ terminal(cmd="...") }}` shortcodes without body content
|
||
let content = ZOLA_TERMINAL_SELF_CLOSING_PATTERN
|
||
.replace_all(&content, |caps: ®ex::Captures| {
|
||
cmd_to_bash_block(caps.get(1).map_or("", |m| m.as_str()), "", false)
|
||
})
|
||
.into_owned();
|
||
|
||
// Transform figure/picture elements to markdown images with GitHub raw URLs
|
||
ZOLA_FIGURE_PATTERN
|
||
.replace_all(&content, |caps: ®ex::Captures| {
|
||
let filename = caps.get(1).unwrap().as_str();
|
||
let alt = caps.get(2).unwrap().as_str();
|
||
format!(
|
||
""
|
||
)
|
||
})
|
||
.into_owned()
|
||
}
|
||
|
||
/// Get section content from docs file, transformed for README
|
||
///
|
||
/// Parses `path#anchor` ID format, extracts section(s) by anchor
|
||
/// (supports ranges like `start..end`), and transforms Zola links to GitHub URLs.
|
||
fn docs_section_for_readme(id: &str, project_root: &Path) -> Result<String, String> {
|
||
let (path, anchor) = id
|
||
.split_once('#')
|
||
.ok_or_else(|| format!("Invalid section ID (missing #): {}", id))?;
|
||
|
||
let docs_path = project_root.join(path);
|
||
let content = fs::read_to_string(&docs_path)
|
||
.map_err(|e| format!("Failed to read {}: {}", docs_path.display(), e))?;
|
||
|
||
let section = extract_section_by_anchor(&content, anchor)
|
||
.ok_or_else(|| format!("Section '{}' not found in {}", anchor, docs_path.display()))?;
|
||
|
||
// Transform Zola links to GitHub URLs
|
||
Ok(transform_zola_to_github(§ion))
|
||
}
|
||
|
||
/// Get content for a README marker based on its type
|
||
///
|
||
/// Handles help (`cmd`) and section (#anchor) markers.
|
||
fn generate_readme_content(
|
||
id: &str,
|
||
_current_content: &str,
|
||
project_root: &Path,
|
||
) -> Result<String, String> {
|
||
match MarkerType::from_id(id) {
|
||
MarkerType::Snapshot => unreachable!("README has no snapshot markers"),
|
||
MarkerType::Help => help_output(id, project_root),
|
||
MarkerType::Section => docs_section_for_readme(id, project_root).map(|c| trim_lines(&c)),
|
||
}
|
||
}
|
||
|
||
/// Sync all README markers in a single pass
|
||
///
|
||
/// Processes all AUTO-GENERATED markers in one regex traversal:
|
||
/// - Help commands (`cmd`) - rendered markdown from --help-md
|
||
/// - Doc sections (#anchor) - extracted content from docs
|
||
fn sync_readme_markers(
|
||
readme_content: &str,
|
||
project_root: &Path,
|
||
) -> Result<(String, usize, usize), Vec<String>> {
|
||
let mut result = readme_content.to_string();
|
||
let mut errors = Vec::new();
|
||
let mut updated = 0;
|
||
|
||
// Collect all matches first
|
||
let matches: Vec<_> = MARKER_PATTERN
|
||
.captures_iter(readme_content)
|
||
.map(|cap| {
|
||
let full_match = cap.get(0).unwrap();
|
||
let id = cap.get(1).unwrap().as_str().trim().to_string();
|
||
let current = cap.get(2).unwrap().as_str().to_string();
|
||
(full_match.start(), full_match.end(), id, current)
|
||
})
|
||
.collect();
|
||
|
||
let total = matches.len();
|
||
|
||
// Process in reverse order to preserve positions. README markers are always
|
||
// Help/Section (unwrapped); a Snapshot marker would mean a stale shortcode
|
||
// leaked through `transform_zola_to_github` and is a real bug to surface.
|
||
for (start, end, id, current) in matches.into_iter().rev() {
|
||
if matches!(MarkerType::from_id(&id), MarkerType::Snapshot) {
|
||
errors.push(format!(
|
||
"❌ {id}: README must not contain snapshot markers — \
|
||
transform_zola_to_github should have stripped this"
|
||
));
|
||
continue;
|
||
}
|
||
|
||
let expected = match generate_readme_content(&id, ¤t, project_root) {
|
||
Ok(content) => content,
|
||
Err(e) => {
|
||
errors.push(format!("❌ {}: {}", id, e));
|
||
continue;
|
||
}
|
||
};
|
||
|
||
// Compare with trim_lines normalization applied once to each side
|
||
if trim_lines(¤t) != trim_lines(&expected) {
|
||
let replacement = format_replacement(&id, &expected, &OutputFormat::Unwrapped);
|
||
result.replace_range(start..end, &replacement);
|
||
updated += 1;
|
||
}
|
||
}
|
||
|
||
if errors.is_empty() {
|
||
Ok((result, updated, total))
|
||
} else {
|
||
Err(errors)
|
||
}
|
||
}
|
||
|
||
/// Transform user config markdown to config.example.toml format
|
||
///
|
||
/// # Design
|
||
///
|
||
/// The source content is the user config section in `src/cli/mod.rs`, embedded between
|
||
/// `<!-- USER_CONFIG_START -->` and `<!-- USER_CONFIG_END -->` markers. This markdown
|
||
/// is designed as a great explainer for configuration options, containing prose
|
||
/// explanations and TOML code blocks showing example values.
|
||
///
|
||
/// The generated file (`dev/config.example.toml`) is the entire source with every line
|
||
/// `# ` prefixed and code fence markers stripped. This creates a fully-commented config
|
||
/// file that serves as inline documentation. Code blocks show default values (single `#`
|
||
/// prefix in the output); users uncomment the relevant `key = value` line to customize.
|
||
///
|
||
/// # Transform Rules
|
||
///
|
||
/// 1. Code fence markers (```` ``` ````, ```` ```toml ````) → stripped entirely
|
||
/// 2. Markdown links → converted to plain URLs (config files aren't rendered as markdown)
|
||
/// 3. All other lines → prefixed with `# `
|
||
/// 4. Trailing empty comment lines → trimmed
|
||
fn transform_config_source_to_toml(source: &str) -> String {
|
||
let mut result = Vec::new();
|
||
let mut in_code_block = false;
|
||
|
||
for line in source.lines() {
|
||
let trimmed = line.trim();
|
||
|
||
// Strip code fence markers
|
||
if trimmed.starts_with("```") {
|
||
in_code_block = !in_code_block;
|
||
continue;
|
||
}
|
||
|
||
// Convert markdown links to plain text for config file readability
|
||
// [Link text](@/page.md) → Link text (https://worktrunk.dev/page/)
|
||
// [Link text](https://...) → Link text (https://...)
|
||
let line = convert_markdown_links_for_config(line);
|
||
|
||
// Comment all lines
|
||
if line.is_empty() {
|
||
result.push(String::from("#"));
|
||
} else {
|
||
result.push(format!("# {}", line));
|
||
}
|
||
}
|
||
|
||
// Clean up: remove trailing empty comment lines
|
||
while result.last().is_some_and(|l| l == "#" || l.is_empty()) {
|
||
result.pop();
|
||
}
|
||
|
||
let result = result.join("\n");
|
||
|
||
// Guardrail: a link `convert_markdown_links_for_config` declined to match
|
||
// survives as raw markdown into the generated example file, which
|
||
// `wt config create` writes to the user's config and `wt config create
|
||
// --help` prints — so the `@/` target would reach the user verbatim.
|
||
assert_no_untransformed_zola_links(&result, "a generated config example");
|
||
|
||
result
|
||
}
|
||
|
||
/// Convert markdown links to plain text with URL in parentheses.
|
||
///
|
||
/// Config files aren't rendered as markdown, so links need to be readable as plain text.
|
||
/// - `[Link text](@/page.md)` → `Link text (https://worktrunk.dev/page/)`
|
||
/// - `[Link text](https://example.com)` → `Link text (https://example.com)`
|
||
///
|
||
/// The link text may itself contain a bracketed span — a TOML section name in
|
||
/// backticks (``[pattern-keyed `[projects]` entry](@/config.md#…)``) is the
|
||
/// shape that occurs here. A link that isn't converted survives as raw
|
||
/// markdown into the generated example file, which `wt config create` writes
|
||
/// to the user's config and `wt config create --help` prints, so a Zola `@/`
|
||
/// target reaches the user verbatim. Brackets in these link texts always sit
|
||
/// inside a backticked code span, so the text class alternates a code span
|
||
/// with any non-`]`-non-backtick char — the same rule `ZOLA_LINK_PATTERN`
|
||
/// uses above, which also covers `[[…]]` array-of-tables names.
|
||
fn convert_markdown_links_for_config(line: &str) -> String {
|
||
use regex::Regex;
|
||
use std::sync::LazyLock;
|
||
|
||
static MARKDOWN_LINK: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"\[((?:`[^`]*`|[^\]`])+)\]\(([^)]+)\)").unwrap());
|
||
|
||
MARKDOWN_LINK
|
||
.replace_all(line, |caps: ®ex::Captures| {
|
||
let text = &caps[1];
|
||
let url = &caps[2];
|
||
|
||
// Convert Zola @/ links to full URLs
|
||
let url = if let Some(path) = url.strip_prefix("@/") {
|
||
// Handle anchors: @/config.md#section → config/#section
|
||
let (page, anchor) = match path.split_once('#') {
|
||
Some((p, a)) => (p.trim_end_matches(".md"), Some(a)),
|
||
None => (path.trim_end_matches(".md"), None),
|
||
};
|
||
match anchor {
|
||
Some(a) => format!("https://worktrunk.dev/{page}/#{a}"),
|
||
None => format!("https://worktrunk.dev/{page}/"),
|
||
}
|
||
} else {
|
||
url.to_string()
|
||
};
|
||
|
||
format!("{text} ({url})")
|
||
})
|
||
.to_string()
|
||
}
|
||
|
||
/// Every link form the config sections use converts to plain text, including
|
||
/// one whose text carries a bracketed span of its own.
|
||
///
|
||
/// The generated file is what `wt config create` writes and what `wt config
|
||
/// create --help` prints, so a link this misses ships a raw `@/page.md` target
|
||
/// to the user. Nothing downstream catches that: the sync test compares the
|
||
/// example against this same transform, so an unconverted link is "in sync".
|
||
#[test]
|
||
fn test_config_markdown_links_convert_to_plain_text() {
|
||
let cases = [
|
||
// Zola page link, and the same with an anchor.
|
||
(
|
||
"See [hooks](@/hook.md) for details",
|
||
"See hooks (https://worktrunk.dev/hook/) for details",
|
||
),
|
||
(
|
||
"See [forge platform](@/config.md#forge-platform).",
|
||
"See forge platform (https://worktrunk.dev/config/#forge-platform).",
|
||
),
|
||
// An absolute URL passes through untouched.
|
||
(
|
||
"See [the spec](https://example.com/a) too",
|
||
"See the spec (https://example.com/a) too",
|
||
),
|
||
// Link text containing a bracketed span — a TOML section name in
|
||
// backticks. The pre-fix regex stopped at the inner `]` and left the
|
||
// whole link as raw markdown.
|
||
(
|
||
"name it once with a [pattern-keyed `[projects]` entry](@/config.md#user-project-specific-settings) instead",
|
||
"name it once with a pattern-keyed `[projects]` entry (https://worktrunk.dev/config/#user-project-specific-settings) instead",
|
||
),
|
||
// The same shape naming an array-of-tables. These sections document
|
||
// `[[projects."…".post-start]]` pipelines, so a link naming one is the
|
||
// next form to arrive; the code-span class covers it.
|
||
(
|
||
"see [`[[projects.\"…\".post-start]]` hooks](@/config.md#hooks) for the pipeline form",
|
||
"see `[[projects.\"…\".post-start]]` hooks (https://worktrunk.dev/config/#hooks) for the pipeline form",
|
||
),
|
||
// Two links on one line still both convert.
|
||
(
|
||
"[a](@/hook.md) and [b](@/config.md)",
|
||
"a (https://worktrunk.dev/hook/) and b (https://worktrunk.dev/config/)",
|
||
),
|
||
// A bare bracketed span is not a link and must survive verbatim —
|
||
// `[forge]` and `[list]` appear all over these sections.
|
||
(
|
||
"A repository's own `[forge]` still wins, field by field.",
|
||
"A repository's own `[forge]` still wins, field by field.",
|
||
),
|
||
];
|
||
|
||
for (input, expected) in cases {
|
||
assert_eq!(
|
||
convert_markdown_links_for_config(input),
|
||
expected,
|
||
"input: {input}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A link shape the rewrite declines to match fails loudly rather than
|
||
/// shipping its `@/` target.
|
||
///
|
||
/// This is the backstop for the case the test above can't anticipate: the next
|
||
/// unsupported link text. The generated config example runs through
|
||
/// `transform_config_source_to_toml`, so the assertion is what turns "the
|
||
/// regex silently declined" into a test failure naming the line.
|
||
#[test]
|
||
#[should_panic(expected = "a generated config example")]
|
||
fn test_untransformed_zola_link_fails_the_config_transform() {
|
||
// An unbalanced backtick in the link text: the code-span alternative can't
|
||
// close, so the rewrite declines and the raw `@/` target would survive.
|
||
transform_config_source_to_toml("See [a `broken span](@/config.md#hooks) here");
|
||
}
|
||
|
||
/// Extract a config section from src/cli/mod.rs by marker pattern.
|
||
fn extract_config_section(cli_mod_content: &str, pattern: &Regex, label: &str) -> String {
|
||
pattern
|
||
.captures(cli_mod_content)
|
||
.and_then(|cap| cap.get(1))
|
||
.map(|m| m.as_str().to_string())
|
||
.unwrap_or_else(|| panic!("{label} markers not found in src/cli/mod.rs"))
|
||
}
|
||
|
||
/// Verify a config example file is in sync with its source section in mod.rs.
|
||
///
|
||
/// If out of sync, overwrites the file and panics so CI fails.
|
||
fn assert_config_example_in_sync(
|
||
cli_mod_content: &str,
|
||
pattern: &Regex,
|
||
marker_label: &str,
|
||
example_path: &Path,
|
||
) {
|
||
let source = extract_config_section(cli_mod_content, pattern, marker_label);
|
||
let expected = trim_lines(&transform_config_source_to_toml(&source));
|
||
|
||
let current = fs::read_to_string(example_path)
|
||
.unwrap_or_else(|e| panic!("Failed to read {}: {}", example_path.display(), e));
|
||
let current = trim_lines(¤t);
|
||
|
||
if current != expected {
|
||
fs::write(example_path, format!("{}\n", expected)).unwrap();
|
||
panic!(
|
||
"{} out of sync with {} section in src/cli/mod.rs. \
|
||
Run tests locally and commit the changes.",
|
||
example_path.file_name().unwrap().to_string_lossy(),
|
||
marker_label,
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_source_generates_example_toml() {
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
let cli_mod_content = fs::read_to_string(project_root.join("src/cli/mod.rs"))
|
||
.unwrap_or_else(|e| panic!("Failed to read src/cli/mod.rs: {e}"));
|
||
|
||
assert_config_example_in_sync(
|
||
&cli_mod_content,
|
||
&USER_CONFIG_PATTERN,
|
||
"USER_CONFIG_START/END",
|
||
&project_root.join("dev/config.example.toml"),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_project_config_source_generates_example_toml() {
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
let cli_mod_content = fs::read_to_string(project_root.join("src/cli/mod.rs"))
|
||
.unwrap_or_else(|e| panic!("Failed to read src/cli/mod.rs: {e}"));
|
||
|
||
assert_config_example_in_sync(
|
||
&cli_mod_content,
|
||
&PROJECT_CONFIG_PATTERN,
|
||
"PROJECT_CONFIG_START/END",
|
||
&project_root.join("dev/wt.example.toml"),
|
||
);
|
||
}
|
||
|
||
/// Verify that all user config struct fields are documented in the user config example.
|
||
///
|
||
/// Section names are derived from `UserConfig`'s JsonSchema, so adding a new field
|
||
/// to the struct automatically fails this test if the docs aren't updated.
|
||
#[test]
|
||
fn test_config_docs_include_all_sections() {
|
||
use std::collections::HashSet;
|
||
use strum::IntoEnumIterator;
|
||
use worktrunk::config::{DEPRECATED_SECTION_KEYS, valid_user_config_keys};
|
||
use worktrunk::git::HookType;
|
||
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
let cli_mod_path = project_root.join("src/cli/mod.rs");
|
||
let cli_mod_content = fs::read_to_string(&cli_mod_path).unwrap();
|
||
let user_config_content =
|
||
extract_config_section(&cli_mod_content, &USER_CONFIG_PATTERN, "USER_CONFIG");
|
||
|
||
let all_keys = valid_user_config_keys();
|
||
|
||
// Hook keys from HookType enum + `pre-create`/`post-create` aliases (see
|
||
// `HooksConfig` in src/config/hooks.rs).
|
||
let hook_keys: HashSet<String> = HookType::iter()
|
||
.map(|h| h.to_string())
|
||
.chain(["pre-create".to_string(), "post-create".to_string()])
|
||
.collect();
|
||
|
||
// Keys that are bare scalars or internal flags, not TOML section headers
|
||
let non_section_keys: HashSet<&str> = [
|
||
"worktree-path",
|
||
"skip-shell-integration-prompt",
|
||
"skip-commit-generation-prompt",
|
||
]
|
||
.into();
|
||
|
||
// Separate schema keys into section keys (excluding hooks and bare scalars)
|
||
let section_keys: Vec<&String> = all_keys
|
||
.iter()
|
||
.filter(|k| !hook_keys.contains(*k) && !non_section_keys.contains(k.as_str()))
|
||
.collect();
|
||
|
||
// Check non-deprecated sections appear as TOML headers ([key] or [key.something])
|
||
for key in §ion_keys {
|
||
if DEPRECATED_SECTION_KEYS
|
||
.iter()
|
||
.any(|d| d.key == key.as_str())
|
||
{
|
||
let header = format!("[{key}]");
|
||
assert!(
|
||
!user_config_content.contains(&header),
|
||
"Deprecated section `{header}` should not appear in user config docs.\n\
|
||
Use the new section name instead."
|
||
);
|
||
} else {
|
||
let header = format!("[{key}]");
|
||
let nested = format!("[{key}.");
|
||
assert!(
|
||
user_config_content.contains(&header) || user_config_content.contains(&nested),
|
||
"Config section `[{key}]` (from UserConfig schema) is missing from user \
|
||
config docs in src/cli/mod.rs.\nAll config sections must be documented between \
|
||
USER_CONFIG_START/END markers."
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Verify that all project config struct fields are documented in the project config example.
|
||
///
|
||
/// Section names are derived from `ProjectConfig`'s JsonSchema, so adding a new field
|
||
/// to the struct automatically fails this test if the docs aren't updated.
|
||
#[test]
|
||
fn test_project_config_docs_include_all_sections() {
|
||
use std::collections::HashSet;
|
||
use strum::IntoEnumIterator;
|
||
use worktrunk::config::{DEPRECATED_SECTION_KEYS, valid_project_config_keys};
|
||
use worktrunk::git::HookType;
|
||
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
let cli_mod_path = project_root.join("src/cli/mod.rs");
|
||
let cli_mod_content = fs::read_to_string(&cli_mod_path).unwrap();
|
||
let project_config_content =
|
||
extract_config_section(&cli_mod_content, &PROJECT_CONFIG_PATTERN, "PROJECT_CONFIG");
|
||
|
||
let all_keys = valid_project_config_keys();
|
||
|
||
// Hook keys from HookType enum + `pre-create`/`post-create` aliases (see
|
||
// `HooksConfig` in src/config/hooks.rs).
|
||
let hook_keys: HashSet<String> = HookType::iter()
|
||
.map(|h| h.to_string())
|
||
.chain(["pre-create".to_string(), "post-create".to_string()])
|
||
.collect();
|
||
|
||
// Separate schema keys into section keys and hook keys
|
||
let section_keys: Vec<&String> = all_keys
|
||
.iter()
|
||
.filter(|k| !hook_keys.contains(*k))
|
||
.collect();
|
||
|
||
// Check non-deprecated sections appear as TOML headers ([key] or [key.something])
|
||
for key in §ion_keys {
|
||
if DEPRECATED_SECTION_KEYS
|
||
.iter()
|
||
.any(|d| d.key == key.as_str())
|
||
{
|
||
let header = format!("[{key}]");
|
||
assert!(
|
||
!project_config_content.contains(&header),
|
||
"Deprecated section `{header}` should not appear in project config docs.\n\
|
||
Use the new section name instead."
|
||
);
|
||
} else {
|
||
let header = format!("[{key}]");
|
||
let nested = format!("[{key}.");
|
||
assert!(
|
||
project_config_content.contains(&header)
|
||
|| project_config_content.contains(&nested),
|
||
"Config section `[{key}]` (from ProjectConfig schema) is missing from project \
|
||
config docs in src/cli/mod.rs.\nAll config sections must be documented between \
|
||
PROJECT_CONFIG_START/END markers."
|
||
);
|
||
}
|
||
}
|
||
|
||
// Hooks section should exist (individual hook keys are documented in user config
|
||
// and cross-referenced from project config)
|
||
assert!(
|
||
project_config_content.contains("## Hooks"),
|
||
"Hooks section heading missing from project config docs.\n\
|
||
Expected `## Hooks` between PROJECT_CONFIG_START/END markers."
|
||
);
|
||
}
|
||
|
||
/// Verify that LLM tool commands in docs/content/llm-commits.md match
|
||
/// the examples in config.example.toml (the single source of truth).
|
||
#[test]
|
||
fn test_llm_docs_commands_match_config_example() {
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
let config_example = fs::read_to_string(project_root.join("dev/config.example.toml")).unwrap();
|
||
let llm_docs = fs::read_to_string(project_root.join("docs/content/llm-commits.md")).unwrap();
|
||
|
||
// Extract commands from config example: "# command = ..." lines
|
||
let config_commands: Vec<String> = config_example
|
||
.lines()
|
||
.filter_map(|line| line.strip_prefix("# "))
|
||
.filter(|line| line.starts_with("command = "))
|
||
.filter_map(|line| {
|
||
let table: toml::Table = toml::from_str(line).ok()?;
|
||
Some(table["command"].as_str()?.to_string())
|
||
})
|
||
.collect();
|
||
|
||
// Extract commands from llm-commits.md: "command = ..." lines in TOML code blocks
|
||
let doc_commands: Vec<String> = llm_docs
|
||
.lines()
|
||
.filter(|line| line.starts_with("command = "))
|
||
.filter_map(|line| {
|
||
let table: toml::Table = toml::from_str(line).ok()?;
|
||
Some(table["command"].as_str()?.to_string())
|
||
})
|
||
.collect();
|
||
|
||
assert!(
|
||
config_commands.len() >= 2,
|
||
"Expected at least 2 tool commands in config.example.toml, found {}",
|
||
config_commands.len()
|
||
);
|
||
|
||
for cmd in &config_commands {
|
||
assert!(
|
||
doc_commands.contains(cmd),
|
||
"Command from config.example.toml not found in docs/content/llm-commits.md:\n {cmd}\n\
|
||
Update llm-commits.md to match the config example (source of truth: dev/config.example.toml, \
|
||
generated from src/cli/mod.rs)."
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Verify that LLM tool commands in Taskfile.yaml bench-llm-commits match
|
||
/// the examples in config.example.toml (the single source of truth).
|
||
/// Only compares tools present in both files — either side may have tools the other lacks.
|
||
#[test]
|
||
fn test_taskfile_llm_commands_match_config_example() {
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
let config_example = fs::read_to_string(project_root.join("dev/config.example.toml")).unwrap();
|
||
let taskfile = fs::read_to_string(project_root.join("Taskfile.yaml")).unwrap();
|
||
|
||
// Extract tool -> command from config example using h3 headings for tool names
|
||
// e.g. "# ### Claude Code" heading followed by '# command = "..."' line
|
||
let mut config_commands = std::collections::HashMap::new();
|
||
let mut current_tool: Option<String> = None;
|
||
for line in config_example.lines() {
|
||
if let Some(heading) = line.strip_prefix("# ### ") {
|
||
current_tool = heading.split_whitespace().next().map(|s| s.to_lowercase());
|
||
} else if let Some(cmd_line) = line.strip_prefix("# ")
|
||
&& cmd_line.starts_with("command = ")
|
||
&& let Some(ref tool) = current_tool
|
||
&& let Ok(table) = toml::from_str::<toml::Table>(cmd_line)
|
||
&& let Some(cmd) = table.get("command").and_then(|v| v.as_str())
|
||
{
|
||
config_commands.insert(tool.clone(), cmd.to_string());
|
||
}
|
||
}
|
||
|
||
// Extract tool -> command from Taskfile: COMMANDS["tool"]='shell-escaped-value'
|
||
// Unescape bash's '"'"' idiom (literal single quote) then strip outer quotes
|
||
let taskfile_re = Regex::new(r#"COMMANDS\["(\w+)"\]=(.*)"#).unwrap();
|
||
let taskfile_commands: std::collections::HashMap<String, String> = taskfile
|
||
.lines()
|
||
.filter_map(|line| {
|
||
let caps = taskfile_re.captures(line.trim())?;
|
||
let tool = caps[1].to_string();
|
||
let raw = &caps[2];
|
||
let unescaped = raw.replace("'\"'\"'", "'");
|
||
let cmd = unescaped
|
||
.strip_prefix('\'')?
|
||
.strip_suffix('\'')?
|
||
.to_string();
|
||
Some((tool, cmd))
|
||
})
|
||
.collect();
|
||
|
||
// Compare only tools present in both
|
||
let mut checked = 0;
|
||
for (tool, taskfile_cmd) in &taskfile_commands {
|
||
if let Some(config_cmd) = config_commands.get(tool.as_str()) {
|
||
assert_eq!(
|
||
config_cmd, taskfile_cmd,
|
||
"Command mismatch for '{tool}'.\n\
|
||
Config example: {config_cmd}\n\
|
||
Taskfile: {taskfile_cmd}\n\
|
||
Update Taskfile.yaml to match dev/config.example.toml (source of truth)."
|
||
);
|
||
checked += 1;
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
checked >= 1,
|
||
"No overlapping tools between config.example.toml and Taskfile.yaml"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_source_templates_are_in_sync() {
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
let llm_rs_path = project_root.join("src/llm.rs");
|
||
let cli_mod_path = project_root.join("src/cli/mod.rs");
|
||
|
||
let llm_content = fs::read_to_string(&llm_rs_path).unwrap();
|
||
let cli_mod_content = fs::read_to_string(&cli_mod_path).unwrap();
|
||
|
||
// Extract templates from llm.rs
|
||
let templates = extract_templates(&llm_content);
|
||
assert!(
|
||
templates.contains_key("DEFAULT_TEMPLATE"),
|
||
"DEFAULT_TEMPLATE not found in src/llm.rs"
|
||
);
|
||
assert!(
|
||
templates.contains_key("DEFAULT_SQUASH_TEMPLATE"),
|
||
"DEFAULT_SQUASH_TEMPLATE not found in src/llm.rs"
|
||
);
|
||
|
||
let mut updated_content = cli_mod_content.clone();
|
||
let mut updated_count = 0;
|
||
|
||
// Helper to replace a template section in markdown format
|
||
let mut replace_template = |pattern: &Regex, name: &str, key: &str| {
|
||
if let Some(cap) = pattern.captures(&updated_content.clone()) {
|
||
let full_match = cap.get(0).unwrap();
|
||
let prefix = cap.get(1).unwrap().as_str();
|
||
let suffix = cap.get(2).unwrap().as_str();
|
||
|
||
let template = templates
|
||
.get(name)
|
||
.unwrap_or_else(|| panic!("{name} not found in src/llm.rs"));
|
||
|
||
// Format as markdown code block
|
||
let replacement = format!(
|
||
r#"{prefix}```toml
|
||
[commit.generation]
|
||
{key} = """
|
||
{template}
|
||
"""
|
||
```
|
||
{suffix}"#
|
||
);
|
||
|
||
if full_match.as_str() != replacement {
|
||
updated_content = updated_content.replace(full_match.as_str(), &replacement);
|
||
updated_count += 1;
|
||
}
|
||
}
|
||
};
|
||
|
||
replace_template(&DEFAULT_TEMPLATE_PATTERN, "DEFAULT_TEMPLATE", "template");
|
||
replace_template(
|
||
&SQUASH_TEMPLATE_PATTERN,
|
||
"DEFAULT_SQUASH_TEMPLATE",
|
||
"squash-template",
|
||
);
|
||
|
||
if updated_count > 0 {
|
||
fs::write(&cli_mod_path, &updated_content).unwrap();
|
||
panic!(
|
||
"Templates out of sync: updated {} section(s) in src/cli/mod.rs. \
|
||
Run tests locally and commit the changes.",
|
||
updated_count
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Sync docs snapshot markers in a single file (with ANSI to HTML conversion)
|
||
fn sync_docs_snapshots(doc_path: &Path, project_root: &Path) -> Result<usize, Vec<String>> {
|
||
if !doc_path.exists() {
|
||
return Ok(0);
|
||
}
|
||
|
||
let content = fs::read_to_string(doc_path)
|
||
.map_err(|e| vec![format!("Failed to read {}: {}", doc_path.display(), e)])?;
|
||
|
||
let project_root_for_snapshots = project_root.to_path_buf();
|
||
match update_section(
|
||
&content,
|
||
&DOCS_SNAPSHOT_MARKER_PATTERN,
|
||
OutputFormat::DocsHtml,
|
||
|snap_path, _current_content| {
|
||
let full_path = project_root_for_snapshots.join(snap_path);
|
||
let raw = fs::read_to_string(&full_path)
|
||
.map_err(|e| format!("Failed to read {}: {}", full_path.display(), e))?;
|
||
|
||
// Extract command from snapshot YAML header
|
||
let command = extract_command_from_snapshot(&raw);
|
||
|
||
let html_content = parse_snapshot_content_for_docs(&raw)?;
|
||
let normalized = trim_lines(&html_content);
|
||
|
||
// Prepend command line with styling if present
|
||
// Prompt ($) is added via CSS ::before, so not included in HTML
|
||
Ok(match command {
|
||
Some(cmd) => format!("<span class=\"cmd\">{}</span>\n{}", cmd, normalized),
|
||
None => normalized,
|
||
})
|
||
},
|
||
) {
|
||
Ok((new_content, updated_count, _total_count)) => {
|
||
if updated_count > 0 {
|
||
fs::write(doc_path, &new_content).unwrap();
|
||
}
|
||
Ok(updated_count)
|
||
}
|
||
Err(errs) => Err(errs),
|
||
}
|
||
}
|
||
|
||
/// Update or insert the `description` field in TOML frontmatter.
|
||
///
|
||
/// Handles three cases:
|
||
/// - Description field exists → update it
|
||
/// - No description field → insert after title line
|
||
/// - No frontmatter → return content unchanged
|
||
fn sync_frontmatter_description(content: &str, description: &str) -> String {
|
||
static DESC_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r#"(?m)^description\s*=\s*"[^"]*""#).unwrap());
|
||
|
||
let new_field = format!(r#"description = "{}""#, description.replace('"', r#"\""#));
|
||
|
||
// Check if we're in a TOML frontmatter block
|
||
if !content.starts_with("+++\n") {
|
||
return content.to_string();
|
||
}
|
||
|
||
if DESC_PATTERN.is_match(content) {
|
||
// Replace existing description
|
||
DESC_PATTERN
|
||
.replace(content, new_field.as_str())
|
||
.to_string()
|
||
} else {
|
||
// Insert after title line
|
||
static TITLE_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r#"(?m)^(title\s*=\s*"[^"]*")\n"#).unwrap());
|
||
|
||
TITLE_PATTERN
|
||
.replace(content, |caps: ®ex::Captures| {
|
||
format!("{}\n{}\n", &caps[1], new_field)
|
||
})
|
||
.to_string()
|
||
}
|
||
}
|
||
|
||
/// Command pages generated via `wt <cmd> --help-page`
|
||
/// Each page preserves its frontmatter and replaces the AUTO-GENERATED marker region.
|
||
/// Note: `select` is excluded because it's a deprecated hidden alias for `wt switch`.
|
||
const COMMAND_PAGES: &[&str] = &[
|
||
"switch", "list", "merge", "remove", "config", "step", "hook",
|
||
];
|
||
|
||
/// Write `expected` to `path` and record `rel_path` in `updated`. Creates
|
||
/// parent directories as needed. Panics on I/O failure — these are test-time
|
||
/// syncs, so any write error should abort the run.
|
||
///
|
||
/// Callers are responsible for the "is it different?" check. This lets each
|
||
/// site apply its own normalization (e.g., `trim_lines`) before comparing
|
||
/// without forcing it into the helper.
|
||
fn write_tracked(
|
||
path: &Path,
|
||
expected: &str,
|
||
rel_path: impl Into<String>,
|
||
updated: &mut Vec<String>,
|
||
) {
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent)
|
||
.unwrap_or_else(|e| panic!("Failed to create {}: {}", parent.display(), e));
|
||
}
|
||
fs::write(path, expected)
|
||
.unwrap_or_else(|e| panic!("Failed to write {}: {}", path.display(), e));
|
||
updated.push(rel_path.into());
|
||
}
|
||
|
||
/// Sync command pages from --help-page output to docs/content/*.md
|
||
/// Returns (errors, updated_files)
|
||
fn sync_command_pages(project_root: &Path) -> (Vec<String>, Vec<String>) {
|
||
let mut errors = Vec::new();
|
||
let mut updated_files = Vec::new();
|
||
|
||
for cmd in COMMAND_PAGES {
|
||
let doc_path = project_root.join(format!("docs/content/{}.md", cmd));
|
||
|
||
// Run wt <cmd> --help-page (outputs START marker + content + END marker)
|
||
let output = wt_command()
|
||
.args([cmd, "--help-page"])
|
||
.current_dir(project_root)
|
||
.output()
|
||
.expect("Failed to run wt --help-page");
|
||
|
||
if !output.status.success() {
|
||
errors.push(format!(
|
||
"'wt {} --help-page' failed (exit {}): {}",
|
||
cmd,
|
||
output.status.code().unwrap_or(-1),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
));
|
||
continue;
|
||
}
|
||
|
||
// Strip trailing whitespace from each line (pre-commit does this)
|
||
let generated: String = String::from_utf8_lossy(&output.stdout)
|
||
.lines()
|
||
.map(|line| line.trim_end())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
if generated.trim().is_empty() {
|
||
errors.push(format!(
|
||
"Empty output from 'wt {} --help-page': {}",
|
||
cmd,
|
||
String::from_utf8_lossy(&output.stderr)
|
||
));
|
||
continue;
|
||
}
|
||
|
||
// Expand command placeholders (wt list -> terminal shortcode with snapshot output)
|
||
let snapshots_dir = project_root.join("tests/snapshots");
|
||
let generated =
|
||
match expand_command_placeholders(&generated, &snapshots_dir, ExpandMode::Html) {
|
||
Ok(expanded) => expanded,
|
||
Err(e) => {
|
||
errors.push(format!(
|
||
"Failed to expand placeholders for '{}': {}",
|
||
cmd, e
|
||
));
|
||
continue;
|
||
}
|
||
};
|
||
|
||
// Convert command reference code blocks to terminal shortcodes with HTML
|
||
let generated = match convert_command_reference_to_html(&generated) {
|
||
Ok(converted) => converted,
|
||
Err(e) => {
|
||
errors.push(format!(
|
||
"Failed to convert command reference for '{}': {}",
|
||
cmd, e
|
||
));
|
||
continue;
|
||
}
|
||
};
|
||
|
||
// Get meta description from --help-description
|
||
let desc_output = wt_command()
|
||
.args([cmd, "--help-description"])
|
||
.current_dir(project_root)
|
||
.output()
|
||
.expect("Failed to run wt --help-description");
|
||
let description = String::from_utf8_lossy(&desc_output.stdout)
|
||
.trim()
|
||
.to_string();
|
||
|
||
let current = fs::read_to_string(&doc_path)
|
||
.unwrap_or_else(|e| panic!("Failed to read {}: {}", doc_path.display(), e));
|
||
|
||
// Update frontmatter description field
|
||
let new_content = if !description.is_empty() {
|
||
sync_frontmatter_description(¤t, &description)
|
||
} else {
|
||
current.clone()
|
||
};
|
||
|
||
// Find the help-page marker region. Non-greedy `.*?` pairs the open
|
||
// with the nearest `MARKER_CLOSE`. Inner `AUTO-GENERATED` markers are
|
||
// not emitted by any sync step (verified via test that ensures no
|
||
// nesting in command pages); if that ever changes, a tempered match
|
||
// would be needed instead of bare non-greedy.
|
||
let id_re = regex::escape(&format!("`wt {cmd} --help-page`"));
|
||
let marker_pattern = Regex::new(&format!(
|
||
r"(?s){open}{id_re}[^>]*-->.*?{close}",
|
||
open = regex::escape(MARKER_OPEN_PREFIX),
|
||
close = regex::escape(MARKER_CLOSE),
|
||
))
|
||
.unwrap();
|
||
|
||
let new_content = if let Some(m) = marker_pattern.find(&new_content) {
|
||
let before = &new_content[..m.start()];
|
||
let after = &new_content[m.end()..];
|
||
format!("{}{}{}", before, generated.trim(), after)
|
||
} else {
|
||
errors.push(format!(
|
||
"No AUTO-GENERATED region found in {}. \
|
||
Ensure file has marker region for `wt {} --help-page`.",
|
||
doc_path.display(),
|
||
cmd
|
||
));
|
||
continue;
|
||
};
|
||
|
||
if current != new_content {
|
||
write_tracked(
|
||
&doc_path,
|
||
&new_content,
|
||
format!("docs/content/{}.md", cmd),
|
||
&mut updated_files,
|
||
);
|
||
}
|
||
}
|
||
|
||
(errors, updated_files)
|
||
}
|
||
|
||
// =============================================================================
|
||
// Docs to Skill File Sync
|
||
// =============================================================================
|
||
|
||
/// Regex to match Zola frontmatter and extract title
|
||
static ZOLA_FRONTMATTER_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"(?s)^\+\+\+\n(.*?)\n\+\+\+\n*").unwrap());
|
||
|
||
/// Regex to extract title from frontmatter
|
||
static ZOLA_TITLE_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r#"title\s*=\s*"([^"]+)""#).unwrap());
|
||
|
||
/// Regex to strip body-form terminal shortcodes ({% terminal(...) %}...{% end %}).
|
||
/// Optionally captures the cmd parameter value (group 1) and body (group 2).
|
||
static ZOLA_TERMINAL_BODY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r#"(?s)\{%\s*terminal\((?:cmd="([^"]*)"\s*)?\)\s*%\}\n?(.*?)\{%\s*end\s*%\}"#)
|
||
.unwrap()
|
||
});
|
||
|
||
/// Regex to strip self-closing terminal shortcodes ({{ terminal(cmd="...") }}).
|
||
static ZOLA_TERMINAL_SELF_CLOSING_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r#"\{\{ terminal\(cmd="([^"]*)"\) \}\}"#).unwrap());
|
||
|
||
/// Regex to replace Zola experimental shortcode with plain text for skill files
|
||
static ZOLA_EXPERIMENTAL_SHORTCODE: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"\{\{\s*experimental\(\)\s*\}\}").unwrap());
|
||
|
||
/// Regex to strip AUTO-GENERATED marker comments (just the comments, not content).
|
||
/// Matches the open prefix (with ⚠️) and the bare close form.
|
||
static AUTO_GENERATED_MARKER_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(&format!(
|
||
r"{open}[^>]*-->\n*|{close}\n*",
|
||
open = regex::escape(MARKER_OPEN_PREFIX),
|
||
close = regex::escape(MARKER_CLOSE),
|
||
))
|
||
.unwrap()
|
||
});
|
||
|
||
/// Regex to strip HTML figure/picture elements (demo GIFs)
|
||
static HTML_FIGURE_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"(?s)<figure[^>]*>.*?</figure>\n*").unwrap());
|
||
|
||
/// Regex to strip `<span class="cmd">...</span>` lines from shortcode bodies.
|
||
/// These duplicate the cmd parameter content (the template uses `clean_body` for this).
|
||
static SPAN_CMD_PATTERN: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r#"<span class="cmd">[^<]*</span>\n?"#).unwrap());
|
||
|
||
/// Regex to convert `<span class="cmd">X</span>` → `$ X` for no-cmd body shortcodes.
|
||
static SPAN_CMD_TO_DOLLAR: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r#"<span class="cmd">([^<]*)</span>"#).unwrap());
|
||
|
||
/// Convert a `|||`-delimited cmd string (and optional body) into a ```bash block.
|
||
/// When `with_prompt` is false, command lines pass through verbatim so the block
|
||
/// is directly copy-pasteable. When true, command lines get a `$ ` prefix to
|
||
/// distinguish them from interleaved output. Comment-only lines (`#`) and blank
|
||
/// lines never get the prefix.
|
||
fn cmd_to_bash_block(cmd: &str, body: &str, with_prompt: bool) -> String {
|
||
let mut result = String::from("```bash\n");
|
||
for line in cmd.split("|||") {
|
||
if line.is_empty() {
|
||
result.push('\n');
|
||
} else if line.starts_with('#') {
|
||
result.push_str(line);
|
||
result.push('\n');
|
||
} else {
|
||
if with_prompt {
|
||
result.push_str("$ ");
|
||
}
|
||
result.push_str(line);
|
||
result.push('\n');
|
||
}
|
||
}
|
||
// Strip <span class="cmd"> lines that duplicate the cmd parameter
|
||
let clean_body = SPAN_CMD_PATTERN.replace_all(body, "");
|
||
if !clean_body.is_empty() {
|
||
result.push_str(&clean_body);
|
||
if !clean_body.ends_with('\n') {
|
||
result.push('\n');
|
||
}
|
||
}
|
||
result.push_str("```");
|
||
result
|
||
}
|
||
|
||
/// Transform docs content for skill file consumption
|
||
///
|
||
/// Transforms:
|
||
/// - Extracts title from Zola frontmatter and prepends as H1
|
||
/// - Strips Zola terminal shortcodes ({% terminal() %}...{% end %}) - keeps inner content
|
||
/// - Strips AUTO-GENERATED marker comments (keeps content)
|
||
/// - Strips HTML figure elements (demo GIFs not useful for skill)
|
||
/// - Replaces Zola shortcodes with plain text equivalents
|
||
/// - Converts Zola internal links (@/page.md) -> full URLs
|
||
/// - Removes "See also" section (just links to other docs pages)
|
||
fn transform_docs_for_skill(content: &str) -> String {
|
||
// Extract title from frontmatter
|
||
let title = ZOLA_FRONTMATTER_PATTERN
|
||
.captures(content)
|
||
.and_then(|caps| caps.get(1))
|
||
.and_then(|fm| ZOLA_TITLE_PATTERN.captures(fm.as_str()))
|
||
.and_then(|caps| caps.get(1))
|
||
.map(|m| m.as_str().to_string());
|
||
|
||
// Strip frontmatter
|
||
let content = ZOLA_FRONTMATTER_PATTERN.replace(content, "");
|
||
|
||
// Strip terminal shortcodes, converting cmd parameters back to bash blocks.
|
||
// Commands joined by `|||` are split into separate lines. Block-form shortcodes
|
||
// with a body interleave command + output, so we keep the `$ ` prompt prefix
|
||
// there to distinguish the two; self-closing shortcodes are pure commands and
|
||
// get no prefix so the block stays copy-pasteable.
|
||
let content = ZOLA_TERMINAL_BODY_PATTERN.replace_all(&content, |caps: ®ex::Captures| {
|
||
let body = caps.get(2).map_or("", |m| m.as_str());
|
||
match caps.get(1) {
|
||
Some(cmd) => cmd_to_bash_block(cmd.as_str(), body, !body.trim().is_empty()),
|
||
None if body.contains(r#"<span class="cmd">"#) => {
|
||
// Old-style body shortcode with <span class="cmd"> — convert to $ lines
|
||
let converted = SPAN_CMD_TO_DOLLAR.replace_all(body, "$$ $1");
|
||
format!("```bash\n{converted}```")
|
||
}
|
||
// Terminal bodies without cmd are styled CLI output demos (colored
|
||
// spans, bold text). Strip HTML to plain text for skill files.
|
||
None => strip_html(body),
|
||
}
|
||
});
|
||
let content =
|
||
ZOLA_TERMINAL_SELF_CLOSING_PATTERN.replace_all(&content, |caps: ®ex::Captures| {
|
||
cmd_to_bash_block(caps.get(1).map_or("", |m| m.as_str()), "", false)
|
||
});
|
||
|
||
// Strip rawcode shortcodes (keep content)
|
||
let content = ZOLA_RAWCODE_PATTERN.replace_all(&content, "$1");
|
||
|
||
// Replace placeholders used to escape Tera template syntax in cmd parameters
|
||
let content = content
|
||
.replace("__WT_OPEN__", "{{")
|
||
.replace("__WT_CLOSE__", "}}")
|
||
.replace("__WT_QUOT__", "\"");
|
||
|
||
// Strip AUTO-GENERATED marker comments (keep content)
|
||
let content = AUTO_GENERATED_MARKER_PATTERN.replace_all(&content, "");
|
||
|
||
// Strip HTML figure elements (demo GIFs)
|
||
let content = HTML_FIGURE_PATTERN.replace_all(&content, "");
|
||
|
||
// Replace experimental markers (shortcode and HTML badge) with plain text.
|
||
// Sourcing the badge HTML from `worktrunk::docs` keeps producer (help.rs)
|
||
// and consumer (this strip) in lockstep: a format change there breaks
|
||
// here at compile time rather than silently leaking HTML into skills.
|
||
let content = ZOLA_EXPERIMENTAL_SHORTCODE.replace_all(&content, "[experimental]");
|
||
let content = content.replace(worktrunk::docs::BADGE_EXPERIMENTAL_HTML, "[experimental]");
|
||
|
||
// Prepend title as H1 if extracted
|
||
let content = if let Some(title) = title {
|
||
format!("# {}\n\n{}", title, content.trim())
|
||
} else {
|
||
content.trim().to_string()
|
||
};
|
||
|
||
// Apply shared finalization: Zola links, See also removal, blank line cleanup
|
||
finalize_skill_content(&content)
|
||
}
|
||
|
||
/// Remove a section from markdown content (from heading to next same-level heading)
|
||
fn remove_section(content: &str, heading: &str) -> String {
|
||
let lines: Vec<&str> = content.lines().collect();
|
||
let heading_level = heading.chars().take_while(|&c| c == '#').count();
|
||
|
||
if let Some(start_idx) = lines.iter().position(|line| line.starts_with(heading)) {
|
||
// Find end: next heading at same or higher level
|
||
let end_idx = lines
|
||
.iter()
|
||
.skip(start_idx + 1)
|
||
.position(|line| {
|
||
let level = line.chars().take_while(|&c| c == '#').count();
|
||
level > 0 && level <= heading_level
|
||
})
|
||
.map(|i| i + start_idx + 1)
|
||
.unwrap_or(lines.len());
|
||
|
||
let mut result: Vec<&str> = lines[..start_idx].to_vec();
|
||
result.extend(&lines[end_idx..]);
|
||
result.join("\n")
|
||
} else {
|
||
content.to_string()
|
||
}
|
||
}
|
||
|
||
/// Convert ```console blocks with $ to terminal shortcodes in all docs files.
|
||
///
|
||
/// Command pages already have this conversion via --help-page, but hand-written
|
||
/// docs (faq.md, llm-commits.md, claude-code.md) can also use ```console with $
|
||
/// and get the same treatment.
|
||
fn convert_console_blocks_in_docs(project_root: &Path) -> (Vec<String>, Vec<String>) {
|
||
let mut errors = Vec::new();
|
||
let mut updated_files = Vec::new();
|
||
let docs_dir = project_root.join("docs/content");
|
||
|
||
for name in docs_content_page_names(&docs_dir) {
|
||
let path = docs_dir.join(&name);
|
||
let content = match fs::read_to_string(&path) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
errors.push(format!("read {}: {e}", path.display()));
|
||
continue;
|
||
}
|
||
};
|
||
let converted = worktrunk::docs::convert_dollar_console_to_terminal(&content);
|
||
if converted != content {
|
||
write_tracked(
|
||
&path,
|
||
&converted,
|
||
format!("docs/content/{name}"),
|
||
&mut updated_files,
|
||
);
|
||
}
|
||
}
|
||
|
||
(errors, updated_files)
|
||
}
|
||
|
||
/// Sorted `.md` page filenames in `docs/content/` (excluding `_index.md`
|
||
/// and similar underscore-prefixed Zola section markers).
|
||
fn docs_content_page_names(docs_dir: &Path) -> Vec<String> {
|
||
let mut names: Vec<String> = fs::read_dir(docs_dir)
|
||
.unwrap_or_else(|e| panic!("Failed to read {}: {}", docs_dir.display(), e))
|
||
.filter_map(|entry| {
|
||
let name = entry.ok()?.file_name().to_string_lossy().into_owned();
|
||
(name.ends_with(".md") && !name.starts_with('_')).then_some(name)
|
||
})
|
||
.collect();
|
||
names.sort();
|
||
names
|
||
}
|
||
|
||
fn sync_skill_files(project_root: &Path) -> (Vec<String>, Vec<String>) {
|
||
let mut errors = Vec::new();
|
||
let mut updated_files = Vec::new();
|
||
|
||
let docs_dir = project_root.join("docs/content");
|
||
let skill_dir = project_root.join("skills/worktrunk/reference");
|
||
|
||
let entries = docs_content_page_names(&docs_dir);
|
||
|
||
for name in &entries {
|
||
let skill_file = skill_dir.join(name);
|
||
let cmd_name = name.trim_end_matches(".md");
|
||
|
||
let expected = if COMMAND_PAGES.contains(&cmd_name) {
|
||
// Command pages: generate directly from --help-page --plain (no HTML)
|
||
match generate_skill_from_help(cmd_name, project_root) {
|
||
Ok(content) => content,
|
||
Err(e) => {
|
||
errors.push(e);
|
||
continue;
|
||
}
|
||
}
|
||
} else {
|
||
// Non-command pages: read from docs, transform Zola syntax, strip residual HTML
|
||
let docs_file = docs_dir.join(name);
|
||
let docs_content = fs::read_to_string(&docs_file)
|
||
.unwrap_or_else(|e| panic!("Failed to read {}: {}", docs_file.display(), e));
|
||
transform_docs_for_skill(&docs_content)
|
||
};
|
||
let expected = trim_lines(&expected);
|
||
|
||
// Treat any read failure (incl. missing) as empty — we'll write `expected` either way.
|
||
let current = trim_lines(&fs::read_to_string(&skill_file).unwrap_or_default());
|
||
|
||
if current != expected {
|
||
write_tracked(
|
||
&skill_file,
|
||
&format!("{expected}\n"),
|
||
format!("skills/worktrunk/reference/{name}"),
|
||
&mut updated_files,
|
||
);
|
||
}
|
||
}
|
||
|
||
(errors, updated_files)
|
||
}
|
||
|
||
/// Generate a skill reference file directly from `--help-page --plain` output.
|
||
///
|
||
/// For command pages, this produces clean markdown without HTML. The only
|
||
/// post-processing needed is Zola link transformation and section cleanup.
|
||
fn generate_skill_from_help(cmd: &str, project_root: &Path) -> Result<String, String> {
|
||
let output = wt_command()
|
||
.args([cmd, "--help-page", "--plain"])
|
||
.current_dir(project_root)
|
||
.output()
|
||
.expect("Failed to run wt --help-page --plain");
|
||
|
||
if !output.status.success() {
|
||
return Err(format!(
|
||
"'wt {} --help-page --plain' failed (exit {}): {}",
|
||
cmd,
|
||
output.status.code().unwrap_or(-1),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
));
|
||
}
|
||
|
||
let content = String::from_utf8_lossy(&output.stdout).to_string();
|
||
if content.trim().is_empty() {
|
||
return Err(format!(
|
||
"Empty output from 'wt {} --help-page --plain': {}",
|
||
cmd,
|
||
String::from_utf8_lossy(&output.stderr)
|
||
));
|
||
}
|
||
|
||
// Expand command placeholders (e.g., <!-- wt list --> → plain text snapshot output)
|
||
let snapshots_dir = project_root.join("tests/snapshots");
|
||
let content = expand_command_placeholders(&content, &snapshots_dir, ExpandMode::Plain)?;
|
||
|
||
Ok(finalize_skill_content(&content))
|
||
}
|
||
|
||
/// Apply final transforms shared between command and non-command skill files:
|
||
/// Zola internal links → full URLs, remove "See also" section, collapse blank lines.
|
||
fn finalize_skill_content(content: &str) -> String {
|
||
// Transform Zola internal links to full URLs
|
||
let content = ZOLA_LINK_PATTERN
|
||
.replace_all(content, |caps: ®ex::Captures| {
|
||
let text = caps.get(1).unwrap().as_str();
|
||
let page = caps.get(2).unwrap().as_str();
|
||
let anchor = caps.get(3).map_or("", |m| m.as_str());
|
||
format!("[{text}](https://worktrunk.dev/{page}/{anchor})")
|
||
})
|
||
.into_owned();
|
||
|
||
// Guardrail: ZOLA_LINK_PATTERN must catch every Zola internal link before
|
||
// we ship skill content, which would otherwise carry a dead link.
|
||
assert_no_untransformed_zola_links(&content, "skill content");
|
||
|
||
// Remove "See also" section (just contains links to other pages)
|
||
let content = remove_section(&content, "## See also");
|
||
|
||
// Clean up multiple consecutive blank lines
|
||
content
|
||
.lines()
|
||
.fold((Vec::new(), false), |(mut acc, prev_blank), line| {
|
||
let is_blank = line.trim().is_empty();
|
||
if !(is_blank && prev_blank) {
|
||
acc.push(line);
|
||
}
|
||
(acc, is_blank)
|
||
})
|
||
.0
|
||
.join("\n")
|
||
}
|
||
|
||
/// Mirror the repo-root `skills/` tree into `plugins/worktrunk/skills/` as
|
||
/// regular files, dereferencing symlinks, and delete mirror files whose
|
||
/// source is gone.
|
||
///
|
||
/// The mirror is what Claude and Codex installs ship. It must hold real files
|
||
/// only: Codex's plugin installer copies the plugin root with a copier that
|
||
/// silently skips symlink entries (`copy_dir_recursive` in codex-rs
|
||
/// core-plugins), so a symlink anywhere in the tree — a `skills` link at the
|
||
/// top or a nested one like `reference/README.md` — ships no content, and a
|
||
/// symlink also materializes as a plain text file on Windows checkouts.
|
||
/// Repo-root `skills/` stays the authored home: Gemini reads it directly, and
|
||
/// the earlier sync stages write into it.
|
||
fn sync_plugin_skills_mirror(project_root: &Path) -> (Vec<String>, Vec<String>) {
|
||
let mut errors = Vec::new();
|
||
let mut updated_files = Vec::new();
|
||
|
||
let source_root = project_root.join("skills");
|
||
let mirror_root = project_root.join("plugins/worktrunk/skills");
|
||
|
||
// `is_dir` and `read` follow symlinks, so linked source content lands in
|
||
// the collected map — and therefore in the mirror — as regular file bytes.
|
||
fn collect_files(
|
||
root: &Path,
|
||
dir: &Path,
|
||
files: &mut BTreeMap<PathBuf, Vec<u8>>,
|
||
) -> std::io::Result<()> {
|
||
for entry in fs::read_dir(dir)? {
|
||
let path = entry?.path();
|
||
if path.is_dir() {
|
||
collect_files(root, &path, files)?;
|
||
} else {
|
||
let rel = path.strip_prefix(root).unwrap().to_path_buf();
|
||
files.insert(rel, fs::read(&path)?);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
let mut source_files = BTreeMap::new();
|
||
if let Err(e) = collect_files(&source_root, &source_root, &mut source_files) {
|
||
errors.push(format!("walk {}: {e}", source_root.display()));
|
||
return (errors, updated_files);
|
||
}
|
||
let mut mirror_files = BTreeMap::new();
|
||
if mirror_root.exists()
|
||
&& let Err(e) = collect_files(&mirror_root, &mirror_root, &mut mirror_files)
|
||
{
|
||
errors.push(format!("walk {}: {e}", mirror_root.display()));
|
||
return (errors, updated_files);
|
||
}
|
||
|
||
for (rel, content) in &source_files {
|
||
if mirror_files
|
||
.get(rel)
|
||
.is_none_or(|mirrored| mirrored != content)
|
||
{
|
||
let dst = mirror_root.join(rel);
|
||
if let Some(parent) = dst.parent() {
|
||
fs::create_dir_all(parent)
|
||
.unwrap_or_else(|e| panic!("Failed to create {}: {}", parent.display(), e));
|
||
}
|
||
fs::write(&dst, content)
|
||
.unwrap_or_else(|e| panic!("Failed to write {}: {}", dst.display(), e));
|
||
updated_files.push(format!("plugins/worktrunk/skills/{}", rel.display()));
|
||
}
|
||
}
|
||
for rel in mirror_files.keys() {
|
||
if !source_files.contains_key(rel) {
|
||
let stale = mirror_root.join(rel);
|
||
fs::remove_file(&stale)
|
||
.unwrap_or_else(|e| panic!("Failed to remove {}: {}", stale.display(), e));
|
||
updated_files.push(format!(
|
||
"plugins/worktrunk/skills/{} (removed)",
|
||
rel.display()
|
||
));
|
||
}
|
||
}
|
||
|
||
(errors, updated_files)
|
||
}
|
||
|
||
/// Sync .well-known/agent-skills/ index.json and verify symlink.
|
||
///
|
||
/// The skill files are served via a symlink:
|
||
/// docs/static/.well-known/agent-skills/worktrunk → ../../../../skills/worktrunk
|
||
///
|
||
/// This function verifies the symlink is correct and generates index.json
|
||
/// with the correct SHA-256 digest per the Cloudflare agent-skills-discovery RFC.
|
||
fn sync_well_known_skills(project_root: &Path) -> (Vec<String>, Vec<String>) {
|
||
let mut errors = Vec::new();
|
||
let mut updated_files = Vec::new();
|
||
|
||
let well_known_dir = project_root.join("docs/static/.well-known/agent-skills");
|
||
let symlink_path = well_known_dir.join("worktrunk");
|
||
|
||
// Verify the symlink exists and points to the right place
|
||
let expected_target = Path::new("../../../../skills/worktrunk");
|
||
match fs::read_link(&symlink_path) {
|
||
Ok(target) if target == expected_target => {}
|
||
Ok(target) => {
|
||
errors.push(format!(
|
||
"Symlink at {} points to {:?}, expected {:?}",
|
||
symlink_path.display(),
|
||
target,
|
||
expected_target
|
||
));
|
||
return (errors, updated_files);
|
||
}
|
||
Err(_) => {
|
||
errors.push(format!(
|
||
"Expected symlink at {} → {:?}, but it doesn't exist or isn't a symlink",
|
||
symlink_path.display(),
|
||
expected_target
|
||
));
|
||
return (errors, updated_files);
|
||
}
|
||
}
|
||
|
||
// Read SKILL.md (through the symlink) for digest and description
|
||
let skill_md_path = symlink_path.join("SKILL.md");
|
||
let skill_md_bytes = match fs::read(&skill_md_path) {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
errors.push(format!("read {}: {e}", skill_md_path.display()));
|
||
return (errors, updated_files);
|
||
}
|
||
};
|
||
|
||
// Generate index.json with SHA-256 digest of SKILL.md
|
||
let digest = {
|
||
use sha2::{Digest, Sha256};
|
||
let hash = Sha256::digest(&skill_md_bytes);
|
||
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
|
||
format!("sha256:{hex}")
|
||
};
|
||
|
||
// Parse the description from SKILL.md frontmatter
|
||
let description = std::str::from_utf8(&skill_md_bytes)
|
||
.ok()
|
||
.and_then(|s| s.strip_prefix("---\n"))
|
||
.and_then(|rest| rest.split_once("\n---"))
|
||
.and_then(|(frontmatter, _)| {
|
||
frontmatter
|
||
.lines()
|
||
.find(|line| line.starts_with("description:"))
|
||
.map(|line| line.trim_start_matches("description:").trim().to_string())
|
||
})
|
||
.unwrap_or_default();
|
||
|
||
let index_json = format!(
|
||
"{{\n \"$schema\": \"https://schemas.agentskills.io/discovery/0.2.0/schema.json\",\n \"skills\": [\n {{\n \"name\": \"worktrunk\",\n \"type\": \"skill-md\",\n \"description\": {description},\n \"url\": \"./worktrunk/SKILL.md\",\n \"digest\": \"{digest}\"\n }}\n ]\n}}\n",
|
||
description = serde_json::to_string(&description).unwrap(),
|
||
);
|
||
|
||
let index_dst = well_known_dir.join("index.json");
|
||
let current_index = fs::read_to_string(&index_dst).unwrap_or_default();
|
||
if current_index != index_json {
|
||
write_tracked(
|
||
&index_dst,
|
||
&index_json,
|
||
"docs/static/.well-known/agent-skills/index.json",
|
||
&mut updated_files,
|
||
);
|
||
}
|
||
|
||
(errors, updated_files)
|
||
}
|
||
|
||
/// Regex for `<!-- wt <id> -->\n```console\n$ <cmd>\n[body]\n``` ` blocks in
|
||
/// `src/cli/mod.rs`. The body is anything between the command line and the
|
||
/// closing fence, captured non-greedily so adjacent blocks don't overlap.
|
||
///
|
||
/// Capture groups:
|
||
/// 1. placeholder id
|
||
/// 2. display command (the `$ ...` line)
|
||
/// 3. body — multiline output lines (may be empty when the placeholder is a
|
||
/// freshly added stub with no snapshot filled in yet).
|
||
static CLI_MOD_EXAMPLE_BODY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"(?s)<!-- (wt [^>\n]+) -->\n```console\n\$ (wt [^\n]+)\n(.*?)```").unwrap()
|
||
});
|
||
|
||
/// Fill the body of each `<!-- wt <id> -->`-tagged ```console``` block in
|
||
/// `src/cli/mod.rs` with the plain-text output of the snapshot registered for
|
||
/// that id. This is the write-back half of the docs-example pipeline: it keeps
|
||
/// the terminal `--help` output (which is served verbatim from the source)
|
||
/// faithful to real command output without requiring hand maintenance.
|
||
///
|
||
/// Runs before `sync_command_pages` so `--help-page` sees the fresh bodies.
|
||
fn sync_cli_mod_example_bodies(project_root: &Path) -> (Vec<String>, Vec<String>) {
|
||
let mut errors = Vec::new();
|
||
let mut updated_files = Vec::new();
|
||
|
||
let cli_mod_path = project_root.join("src/cli/mod.rs");
|
||
let content = match fs::read_to_string(&cli_mod_path) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
errors.push(format!("Failed to read {}: {}", cli_mod_path.display(), e));
|
||
return (errors, updated_files);
|
||
}
|
||
};
|
||
let snapshots_dir = project_root.join("tests/snapshots");
|
||
|
||
// Collect matches first to replace in reverse (preserves byte offsets).
|
||
let matches: Vec<_> = CLI_MOD_EXAMPLE_BODY_PATTERN
|
||
.captures_iter(&content)
|
||
.map(|cap| {
|
||
let m = cap.get(0).unwrap();
|
||
(
|
||
m.start(),
|
||
m.end(),
|
||
cap.get(1).unwrap().as_str().to_string(),
|
||
cap.get(2).unwrap().as_str().to_string(),
|
||
cap.get(3).unwrap().as_str().to_string(),
|
||
)
|
||
})
|
||
.collect();
|
||
|
||
let mut new_content = content.clone();
|
||
for (start, end, placeholder_id, display_cmd, current_body) in matches.into_iter().rev() {
|
||
let Some(snapshot_name) = command_to_snapshot(&placeholder_id) else {
|
||
continue;
|
||
};
|
||
|
||
let snapshot_path = snapshots_dir.join(snapshot_name);
|
||
let snapshot_content = match fs::read_to_string(&snapshot_path) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
errors.push(format!(
|
||
"Failed to read {}: {} (for placeholder '{}')",
|
||
snapshot_path.display(),
|
||
e,
|
||
placeholder_id
|
||
));
|
||
continue;
|
||
}
|
||
};
|
||
|
||
let plain = trim_lines(&parse_snapshot_content_for_skill(&snapshot_content));
|
||
// Body ends with a newline before the closing fence; match the source
|
||
// convention so we don't churn whitespace on subsequent runs.
|
||
let new_body = if plain.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!("{plain}\n")
|
||
};
|
||
let replacement =
|
||
format!("<!-- {placeholder_id} -->\n```console\n$ {display_cmd}\n{new_body}```",);
|
||
|
||
// Compare normalized bodies (trim each line of trailing whitespace) so
|
||
// pre-commit's trailing-whitespace trimmer doesn't create infinite loops.
|
||
if trim_lines(¤t_body) != trim_lines(&new_body) {
|
||
new_content.replace_range(start..end, &replacement);
|
||
}
|
||
}
|
||
|
||
if new_content != content {
|
||
if let Err(e) = fs::write(&cli_mod_path, &new_content) {
|
||
errors.push(format!("Failed to write {}: {}", cli_mod_path.display(), e));
|
||
} else {
|
||
updated_files.push("src/cli/mod.rs".to_string());
|
||
}
|
||
}
|
||
|
||
(errors, updated_files)
|
||
}
|
||
|
||
/// Generate `docs/static/schema/list-v2.json` from `wt list --print-schema`,
|
||
/// publishing it at the `$id` the document carries
|
||
/// (`https://worktrunk.dev/schema/list-v2.json`).
|
||
///
|
||
/// Shells out rather than calling `schema_for!` directly: the `JsonEnvelope`
|
||
/// it derives from lives in the bin-only `crate::commands` tree, which an
|
||
/// integration test can't import. Same reason `sync_command_pages` runs
|
||
/// `--help-page`.
|
||
///
|
||
/// A schemars upgrade rewrites this file. That shows up here as an ordinary
|
||
/// out-of-sync failure, which is the intent — a consumer's schema changing
|
||
/// under a dependency bump should be a reviewed diff.
|
||
fn sync_json_schema(project_root: &Path) -> (Vec<String>, Vec<String>) {
|
||
let mut errors = Vec::new();
|
||
let mut updated_files = Vec::new();
|
||
|
||
let output = wt_command()
|
||
.args(["list", "--print-schema"])
|
||
.current_dir(project_root)
|
||
.output()
|
||
.expect("Failed to run wt list --print-schema");
|
||
|
||
if !output.status.success() {
|
||
errors.push(format!(
|
||
"'wt list --print-schema' failed (exit {}): {}",
|
||
output.status.code().unwrap_or(-1),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
));
|
||
return (errors, updated_files);
|
||
}
|
||
|
||
let generated = String::from_utf8_lossy(&output.stdout).to_string();
|
||
if generated.trim().is_empty() {
|
||
errors.push("Empty output from 'wt list --print-schema'".to_string());
|
||
return (errors, updated_files);
|
||
}
|
||
|
||
let rel_path = "docs/static/schema/list-v2.json";
|
||
let dst = project_root.join(rel_path);
|
||
if fs::read_to_string(&dst).unwrap_or_default() != generated {
|
||
write_tracked(&dst, &generated, rel_path, &mut updated_files);
|
||
}
|
||
|
||
(errors, updated_files)
|
||
}
|
||
|
||
/// Generate `docs/static/llms.txt` from `docs/content/*.md` front-matter,
|
||
/// following the llms.txt spec (https://llmstxt.org/): H1, blockquote summary,
|
||
/// optional intro prose, H2 section headings with bulleted link lists.
|
||
///
|
||
/// Link targets use the `.md` companion URLs (served via symlinks in
|
||
/// `docs/static/*.md` → `skills/worktrunk/reference/*.md`).
|
||
fn sync_llms_txt(project_root: &Path) -> (Vec<String>, Vec<String>) {
|
||
use serde::Deserialize;
|
||
use std::collections::BTreeMap;
|
||
|
||
#[derive(Deserialize)]
|
||
struct ExtraFm {
|
||
group: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct Frontmatter {
|
||
title: String,
|
||
#[serde(default)]
|
||
description: Option<String>,
|
||
weight: i64,
|
||
#[serde(default)]
|
||
extra: Option<ExtraFm>,
|
||
}
|
||
|
||
let mut errors = Vec::new();
|
||
let mut updated = Vec::new();
|
||
|
||
let docs_dir = project_root.join("docs/content");
|
||
let config_path = project_root.join("docs/config.toml");
|
||
|
||
let config_content = match fs::read_to_string(&config_path) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
errors.push(format!("read {}: {e}", config_path.display()));
|
||
return (errors, updated);
|
||
}
|
||
};
|
||
let site_config: toml::Value = match toml::from_str(&config_content) {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
errors.push(format!("parse {}: {e}", config_path.display()));
|
||
return (errors, updated);
|
||
}
|
||
};
|
||
let site_title = site_config
|
||
.get("title")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("Worktrunk");
|
||
let site_description = site_config
|
||
.get("extra")
|
||
.and_then(|e| e.get("site_description"))
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("");
|
||
let base_url = site_config
|
||
.get("base_url")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("https://worktrunk.dev/")
|
||
.trim_end_matches('/');
|
||
|
||
let mut home_intro = String::new();
|
||
// Group pages by `extra.group`; order groups by their minimum weight so
|
||
// `## Commands` (weights 10–17) precedes `## Reference` (21–25) without
|
||
// hard-coding group names.
|
||
let mut groups: BTreeMap<String, Vec<(String, Frontmatter)>> = BTreeMap::new();
|
||
|
||
for name in docs_content_page_names(&docs_dir) {
|
||
let path = docs_dir.join(&name);
|
||
let slug = name.trim_end_matches(".md").to_string();
|
||
let content = match fs::read_to_string(&path) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
errors.push(format!("read {}: {e}", path.display()));
|
||
continue;
|
||
}
|
||
};
|
||
let Some(rest) = content.strip_prefix("+++\n") else {
|
||
continue;
|
||
};
|
||
let Some((fm_text, body)) = rest.split_once("\n+++\n") else {
|
||
continue;
|
||
};
|
||
let fm: Frontmatter = match toml::from_str(fm_text) {
|
||
Ok(fm) => fm,
|
||
Err(e) => {
|
||
errors.push(format!("parse frontmatter of {}: {e}", path.display()));
|
||
continue;
|
||
}
|
||
};
|
||
|
||
// Ungrouped pages supply the document's intro prose instead of becoming bullets.
|
||
let Some(group) = fm.extra.as_ref().and_then(|e| e.group.clone()) else {
|
||
home_intro = extract_intro_prose(body);
|
||
continue;
|
||
};
|
||
|
||
groups.entry(group).or_default().push((slug, fm));
|
||
}
|
||
|
||
for pages in groups.values_mut() {
|
||
pages.sort_by_key(|(_, fm)| fm.weight);
|
||
}
|
||
let mut ordered: Vec<(String, Vec<(String, Frontmatter)>)> = groups.into_iter().collect();
|
||
ordered.sort_by_key(|(_, pages)| pages.first().map(|(_, fm)| fm.weight).unwrap_or(i64::MAX));
|
||
|
||
use std::fmt::Write;
|
||
let mut out = String::new();
|
||
writeln!(out, "# {site_title}\n").unwrap();
|
||
if !site_description.is_empty() {
|
||
writeln!(out, "> {site_description}\n").unwrap();
|
||
}
|
||
if !home_intro.is_empty() {
|
||
writeln!(out, "{home_intro}\n").unwrap();
|
||
}
|
||
for (group, pages) in ordered {
|
||
writeln!(out, "## {group}\n").unwrap();
|
||
for (slug, fm) in pages {
|
||
let desc = fm
|
||
.description
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
.map(|d| format!(": {d}"))
|
||
.unwrap_or_default();
|
||
writeln!(out, "- [{}]({base_url}/{slug}.md){desc}", fm.title).unwrap();
|
||
}
|
||
writeln!(out).unwrap();
|
||
}
|
||
|
||
let out = format!("{}\n", out.trim_end());
|
||
|
||
let dst = project_root.join("docs/static/llms.txt");
|
||
let current = fs::read_to_string(&dst).unwrap_or_default();
|
||
if current != out {
|
||
write_tracked(&dst, &out, "docs/static/llms.txt", &mut updated);
|
||
}
|
||
(errors, updated)
|
||
}
|
||
|
||
/// Take the leading prose paragraphs of a page body, stopping at the first
|
||
/// section heading or HTML block (figure, comment, etc.). The homepage uses
|
||
/// this for the llms.txt intro.
|
||
///
|
||
/// Trims trailing lines ending with `:` — those typically introduce the
|
||
/// content we just cut (a figure, code block, etc.) and dangle without it.
|
||
fn extract_intro_prose(body: &str) -> String {
|
||
let mut lines: Vec<&str> = Vec::new();
|
||
for line in body.lines() {
|
||
let trimmed = line.trim_start();
|
||
if trimmed.starts_with("##") || trimmed.starts_with('<') || trimmed.starts_with("<!--") {
|
||
break;
|
||
}
|
||
lines.push(line);
|
||
}
|
||
while lines
|
||
.last()
|
||
.is_some_and(|l| l.trim_end().ends_with(':') || l.trim().is_empty())
|
||
{
|
||
lines.pop();
|
||
}
|
||
lines.join("\n").trim().to_string()
|
||
}
|
||
|
||
/// Single end-to-end sync test that owns the full pipeline.
|
||
///
|
||
/// Steps run in dependency order, so a single pass converges and there's no
|
||
/// way for nextest parallelism to interleave the stages. The earlier
|
||
/// per-stage tests (`test_docs_quickstart_examples_are_in_sync`,
|
||
/// `test_readme_examples_are_in_sync`) collapsed into this — they shared
|
||
/// state via on-disk docs files, which made test ordering a correctness
|
||
/// requirement, not a performance choice.
|
||
#[test]
|
||
fn test_docs_are_in_sync() {
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
|
||
// Each step's errors and updated-file list are tagged so the failure
|
||
// message tells a developer which stage broke without grepping for code.
|
||
let mut all_errors: Vec<String> = Vec::new();
|
||
let mut all_files: Vec<String> = Vec::new();
|
||
let mut tag = |stage: &str, errors: Vec<String>, files: Vec<String>| {
|
||
all_errors.extend(errors.into_iter().map(|e| format!("[{stage}] {e}")));
|
||
all_files.extend(files.into_iter().map(|f| format!("[{stage}] {f}")));
|
||
};
|
||
|
||
// Step 0: Fill docs-example bodies in src/cli/mod.rs from snapshots. Runs
|
||
// before --help-page reads the file so command pages and skill files see
|
||
// the up-to-date content.
|
||
let (mod_errors, mod_files) = sync_cli_mod_example_bodies(project_root);
|
||
tag("cli/mod.rs", mod_errors, mod_files);
|
||
|
||
// Step 1: Sync command pages (mod.rs → docs/content/*.md)
|
||
let (cmd_errors, cmd_files) = sync_command_pages(project_root);
|
||
tag("command pages", cmd_errors, cmd_files);
|
||
|
||
// Step 1b: Convert $ console blocks to terminal shortcodes in ALL docs
|
||
// (command pages already converted via --help-page; this catches hand-written docs)
|
||
let (console_errors, console_files) = convert_console_blocks_in_docs(project_root);
|
||
tag("console→terminal", console_errors, console_files);
|
||
|
||
// Step 2: Sync standalone docs files (snapshots → docs/content/*.md).
|
||
// README extraction in step 5 reads these, so they must be current first.
|
||
let standalone_doc_files = [
|
||
"docs/content/worktrunk.md",
|
||
"docs/content/claude-code.md",
|
||
"docs/content/tips-patterns.md",
|
||
"docs/content/llm-commits.md",
|
||
];
|
||
let mut docs_errors: Vec<String> = Vec::new();
|
||
let mut docs_files: Vec<String> = Vec::new();
|
||
for doc_file in standalone_doc_files {
|
||
let doc_path = project_root.join(doc_file);
|
||
match sync_docs_snapshots(&doc_path, project_root) {
|
||
Ok(updated) => {
|
||
if updated > 0 {
|
||
docs_files.push(doc_file.to_string());
|
||
}
|
||
}
|
||
Err(errors) => docs_errors.extend(errors),
|
||
}
|
||
}
|
||
tag("standalone docs", docs_errors, docs_files);
|
||
|
||
// Step 3: Sync skill files (docs/content/*.md → skills/*)
|
||
let (skill_errors, skill_files) = sync_skill_files(project_root);
|
||
tag("skill files", skill_errors, skill_files);
|
||
|
||
// Step 3b: Mirror the now-fresh skills/ into plugins/worktrunk/skills/
|
||
// (real files for the plugin payload — Codex's installer drops symlinks)
|
||
let (mirror_errors, mirror_files) = sync_plugin_skills_mirror(project_root);
|
||
tag("plugin skills mirror", mirror_errors, mirror_files);
|
||
|
||
// Step 4: Sync .well-known/agent-skills/ (skills/ → docs/static/)
|
||
let (well_known_errors, well_known_files) = sync_well_known_skills(project_root);
|
||
tag(".well-known", well_known_errors, well_known_files);
|
||
|
||
// Step 5: Generate docs/static/llms.txt from docs/content front-matter
|
||
let (llms_errors, llms_files) = sync_llms_txt(project_root);
|
||
tag("llms.txt", llms_errors, llms_files);
|
||
|
||
// Step 5b: Generate docs/static/schema/list-v2.json from the derived
|
||
// schema. Grouped here because it also writes docs/static/, but it reads
|
||
// the binary rather than the markdown pipeline, so it has no ordering
|
||
// dependency on the steps above.
|
||
let (schema_errors, schema_files) = sync_json_schema(project_root);
|
||
tag("json schema", schema_errors, schema_files);
|
||
|
||
// Step 6: Sync README from the now-fresh docs files. Runs last because
|
||
// section extraction depends on docs/content/*.md being current.
|
||
let readme_path = project_root.join("README.md");
|
||
let readme_content = fs::read_to_string(&readme_path).unwrap();
|
||
let mut readme_errors: Vec<String> = Vec::new();
|
||
let mut readme_files: Vec<String> = Vec::new();
|
||
match sync_readme_markers(&readme_content, project_root) {
|
||
Ok((updated_content, updated_count, total_count)) => {
|
||
assert!(total_count > 0, "No README markers found in README.md");
|
||
if updated_count > 0 {
|
||
fs::write(&readme_path, &updated_content).unwrap();
|
||
readme_files.push(format!(
|
||
"README.md ({updated_count} of {total_count} section(s) updated)"
|
||
));
|
||
}
|
||
}
|
||
Err(errors) => readme_errors.extend(errors),
|
||
}
|
||
tag("README", readme_errors, readme_files);
|
||
|
||
if !all_errors.is_empty() {
|
||
panic!("Sync errors:\n\n{}\n", all_errors.join("\n"));
|
||
}
|
||
|
||
if !all_files.is_empty() {
|
||
panic!(
|
||
"Files out of sync (updated):\n {}\n\nRun tests locally and commit the changes.",
|
||
all_files.join("\n ")
|
||
);
|
||
}
|
||
}
|
||
|
||
/// `AUTO-GENERATED` markers must not nest. The help-page region's close uses
|
||
/// the bare `MARKER_CLOSE`, paired with the open via non-greedy `.*?` — a
|
||
/// nested inner close would cause the regex to chop the region short, leaving
|
||
/// stale content beyond the inner close. This test catches re-introduction of
|
||
/// nesting before that subtle failure mode lands.
|
||
#[test]
|
||
fn test_no_nested_auto_generated_markers() {
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
let mut violations = Vec::new();
|
||
for entry in fs::read_dir(project_root.join("docs/content")).unwrap() {
|
||
let path = entry.unwrap().path();
|
||
if path.extension().is_some_and(|e| e == "md") {
|
||
let content = fs::read_to_string(&path).unwrap();
|
||
let mut depth = 0;
|
||
for (i, line) in content.lines().enumerate() {
|
||
if line.contains(MARKER_OPEN_PREFIX) {
|
||
depth += 1;
|
||
if depth > 1 {
|
||
violations.push(format!(
|
||
"{}:{}: nested AUTO-GENERATED open (depth {depth})",
|
||
path.display(),
|
||
i + 1
|
||
));
|
||
}
|
||
} else if line.contains(MARKER_CLOSE) && depth > 0 {
|
||
depth -= 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert!(
|
||
violations.is_empty(),
|
||
"Nested AUTO-GENERATED markers found — outer help-page regex would chop \
|
||
the region at the inner close. Either flatten the nesting or restore a \
|
||
disambiguating close marker.\n\n{}",
|
||
violations.join("\n")
|
||
);
|
||
}
|
||
|
||
/// `terminal(cmd=...)` shortcodes must use the `__WT_QUOT__` placeholder for
|
||
/// embedded double quotes — the terminal template substitutes that back to `"`
|
||
/// before Syntect highlighting. Raw `"` HTML entities pass through Tera
|
||
/// untouched and render literally in the docs site (see #2495).
|
||
#[test]
|
||
fn test_terminal_cmd_uses_wt_quot_placeholder() {
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
let mut violations = Vec::new();
|
||
for entry in fs::read_dir(project_root.join("docs/content")).unwrap() {
|
||
let path = entry.unwrap().path();
|
||
if path.extension().is_some_and(|e| e == "md") {
|
||
let content = fs::read_to_string(&path).unwrap();
|
||
for (i, line) in content.lines().enumerate() {
|
||
if line.contains("terminal(cmd=") && line.contains(""") {
|
||
violations.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert!(
|
||
violations.is_empty(),
|
||
"Found `"` inside terminal(cmd=...) shortcodes — these render \
|
||
literally on the docs site. Replace with `__WT_QUOT__`.\n\n{}",
|
||
violations.join("\n")
|
||
);
|
||
}
|
||
|
||
/// The hand-authored `## Template variables` table in `src/cli/mod.rs` must
|
||
/// match the variable constants in `src/config/expansion.rs`. Drift means the
|
||
/// help docs lie about which vars hooks and aliases can reference.
|
||
///
|
||
/// Checks presence and group placement; descriptions stay free-form prose.
|
||
#[test]
|
||
fn test_template_variables_table_matches_constants() {
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use strum::IntoEnumIterator;
|
||
use worktrunk::config::{
|
||
ACTIVE_VARS, ALIAS_ARGS_KEY, DEPRECATED_TEMPLATE_VARS, EXEC_BASE_VARS, REPO_VARS,
|
||
ValidationScope, vars_available_in,
|
||
};
|
||
use worktrunk::git::HookType;
|
||
|
||
let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/cli/mod.rs");
|
||
let content = fs::read_to_string(&cli_path).unwrap();
|
||
|
||
// Carve out the `## Template variables` section: from its heading to the
|
||
// next `\n## ` (next level-2 heading). Anchored on the exact heading so an
|
||
// unrelated `## Template …` elsewhere can't be mistaken for it.
|
||
let heading = "\n## Template variables\n";
|
||
let start = content
|
||
.find(heading)
|
||
.expect("`## Template variables` heading missing in src/cli/mod.rs");
|
||
let rest = &content[start + heading.len()..];
|
||
let end = rest.find("\n## ").unwrap_or(rest.len());
|
||
let section = &rest[..end];
|
||
|
||
// Parse table rows: `| kind | `{{ name }}` | description |`. The kind
|
||
// column only appears on the first row of each group — subsequent rows
|
||
// leave it blank, inheriting the last-seen value.
|
||
let var_re = Regex::new(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_.<>]*)\s*\}\}").unwrap();
|
||
let mut actual: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||
let mut current_kind: Option<String> = None;
|
||
for line in section.lines() {
|
||
if !line.starts_with("| ") || line.starts_with("|---") {
|
||
continue;
|
||
}
|
||
let cells: Vec<&str> = line.split('|').map(str::trim).collect();
|
||
// cells[0] and cells[last] are empty (leading/trailing `|`).
|
||
if cells.len() < 4 {
|
||
continue;
|
||
}
|
||
let kind_cell = cells[1];
|
||
let var_cell = cells[2];
|
||
// Skip the header row.
|
||
if kind_cell == "Kind" {
|
||
continue;
|
||
}
|
||
if !kind_cell.is_empty() {
|
||
current_kind = Some(kind_cell.to_string());
|
||
}
|
||
let Some(kind) = current_kind.as_ref() else {
|
||
continue;
|
||
};
|
||
if let Some(cap) = var_re.captures(var_cell) {
|
||
let name = cap[1].to_string();
|
||
actual.entry(kind.clone()).or_default().insert(name);
|
||
}
|
||
}
|
||
|
||
// Build expected groups from constants.
|
||
let mut expected: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||
expected.insert(
|
||
"active".into(),
|
||
ACTIVE_VARS.iter().map(|s| s.to_string()).collect(),
|
||
);
|
||
expected.insert(
|
||
"repo".into(),
|
||
REPO_VARS.iter().map(|s| s.to_string()).collect(),
|
||
);
|
||
// `exec` in the docs = runtime infra vars plus `args` (hook+alias body
|
||
// forwarding). The `hook_type`/`hook_name` names aren't exported as a
|
||
// constant, so they're inlined here — anchoring them to the table row
|
||
// they appear in.
|
||
let mut exec: BTreeSet<String> = EXEC_BASE_VARS.iter().map(|s| s.to_string()).collect();
|
||
exec.insert("hook_type".into());
|
||
exec.insert("hook_name".into());
|
||
exec.insert(ALIAS_ARGS_KEY.to_string());
|
||
expected.insert("exec".into(), exec);
|
||
// `user` row has a single entry — the `{{ vars.<key> }}` placeholder.
|
||
expected.insert("user".into(), BTreeSet::from(["vars.<key>".to_string()]));
|
||
// `operation` = union of hook-type-specific extras. Derived through the
|
||
// public `vars_available_in` so this test doesn't depend on the private
|
||
// `hook_extras` helper.
|
||
let base: BTreeSet<&&str> = ACTIVE_VARS
|
||
.iter()
|
||
.chain(REPO_VARS.iter())
|
||
.chain(EXEC_BASE_VARS.iter())
|
||
.chain(DEPRECATED_TEMPLATE_VARS.iter())
|
||
.collect();
|
||
let infra_and_args: BTreeSet<&str> = ["hook_type", "hook_name", ALIAS_ARGS_KEY].into();
|
||
let mut operation: BTreeSet<String> = BTreeSet::new();
|
||
for ht in HookType::iter() {
|
||
for v in vars_available_in(ValidationScope::Hook(ht)) {
|
||
if !base.contains(&v) && !infra_and_args.contains(v) {
|
||
operation.insert(v.to_string());
|
||
}
|
||
}
|
||
}
|
||
expected.insert("operation".into(), operation);
|
||
|
||
assert_eq!(
|
||
actual, expected,
|
||
"`## Template variables` table in src/cli/mod.rs drifted from \
|
||
constants in src/config/expansion.rs. Update the table or the constants."
|
||
);
|
||
}
|
||
|
||
/// Verify that post_process_for_html() transforms the approval prompt code block
|
||
/// into a styled terminal shortcode. If the source text in cli/mod.rs changes
|
||
/// without updating the replacement in help.rs, the .replace() silently stops
|
||
/// matching and the web docs fall back to a plain code block.
|
||
#[test]
|
||
fn test_approval_prompt_styled_in_hook_page() {
|
||
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||
let output = wt_command()
|
||
.args(["hook", "--help-page"])
|
||
.current_dir(project_root)
|
||
.output()
|
||
.expect("Failed to run wt hook --help-page");
|
||
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
assert!(
|
||
stdout.contains(r#"class="y""#),
|
||
"hook --help-page should contain styled approval prompt (class=\"y\" for yellow ▲). \
|
||
If cli/mod.rs approval example changed, update the replacement in help.rs post_process_for_html()."
|
||
);
|
||
}
|