aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.cargo/config.toml2
-rw-r--r--.config/ci-ignored-dirs.txt5
-rw-r--r--.github/workflows/ci-check-only.yml79
-rw-r--r--.github/workflows/ci.yml75
-rw-r--r--.run/src/bin/build-all.ps18
-rwxr-xr-x.run/src/bin/build-all.sh6
-rw-r--r--.run/src/bin/check-docs-structure.rs352
-rw-r--r--.run/src/bin/ci.py75
-rw-r--r--.run/src/bin/ci.rs464
-rw-r--r--.run/src/bin/clippy.ps18
-rwxr-xr-x.run/src/bin/clippy.sh6
-rw-r--r--.run/src/bin/doc-nightly.ps16
-rwxr-xr-x.run/src/bin/doc-nightly.sh8
-rw-r--r--.run/src/bin/doc.ps15
-rwxr-xr-x.run/src/bin/doc.sh7
-rw-r--r--.run/src/bin/docs-code-box-fix.rs166
-rw-r--r--.run/src/bin/docsify-sidebar-gen.rs262
-rw-r--r--.run/src/bin/refresh-docs.rs178
-rw-r--r--.run/src/bin/refresh-feature-mod.rs97
-rw-r--r--.run/src/bin/sync-examples.rs131
-rw-r--r--.run/src/bin/test-all-markdown-code.rs261
-rw-r--r--.run/src/bin/test-all.ps18
-rw-r--r--.run/src/bin/test-all.sh6
-rw-r--r--.run/src/bin/test-examples.rs197
-rw-r--r--.vscode/settings.json1
-rw-r--r--.zed/settings.json1
-rw-r--r--CONTRIBUTING.md56
-rw-r--r--arg_picker/mingling-ci.toml2
-rw-r--r--arg_picker_macros/mingling-ci.toml2
-rw-r--r--docs/dev/pages/abouts/ci.md148
-rw-r--r--docs/dev/pages/abouts/code-verify-system.md47
-rw-r--r--index.html2
-rw-r--r--mingling/mingling-ci.toml2
-rw-r--r--mingling/src/setups/exit_code.rs6
-rw-r--r--mingling_ci/Cargo.lock777
-rw-r--r--mingling_ci/Cargo.toml47
-rw-r--r--mingling_ci/build.rs6
-rw-r--r--mingling_ci/help.txt34
-rw-r--r--mingling_ci/src/bin/ci.rs30
-rw-r--r--mingling_ci/src/cmd.rs6
-rw-r--r--mingling_ci/src/cmd/cmd_git_lock.rs77
-rw-r--r--mingling_ci/src/cmd/cmd_git_unlock.rs116
-rw-r--r--mingling_ci/src/cmd/cmd_report_clean.rs54
-rw-r--r--mingling_ci/src/cmd/cmd_report_collect.rs150
-rw-r--r--mingling_ci/src/cmd/cmd_show_features.rs26
-rw-r--r--mingling_ci/src/cmd/cmd_show_manifests.rs71
-rw-r--r--mingling_ci/src/examples.rs186
-rw-r--r--mingling_ci/src/git.rs69
-rw-r--r--mingling_ci/src/lib.rs28
-rw-r--r--mingling_ci/src/markdown.rs3
-rw-r--r--mingling_ci/src/markdown/compare.rs203
-rw-r--r--mingling_ci/src/markdown/project.rs347
-rw-r--r--mingling_ci/src/markdown/test.rs152
-rw-r--r--mingling_ci/src/progress.rs24
-rw-r--r--mingling_ci/src/reporter.rs208
-rw-r--r--mingling_ci/src/res.rs14
-rw-r--r--mingling_ci/src/res/collect_logs.rs203
-rw-r--r--mingling_ci/src/res/crate_config.rs79
-rw-r--r--mingling_ci/src/res/features.rs47
-rw-r--r--mingling_ci/src/res/manifests.rs103
-rw-r--r--mingling_ci/src/res/print.rs174
-rw-r--r--mingling_ci/src/task.rs9
-rw-r--r--mingling_ci/src/task/cmd_build_check.rs47
-rw-r--r--mingling_ci/src/task/cmd_clippy_check.rs50
-rw-r--r--mingling_ci/src/task/cmd_docs_check.rs44
-rw-r--r--mingling_ci/src/task/cmd_example_check.rs69
-rw-r--r--mingling_ci/src/task/cmd_markdown_check.rs192
-rw-r--r--mingling_ci/src/task/cmd_markdown_compare.rs221
-rw-r--r--mingling_ci/src/task/cmd_test.rs54
-rw-r--r--mingling_ci/src/task/run.rs114
-rw-r--r--mingling_ci/src/tools.rs3
-rw-r--r--mingling_ci/src/tools/docsify_refresh.rs373
-rw-r--r--mingling_ci/src/tools/example_refresh.rs279
-rw-r--r--mingling_ci/src/tools/features_refresh.rs96
-rw-r--r--mingling_ci/tmpls/report.md9
-rw-r--r--mingling_ci/tmpls/task_section.md18
-rw-r--r--mingling_core/mingling-ci.toml2
-rw-r--r--mingling_macros/mingling-ci.toml2
-rw-r--r--mingling_pathf/mingling-ci.toml2
79 files changed, 5208 insertions, 2289 deletions
diff --git a/.cargo/config.toml b/.cargo/config.toml
index f375b03..f0b35b3 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -4,5 +4,5 @@ target-dir = "./.temp/target"
[env]
[alias]
-ci = "run --manifest-path .run/Cargo.toml --bin ci --quiet --"
+ci = "run --manifest-path mingling_ci/Cargo.toml --bin ci --quiet --"
dev_tool = "run --manifest-path .run/Cargo.toml --quiet --bin "
diff --git a/.config/ci-ignored-dirs.txt b/.config/ci-ignored-dirs.txt
new file mode 100644
index 0000000..08037c9
--- /dev/null
+++ b/.config/ci-ignored-dirs.txt
@@ -0,0 +1,5 @@
+# Temp
+./.temp/
+
+# Self
+./mingling_ci/
diff --git a/.github/workflows/ci-check-only.yml b/.github/workflows/ci-check-only.yml
index 0519310..548026d 100644
--- a/.github/workflows/ci-check-only.yml
+++ b/.github/workflows/ci-check-only.yml
@@ -16,7 +16,6 @@ jobs:
build,
clippy,
test,
- arg-picker,
markdown-code,
examples,
docs-refresh,
@@ -27,15 +26,77 @@ jobs:
- uses: actions/checkout@v7
- uses: actions-rust-lang/setup-rust-toolchain@v1
- - run: cargo ci --check-${{ matrix.item }}
+ - name: Configure git identity
+ run: |
+ git config --global user.email "foo@bar.sqz"
+ git config --global user.name "foo"
- - name: Check translated docs structure consistency
- if: matrix.item == 'markdown-code'
- run: cargo ci --check-docs-structure
+ # Every check runs inside a git-lock / git-unlock pair: the final unlock
+ # doubles as the idempotency check, failing the job when the check left
+ # the working tree dirty (e.g. generated docs are out of date).
+ - name: Lock workspace for CI
+ run: cargo ci git-lock
- - name: Upload docs test result on failure
- if: failure() && matrix.item == 'markdown-code'
+ - name: Run check
+ id: check
+ shell: bash
+ run: |
+ case "${{ matrix.item }}" in
+ build) cargo ci build-check ;;
+ clippy) cargo ci clippy-check ;;
+ # arg-picker tests are covered by test-all through its
+ # mingling-ci.toml override.
+ test) cargo ci test-all ;;
+ # markdown-code also verifies the translated docs structure.
+ markdown-code) cargo ci markdown-check-all && cargo ci markdown-compare-all ;;
+ examples) cargo ci example-check ;;
+ docs-refresh)
+ cargo ci example-refresh
+ cargo ci docsify-refresh
+ cargo ci features-refresh
+ ;;
+ api-docs) cargo ci docs-check ;;
+ esac
+ continue-on-error: true
+
+ - name: Unlock workspace (idempotency check)
+ if: always()
+ run: cargo ci git-unlock --show-diff
+
+ - name: Fail when the check failed
+ if: steps.check.outcome == 'failure'
+ run: exit 1
+
+ # Collected reports are uploaded even on failure, so the Report job can
+ # render the failures.
+ - name: Upload collect reports
+ if: always()
uses: actions/upload-artifact@v4
with:
- name: docs-test-result-${{ matrix.os }}.md
- path: .temp/DOCS-TEST-RESULT.md
+ name: ci-collect-${{ matrix.os }}-${{ matrix.item }}
+ path: .temp/reports/collect/
+ if-no-files-found: ignore
+
+ # Collects every matrix run's reports and renders the consolidated result.md
+ # into the job summary. Runs even when some checks failed, so the failures
+ # are still reported.
+ Report:
+ needs: [Check]
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions-rust-lang/setup-rust-toolchain@v1
+
+ - name: Download collect reports
+ uses: actions/download-artifact@v4
+ with:
+ path: .temp/reports/collect
+ pattern: ci-collect-*
+ merge-multiple: true
+
+ - name: Generate report
+ run: cargo ci report-collect
+
+ - name: Publish report to step summary
+ run: cat .temp/reports/result.md >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a8e1281..56667d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,7 +18,6 @@ jobs:
build,
clippy,
test,
- arg-picker,
markdown-code,
examples,
docs-refresh,
@@ -29,18 +28,52 @@ jobs:
- uses: actions/checkout@v7
- uses: actions-rust-lang/setup-rust-toolchain@v1
- - run: cargo ci --check-${{ matrix.item }}
+ - name: Configure git identity
+ run: |
+ git config --global user.email "foo@bar.sqz"
+ git config --global user.name "foo"
+
+ # Every check runs inside a git-lock / git-unlock pair: the final unlock
+ # doubles as the idempotency check, failing the job when the check left
+ # the working tree dirty (e.g. generated docs are out of date).
+ - name: Lock workspace for CI
+ run: cargo ci git-lock
- - name: Check translated docs structure consistency
- if: matrix.item == 'markdown-code'
- run: cargo ci --check-docs-structure
+ - name: Run check
+ id: check
+ shell: bash
+ run: |
+ case "${{ matrix.item }}" in
+ build) cargo ci build-check ;;
+ clippy) cargo ci clippy-check ;;
+ test) cargo ci test-all ;;
+ # markdown-code also verifies the translated docs structure.
+ markdown-code) cargo ci markdown-check-all && cargo ci markdown-compare-all ;;
+ examples) cargo ci example-check ;;
+ docs-refresh)
+ cargo ci example-refresh
+ cargo ci docsify-refresh
+ cargo ci features-refresh
+ ;;
+ api-docs) cargo ci docs-check ;;
+ esac
+ continue-on-error: true
- - name: Upload docs test result on failure
- if: failure() && matrix.item == 'markdown-code'
+ - name: Unlock workspace (idempotency check)
+ if: always()
+ run: cargo ci git-unlock --show-diff
+
+ - name: Fail when the check failed
+ if: steps.check.outcome == 'failure'
+ run: exit 1
+
+ - name: Upload collect reports
+ if: always()
uses: actions/upload-artifact@v4
with:
- name: docs-test-result-${{ matrix.os }}.md
- path: .temp/DOCS-TEST-RESULT.md
+ name: ci-collect-${{ matrix.os }}-${{ matrix.item }}
+ path: .temp/reports/collect/
+ if-no-files-found: ignore
# Build the distributable packages in parallel with the checks, so that the
# `unreleased` tag and the Pages deployment always publish fresh artifacts.
@@ -105,6 +138,30 @@ jobs:
git tag -f unreleased
git push origin unreleased --force
+ # Collects every matrix run's reports and renders the consolidated result.md
+ # into the job summary. Runs even when some checks failed, so the failures
+ # are still reported.
+ Report:
+ needs: [Check]
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions-rust-lang/setup-rust-toolchain@v1
+
+ - name: Download collect reports
+ uses: actions/download-artifact@v4
+ with:
+ path: .temp/reports/collect
+ pattern: ci-collect-*
+ merge-multiple: true
+
+ - name: Generate report
+ run: cargo ci report-collect
+
+ - name: Publish report to step summary
+ run: cat .temp/reports/result.md >> "$GITHUB_STEP_SUMMARY"
+
Deploy-Github-Pages:
needs: [Move-Unreleased-Tag]
permissions:
diff --git a/.run/src/bin/build-all.ps1 b/.run/src/bin/build-all.ps1
deleted file mode 100644
index 4f35ed8..0000000
--- a/.run/src/bin/build-all.ps1
+++ /dev/null
@@ -1,8 +0,0 @@
-$starting_dir = Get-Location
-Get-ChildItem -Recurse -Filter "Cargo.toml" | ForEach-Object {
- $project_dir = $_.DirectoryName
- Push-Location $project_dir
- cargo build
- Pop-Location
-}
-Set-Location $starting_dir
diff --git a/.run/src/bin/build-all.sh b/.run/src/bin/build-all.sh
deleted file mode 100755
index 2036b41..0000000
--- a/.run/src/bin/build-all.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/bash
-
-find . -name "Cargo.toml" -type f | while read -r cargo_file; do
- project_dir=$(dirname "$cargo_file")
- (cd "$project_dir" && cargo build)
-done
diff --git a/.run/src/bin/check-docs-structure.rs b/.run/src/bin/check-docs-structure.rs
deleted file mode 100644
index ac13da2..0000000
--- a/.run/src/bin/check-docs-structure.rs
+++ /dev/null
@@ -1,352 +0,0 @@
-//! Checks that every translated docs directory mirrors the structure of the
-//! reference (English) docs directory exactly.
-//!
-//! The language directories are declared in `.config/docs-lang.txt`, one path
-//! per line (relative to `./docs/`). The first line is the reference
-//! directory; every other line is a translation that must match it.
-//!
-//! For each file pair the tool compares a *structural signature*: one token per
-//! line, classifying headings (both Markdown `#` and HTML `<hN>`), fenced code
-//! blocks (including their language tag), `@@@` hidden-compilation lines, blank
-//! lines, blockquotes, lists and plain text. Translated text is allowed to
-//! differ; the structure is not.
-
-use std::collections::BTreeSet;
-use std::fs;
-use std::path::{Path, PathBuf};
-
-use colored::Colorize;
-use tools::println_cargo_style;
-
-const DOCS_DIR: &str = "./docs";
-const LANG_CONFIG: &str = ".config/docs-lang.txt";
-
-fn main() {
- println_cargo_style!("Checking: docs structure consistency across languages ...");
-
- let repo_root = find_git_repo().expect("Cannot find git repo root");
- let docs_dir = repo_root.join(DOCS_DIR);
-
- let lang_lines = read_lang_config(&repo_root);
- if lang_lines.is_empty() {
- println!("No language directories declared in {LANG_CONFIG}, nothing to check.");
- return;
- }
-
- let reference = docs_dir.join(&lang_lines[0]);
- if !reference.is_dir() {
- eprintln!(
- "Reference docs directory `{}` does not exist.",
- reference.display()
- );
- std::process::exit(1);
- }
-
- let mut failed = false;
-
- for lang in &lang_lines[1..] {
- let lang_dir = docs_dir.join(lang);
- println!("\nChecking `{lang}` against `{}` ...", lang_lines[0]);
- if !lang_dir.is_dir() {
- eprintln!(" ERROR: `{}` does not exist.", lang_dir.display());
- failed = true;
- continue;
- }
- if check_lang_dir(&reference, &lang_dir).is_err() {
- failed = true;
- }
- }
-
- if failed {
- println!();
- eprintln!(
- "{} Fix the differences above.",
- "Docs structure check FAILED.".red().bold()
- );
- std::process::exit(1);
- }
-
- println_cargo_style!("Done: docs structure is consistent across all languages!");
-}
-
-fn read_lang_config(repo_root: &Path) -> Vec<String> {
- let path = repo_root.join(LANG_CONFIG);
- let Ok(content) = fs::read_to_string(&path) else {
- return Vec::new();
- };
- content
- .lines()
- .map(str::trim)
- .filter(|l| !l.is_empty() && !l.starts_with('#'))
- .map(|l| l.trim_start_matches("./").to_string())
- .collect()
-}
-
-/// Returns `Err(())` when the translated directory does not mirror the reference.
-fn check_lang_dir(reference: &Path, lang: &Path) -> Result<(), ()> {
- let mut failed = false;
-
- let ref_files = collect_md_files(reference);
- let lang_files = collect_md_files(lang);
-
- let ref_set: BTreeSet<PathBuf> = ref_files.clone().into_iter().collect();
- let lang_set: BTreeSet<PathBuf> = lang_files.clone().into_iter().collect();
-
- let missing: Vec<PathBuf> = ref_set.difference(&lang_set).cloned().collect();
- let extra: Vec<PathBuf> = lang_set.difference(&ref_set).cloned().collect();
-
- if !missing.is_empty() {
- failed = true;
- println!(" ERROR: files missing in translation:");
- for f in &missing {
- println!(" - {}", f.display());
- }
- }
- if !extra.is_empty() {
- failed = true;
- println!(" ERROR: extra files in translation:");
- for f in &extra {
- println!(" - {}", f.display());
- }
- }
-
- // Compare the structural signature of every file present in both sides.
- for file in &ref_files {
- if !lang_set.contains(file) {
- continue;
- }
- let ref_path = reference.join(file);
- let lang_path = lang.join(file);
- match compare_signature(&ref_path, &lang_path) {
- Ok(()) => {}
- Err(diff) => {
- failed = true;
- eprintln!(
- " {}: structure mismatch in `{}`",
- "ERROR".red().bold(),
- file.display().to_string().cyan()
- );
- for line in diff {
- println!(" {line}");
- }
- }
- }
- }
-
- if failed { Err(()) } else { Ok(()) }
-}
-
-fn collect_md_files(dir: &Path) -> Vec<PathBuf> {
- let mut out = Vec::new();
- let mut stack = vec![dir.to_path_buf()];
- while let Some(current) = stack.pop() {
- let Ok(entries) = fs::read_dir(&current) else {
- continue;
- };
- for entry in entries.flatten() {
- let path = entry.path();
- if path.is_dir() {
- stack.push(path);
- } else if path.extension().is_some_and(|e| e == "md") {
- out.push(path.strip_prefix(dir).unwrap_or(&path).to_path_buf());
- }
- }
- }
- out.sort();
- out
-}
-
-/// Compare the structural signatures of two markdown files.
-///
-/// Returns a list of human-readable diff lines on the first structural
-/// difference found (all differences up to a small window are reported).
-fn compare_signature(ref_path: &Path, lang_path: &Path) -> Result<(), Vec<String>> {
- let ref_content = fs::read_to_string(ref_path).unwrap_or_default();
- let lang_content = fs::read_to_string(lang_path).unwrap_or_default();
-
- let ref_sig = signature_of(&ref_content);
- let lang_sig = signature_of(&lang_content);
-
- if ref_sig == lang_sig {
- return Ok(());
- }
-
- let ref_lines: Vec<&str> = ref_content.lines().collect();
- let lang_lines: Vec<&str> = lang_content.lines().collect();
-
- let mut diffs = Vec::new();
- let mut window = 0;
- let max = ref_sig.len().max(lang_sig.len());
- for i in 0..max {
- let ref_tok = ref_sig.get(i);
- let lang_tok = lang_sig.get(i);
- if ref_tok == lang_tok {
- continue;
- }
- if window >= 5 {
- diffs.push(format!("... ({}-line window truncated)", max - i));
- break;
- }
- window += 1;
- let ref_line = ref_lines.get(i).copied().unwrap_or("<missing>");
- let lang_line = lang_lines.get(i).copied().unwrap_or("<missing>");
- diffs.push(format!(
- " {}: {}",
- "line".yellow().bold(),
- (i + 1).to_string().yellow()
- ));
- diffs.push(format!(
- " {} : {} {}",
- "expect".green().bold(),
- format!("`{}`", token_label(ref_tok.map_or("<eof>", String::as_str))).green(),
- display_line(ref_line).cyan()
- ));
- diffs.push(format!(
- " {} : {} {}",
- "found".red().bold(),
- format!(
- "`{}`",
- token_label(lang_tok.map_or("<eof>", String::as_str))
- )
- .red(),
- display_line(lang_line).cyan()
- ));
- if ref_sig.len() != lang_sig.len() && window >= 5 {
- diffs.push(format!(
- " note: reference has {} lines, translation has {} lines",
- ref_sig.len(),
- lang_sig.len()
- ));
- break;
- }
- }
- if diffs.is_empty() {
- diffs.push("signatures differ in length (see line count note)".to_string());
- }
- Err(diffs)
-}
-
-/// Human-readable label for a structural token.
-fn token_label(token: &str) -> String {
- match token {
- "B" => "blank".to_string(),
- "A" => "@@@".to_string(),
- "Q" => "quote".to_string(),
- "L" => "list".to_string(),
- "P" => "text".to_string(),
- t if t.starts_with("H") => format!("heading-{}", &t[1..]),
- t if t.starts_with("F:") => {
- let lang = &t[2..];
- if lang.is_empty() {
- "fence".to_string()
- } else {
- format!("fence:{lang}")
- }
- }
- _ => token.to_string(),
- }
-}
-
-/// Render a source line for display: blank lines become `<blank>`.
-fn display_line(line: &str) -> String {
- if line.trim().is_empty() {
- "<blank>".to_string()
- } else {
- truncate(line)
- }
-}
-
-/// Build the structural signature of a markdown file.
-fn signature_of(content: &str) -> Vec<String> {
- let mut sig = Vec::new();
- let mut in_fence = false;
- let mut fence_lang = String::new();
-
- for raw_line in content.lines() {
- let line = raw_line.trim();
-
- if in_fence {
- if line.starts_with("```") {
- in_fence = false;
- sig.push(format!("F:{}", fence_lang));
- } else if line.starts_with("@@@") {
- sig.push("A".to_string());
- } else if line.is_empty() {
- sig.push("B".to_string());
- } else {
- sig.push("P".to_string());
- }
- continue;
- }
-
- if line.starts_with("```") {
- in_fence = true;
- fence_lang = line.trim_start_matches("```").trim().to_string();
- sig.push(format!("F:{fence_lang}"));
- } else if line.starts_with('#') {
- let level = line.chars().take_while(|c| *c == '#').count();
- sig.push(format!("H{level}"));
- } else if line.starts_with("<h") || line.starts_with("</h") {
- // HTML headings (e.g. `<h1 align="center">` / `</h1>`)
- let level = line
- .trim_start_matches(['<', '/'])
- .chars()
- .next()
- .and_then(|c| c.to_digit(10))
- .unwrap_or(1);
- sig.push(format!("H{level}"));
- } else if line.starts_with("@@@") {
- sig.push("A".to_string());
- } else if line.is_empty() {
- sig.push("B".to_string());
- } else if line.starts_with('>') {
- sig.push("Q".to_string());
- } else if is_list_line(line) {
- sig.push("L".to_string());
- } else {
- sig.push("P".to_string());
- }
- }
-
- // An unclosed fence is still a fence line; the signature already recorded it.
- sig
-}
-
-fn is_list_line(line: &str) -> bool {
- let trimmed = line.trim_start();
- trimmed.starts_with("- ")
- || trimmed.starts_with("* ")
- || trimmed.starts_with("+ ")
- || is_numbered_list(trimmed)
-}
-
-/// A numbered list item: `1. text`, `1) text`, `10. text`, ...
-fn is_numbered_list(line: &str) -> bool {
- let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count();
- if digit_count == 0 {
- return false;
- }
- let rest = &line[digit_count..];
- (rest.starts_with(". ") || rest.starts_with(") "))
- && rest.chars().nth(1).is_some_and(|c| c == ' ' || c == '\t')
-}
-
-fn truncate(line: &str) -> String {
- const MAX: usize = 60;
- if line.chars().count() <= MAX {
- line.to_string()
- } else {
- let cut: String = line.chars().take(MAX).collect();
- format!("{cut}...")
- }
-}
-
-fn find_git_repo() -> Option<PathBuf> {
- let mut current = std::env::current_dir().ok()?;
- loop {
- if current.join(".git").is_dir() {
- return Some(current);
- }
- current = current.parent()?.to_path_buf();
- }
-}
diff --git a/.run/src/bin/ci.py b/.run/src/bin/ci.py
new file mode 100644
index 0000000..6234a19
--- /dev/null
+++ b/.run/src/bin/ci.py
@@ -0,0 +1,75 @@
+"""Full CI orchestration for the mingling project.
+
+Runs every `cargo ci` step in order: lock the workspace, run all checks,
+refresh the generated artifacts, then unlock. The final `git-unlock` doubles
+as the idempotency check: it fails with a non-zero exit code when the run
+left the working tree dirty.
+
+The script locates the git repository root and runs with it as the working
+directory, so it can be invoked from anywhere inside the repo.
+"""
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+# The pipeline steps, in execution order, as (command, args) pairs.
+STEPS: list[tuple[str, list[str]]] = [
+ ("git-lock", []),
+ ("report-clean", []),
+ ("build-check", []),
+ ("clippy-check", []),
+ ("test-all", []),
+ ("example-check", []),
+ ("docs-check", []),
+ ("example-refresh", []),
+ ("docsify-refresh", []),
+ ("features-refresh", []),
+ # Idempotency check: exits non-zero if CI contaminated the workspace, and
+ # prints the diff of the contamination before restoring.
+ ("git-unlock", ["--show-diff"]),
+]
+
+
+def find_repo_root() -> Path:
+ """Return the nearest ancestor directory containing `.git`."""
+ current = Path.cwd()
+ for directory in (current, *current.parents):
+ if (directory / ".git").is_dir():
+ return directory
+ raise SystemExit("error: not inside a git repository")
+
+
+def main() -> int:
+ root = find_repo_root()
+ os.chdir(root)
+
+ # Signature banner: docs/res/ci_banner.txt, relative to this script
+ # (.run/src/bin -> four levels up is the repo root).
+ banner = (
+ Path(__file__).resolve().parent.parent.parent.parent
+ / "docs"
+ / "res"
+ / "ci_banner.txt"
+ )
+ try:
+ print(banner.read_text(encoding="utf-8"), end="")
+ except OSError:
+ pass
+
+ for command, args in STEPS:
+ print(f"==> cargo ci {' '.join([command, *args])}")
+ result = subprocess.run(["cargo", "ci", command, *args], check=False)
+ if result.returncode != 0:
+ print(
+ f"error: step `{command}` failed with exit code {result.returncode}",
+ file=sys.stderr,
+ )
+ return result.returncode
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.run/src/bin/ci.rs b/.run/src/bin/ci.rs
deleted file mode 100644
index b6d92b8..0000000
--- a/.run/src/bin/ci.rs
+++ /dev/null
@@ -1,464 +0,0 @@
-use std::io::Write as _;
-use std::path::{Path, PathBuf};
-use std::process::exit;
-
-use arg_picker::{Picker, macros::arg};
-use tools::{
- cargo_tomls, crate_name_from, eprintln_cargo_style, println_cargo_style, run_cmd, run_parallel,
-};
-
-fn get_ignore_dirs() -> Vec<String> {
- vec![".temp".to_string()]
-}
-
-/// A single CI step, each individually toggleable via `--check-*`.
-struct Checks {
- build: bool,
- clippy: bool,
- test: bool,
- arg_picker: bool,
- markdown_code: bool,
- examples: bool,
- docs_refresh: bool,
- docs_structure: bool,
- api_docs: bool,
-}
-
-impl Checks {
- fn any(&self) -> bool {
- self.build
- || self.clippy
- || self.test
- || self.arg_picker
- || self.markdown_code
- || self.examples
- || self.docs_refresh
- || self.docs_structure
- || self.api_docs
- }
-}
-
-fn print_help() {
- println!(
- r"
-Usage: ci [options]
-Options:
- -h, --help Print this help message
- -y Auto-confirm temporary commits
- --dirty Run CI on dirty workspace (skip temp commit & clean check)
- --check-build Build all crates
- --check-clippy Run clippy on all crates (-D warnings)
- --check-test Run unit tests for all crates
- --check-arg-picker Test the arg-picker crate
- --check-markdown-code Verify all *.md code blocks compile
- --check-examples Test all examples
- --check-docs-refresh Refresh docs and fail if the tree is contaminated
- --check-docs-structure Verify translated docs mirror the English structure
- --check-api-docs Build API docs with docs.rs features
-
-If no specific options are given, all checks are run.
- "
- );
-}
-
-fn main() {
- #[cfg(windows)]
- let _ = colored::control::set_virtual_terminal(true);
- println!("{}", include_str!("../../../docs/res/ci_banner.txt"));
-
- let (
- auto_yes,
- dirty,
- check_build,
- check_clippy,
- check_test,
- check_arg_picker,
- check_markdown_code,
- check_examples,
- check_docs_refresh,
- check_docs_structure,
- check_api_docs,
- help,
- ) = Picker::from_args()
- .pick_or_default(&arg![yes: bool, 'y'])
- .pick_or_default(&arg![dirty: bool])
- .pick_or_default(&arg![check_build: bool])
- .pick_or_default(&arg![check_clippy: bool])
- .pick_or_default(&arg![check_test: bool])
- .pick_or_default(&arg![check_arg_picker: bool])
- .pick_or_default(&arg![check_markdown_code: bool])
- .pick_or_default(&arg![check_examples: bool])
- .pick_or_default(&arg![check_docs_refresh: bool])
- .pick_or_default(&arg![check_docs_structure: bool])
- .pick_or_default(&arg![check_api_docs: bool])
- .pick_or_default(&arg![help: bool, 'h'])
- .unwrap();
-
- if help {
- print_help();
- return;
- }
-
- let checks = Checks {
- build: check_build,
- clippy: check_clippy,
- test: check_test,
- arg_picker: check_arg_picker,
- markdown_code: check_markdown_code,
- examples: check_examples,
- docs_refresh: check_docs_refresh,
- docs_structure: check_docs_structure,
- api_docs: check_api_docs,
- };
- let run_all = !checks.any();
-
- let needs_commit_temp = !dirty && !{ run_cmd!("git diff-index --quiet HEAD --").is_ok() };
-
- if needs_commit_temp {
- if auto_yes {
- run_cmd!("git add .").unwrap();
- run_cmd!("git commit -m \"[DO NOT PUSH] CI TEMP [DO NOT PUSH]\"").unwrap();
- } else {
- print!("Working tree is not clean, temporarily commit? [y/N]:");
- std::io::stdout().flush().unwrap();
- let mut input = String::new();
- std::io::stdin().read_line(&mut input).unwrap();
- let input = input.trim();
- if input == "y" || input == "Y" || input == "yes" || input == "Yes" {
- run_cmd!("git add .").unwrap();
- run_cmd!("git commit -m \"[DO NOT PUSH] CI TEMP [DO NOT PUSH]\"").unwrap();
- } else {
- eprintln_cargo_style!("Aborting.");
- exit(2)
- }
- }
- }
-
- if let Err(exit_code) = ci(&checks, run_all) {
- restore_workspace(needs_commit_temp).unwrap();
- exit(exit_code)
- }
-
- if !dirty {
- let is_worktree_clean = run_cmd!("git diff-index --quiet HEAD --").is_ok();
- if !is_worktree_clean {
- eprintln_cargo_style!("The repository was contaminated during CI, failing!");
-
- // Print git status
- println!();
- let _ = run_cmd!("git status");
-
- if needs_commit_temp {
- restore_workspace(true).unwrap();
- }
- exit(1)
- }
- }
-
- println_cargo_style!("Done: All check passed!");
-
- if needs_commit_temp {
- restore_workspace(true).unwrap();
- }
-}
-
-fn restore_workspace(undo_commit: bool) -> Result<(), i32> {
- run_cmd!("git reset --hard --quiet")?;
- if undo_commit {
- run_cmd!("git reset --soft HEAD~1 --quiet")?;
- run_cmd!("git reset --quiet")?;
- }
- Ok(())
-}
-
-/// Run one CI step.
-///
-/// When `continue_on_error` is set (used for the documentation steps in
-/// "run all" mode), a failing step is recorded and the remaining steps still
-/// execute, so every problem is reported in a single run.
-fn run_step(
- exit_code: &mut i32,
- phase: &str,
- step: fn() -> Result<(), i32>,
- continue_on_error: bool,
-) -> Result<(), i32> {
- println_cargo_style!(phase);
- match step() {
- Ok(()) => Ok(()),
- Err(code) if continue_on_error => {
- *exit_code = (*exit_code).max(code);
- Ok(())
- }
- Err(code) => Err(code),
- }
-}
-
-fn ci(checks: &Checks, run_all: bool) -> Result<(), i32> {
- let mut exit_code = 0;
-
- if run_all || checks.build {
- run_step(
- &mut exit_code,
- "Phase: Scan and build all crates",
- build_all,
- false,
- )?;
- }
- if run_all || checks.clippy {
- run_step(
- &mut exit_code,
- "Phase: Run clippy for all crates",
- clippy_all,
- false,
- )?;
- }
- if run_all || checks.test {
- run_step(&mut exit_code, "Phase: Test all crates", test_all, false)?;
- }
- if run_all || checks.arg_picker {
- run_step(
- &mut exit_code,
- "Phase: Test arg picker",
- test_arg_picker,
- false,
- )?;
- }
-
- if run_all || checks.markdown_code {
- run_step(
- &mut exit_code,
- "Phase: Verify all *.md document code blocks are compilable",
- test_docs_code_blocks,
- run_all,
- )?;
- }
- if run_all || checks.examples {
- run_step(
- &mut exit_code,
- "Phase: Test all examples",
- test_examples,
- run_all,
- )?;
- }
- if run_all || checks.docs_refresh {
- run_step(
- &mut exit_code,
- "Phase: Check all documentation is up to date",
- docs_refresh,
- run_all,
- )?;
- }
- if run_all || checks.docs_structure {
- run_step(
- &mut exit_code,
- "Phase: Check translated docs structure consistency",
- docs_structure,
- run_all,
- )?;
- }
- if run_all || checks.api_docs {
- run_step(
- &mut exit_code,
- "Phase: Try Build API docs",
- deploy_api_docs,
- run_all,
- )?;
- }
-
- if exit_code != 0 {
- return Err(exit_code);
- }
-
- run_cmd!("git add --renormalize .")?;
-
- Ok(())
-}
-
-fn test_examples() -> Result<(), i32> {
- run_cmd!("cargo run --manifest-path .run/Cargo.toml --color always --bin test-examples")
-}
-
-fn test_docs_code_blocks() -> Result<(), i32> {
- run_cmd!(
- "cargo run --manifest-path .run/Cargo.toml --color always --bin test-all-markdown-code"
- )
-}
-
-/// Returns the manifest paths of all workspace members (via `cargo metadata --no-deps`).
-///
-/// These crates are tested/built/clipped together with `--workspace` so that
-/// feature-gated code is covered, instead of relying on each crate's default features.
-fn workspace_manifests() -> Vec<PathBuf> {
- let Ok(output) = tools::run_cmd_capture("cargo metadata --no-deps --format-version 1") else {
- return Vec::new();
- };
- let Ok(json) = serde_json::from_str::<serde_json::Value>(&output) else {
- return Vec::new();
- };
- json["packages"]
- .as_array()
- .into_iter()
- .flatten()
- .filter_map(|p| p["manifest_path"].as_str().map(PathBuf::from))
- .collect()
-}
-
-fn same_path(a: &Path, b: &Path) -> bool {
- let norm = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
- norm(a) == norm(b)
-}
-
-fn build_all() -> Result<(), i32> {
- let ignore_dirs = get_ignore_dirs();
- let cargo_tomls = cargo_tomls();
- let workspace_manifests = workspace_manifests();
- let mut tasks = Vec::new();
-
- // Workspace members: build with all documented features (same set used by cov-test)
- let features_arg = doc_features_arg();
- tasks.push((
- "Build: workspace".to_string(),
- "workspace".to_string(),
- format!("cargo build --workspace{features_arg} --color always"),
- ));
-
- for cargo_toml in cargo_tomls {
- let path = cargo_toml.parent().unwrap_or(Path::new(""));
- let path_str = path.to_string_lossy();
- if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) {
- continue;
- }
- if workspace_manifests
- .iter()
- .any(|m| same_path(m, &cargo_toml))
- {
- continue;
- }
- let label = format!("Build: {}", cargo_toml.to_string_lossy());
- let crate_name = crate_name_from(&cargo_toml);
- let cmd = format!(
- "cargo build --manifest-path {} --color always",
- cargo_toml.to_string_lossy()
- );
- tasks.push((label, crate_name, cmd));
- }
- run_parallel("Building", tasks)
-}
-
-fn clippy_all() -> Result<(), i32> {
- let ignore_dirs = get_ignore_dirs();
- let cargo_tomls = cargo_tomls();
- let workspace_manifests = workspace_manifests();
- let mut tasks = Vec::new();
-
- // Workspace members: clippy with all documented features
- let features_arg = doc_features_arg();
- tasks.push((
- "Clippy: workspace".to_string(),
- "workspace".to_string(),
- format!("cargo clippy --workspace{features_arg} --color always -- -D warnings"),
- ));
-
- for cargo_toml in cargo_tomls {
- let path = cargo_toml.parent().unwrap_or(Path::new(""));
- let path_str = path.to_string_lossy();
- if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) {
- continue;
- }
- if workspace_manifests
- .iter()
- .any(|m| same_path(m, &cargo_toml))
- {
- continue;
- }
- let label = format!("Clippy: {}", cargo_toml.to_string_lossy());
- let crate_name = crate_name_from(&cargo_toml);
- let cmd = format!(
- "cargo clippy --manifest-path {} --color always -- -D warnings",
- cargo_toml.to_string_lossy()
- );
- tasks.push((label, crate_name, cmd));
- }
- run_parallel("Clippy", tasks)
-}
-
-/// ` --features "<docs.rs features>"` (empty string when unavailable)
-fn doc_features_arg() -> String {
- match tools::read_features() {
- Ok(features) if !features.is_empty() => format!(" --features \"{}\"", features.join(",")),
- _ => String::new(),
- }
-}
-
-fn test_all() -> Result<(), i32> {
- let ignore_dirs = get_ignore_dirs();
- let cargo_tomls = cargo_tomls();
- let workspace_manifests = workspace_manifests();
- let mut tasks = Vec::new();
-
- // Workspace members: test with all documented features so that feature-gated
- // tests (comp/repl/picker/structural_renderer/...) are actually executed.
- // `arg-picker` is excluded here and tested separately via [`test_arg_picker`].
- let features_arg = doc_features_arg();
- tasks.push((
- "Test: workspace".to_string(),
- "workspace".to_string(),
- format!("cargo test --workspace{features_arg} --exclude arg-picker --color always"),
- ));
-
- for cargo_toml in cargo_tomls {
- let path = cargo_toml.parent().unwrap_or(Path::new(""));
- let path_str = path.to_string_lossy();
- if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) {
- continue;
- }
- if workspace_manifests
- .iter()
- .any(|m| same_path(m, &cargo_toml))
- {
- continue;
- }
- let label = format!("Test: {}", cargo_toml.to_string_lossy());
- let crate_name = crate_name_from(&cargo_toml);
- let cmd = format!(
- "cargo test --manifest-path {} --color always",
- cargo_toml.to_string_lossy()
- );
- tasks.push((label, crate_name, cmd));
- }
- run_parallel("Testing", tasks)
-}
-
-/// `arg-picker` is excluded from the workspace test command: when built with
-/// `mingling_support` (enabled via `mingling/picker`), its README doctests
-/// expand `arg!` to `::mingling::picker::PickerArg`, which is not available
-/// inside the arg-picker crate itself. Test it separately with its default
-/// features instead.
-fn test_arg_picker() -> Result<(), i32> {
- run_cmd!("cargo test -p arg-picker --color always")
-}
-
-fn deploy_api_docs() -> Result<(), i32> {
- run_cmd!(
- "cargo run --manifest-path .run/Cargo.toml --color always --bin deploy-api-docs -- --docsrs"
- )
-}
-
-fn docs_refresh() -> Result<(), i32> {
- println_cargo_style!("Refresh: document at `./docs/`");
-
- run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin docs-code-box-fix")?;
- run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin docsify-sidebar-gen")?;
- run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin refresh-docs")?;
- run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin refresh-feature-mod")?;
- run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin sync-examples")?;
- run_cmd!("cargo fmt")?;
-
- Ok(())
-}
-
-fn docs_structure() -> Result<(), i32> {
- println_cargo_style!("Check: docs structure consistency across languages");
-
- run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin check-docs-structure")
-}
diff --git a/.run/src/bin/clippy.ps1 b/.run/src/bin/clippy.ps1
deleted file mode 100644
index 1858873..0000000
--- a/.run/src/bin/clippy.ps1
+++ /dev/null
@@ -1,8 +0,0 @@
-$starting_dir = Get-Location
-Get-ChildItem -Recurse -Filter "Cargo.toml" | ForEach-Object {
- $project_dir = $_.DirectoryName
- Push-Location $project_dir
- cargo clippy --quiet
- Pop-Location
-}
-Set-Location $starting_dir
diff --git a/.run/src/bin/clippy.sh b/.run/src/bin/clippy.sh
deleted file mode 100755
index b393545..0000000
--- a/.run/src/bin/clippy.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/bash
-
-find . -name "Cargo.toml" -type f | while read -r cargo_file; do
- project_dir=$(dirname "$cargo_file")
- (cd "$project_dir" && cargo clippy --quiet)
-done
diff --git a/.run/src/bin/doc-nightly.ps1 b/.run/src/bin/doc-nightly.ps1
deleted file mode 100644
index a81f7e6..0000000
--- a/.run/src/bin/doc-nightly.ps1
+++ /dev/null
@@ -1,6 +0,0 @@
-cargo +nightly rustdoc `
- --manifest-path mingling/Cargo.toml `
- --features docs_rs,core,macros,structural_renderer,repl,comp,picker,clap,extras `
- --open `
- -- `
- --cfg docsrs
diff --git a/.run/src/bin/doc-nightly.sh b/.run/src/bin/doc-nightly.sh
deleted file mode 100755
index ccf3aa0..0000000
--- a/.run/src/bin/doc-nightly.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-
-cargo rustdoc \
- --manifest-path mingling/Cargo.toml \
- --features docs_rs,core,macros,structural_renderer,repl,comp,picker,clap,extras \
- --open \
- -- \
- --cfg docsrs
diff --git a/.run/src/bin/doc.ps1 b/.run/src/bin/doc.ps1
deleted file mode 100644
index 64150b1..0000000
--- a/.run/src/bin/doc.ps1
+++ /dev/null
@@ -1,5 +0,0 @@
-$env:RUSTDOCFLAGS="--html-in-header mingling/arborium-header.html"; cargo doc `
- --manifest-path mingling/Cargo.toml `
- --no-deps `
- --features docs_rs,core,macros,structural_renderer,repl,comp,picker,clap,extras,pathf `
- --open
diff --git a/.run/src/bin/doc.sh b/.run/src/bin/doc.sh
deleted file mode 100755
index 79c74e7..0000000
--- a/.run/src/bin/doc.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash
-
-RUSTDOCFLAGS="--html-in-header mingling/arborium-header.html" cargo doc \
- --manifest-path mingling/Cargo.toml \
- --no-deps \
- --features docs_rs,core,macros,structural_renderer,repl,comp,picker,clap,extras,pathf \
- --open
diff --git a/.run/src/bin/docs-code-box-fix.rs b/.run/src/bin/docs-code-box-fix.rs
deleted file mode 100644
index 21d2cce..0000000
--- a/.run/src/bin/docs-code-box-fix.rs
+++ /dev/null
@@ -1,166 +0,0 @@
-use std::fs;
-use std::path::Path;
-
-use tools::println_cargo_style;
-
-/// Docsify code blocks require that blank lines before and after code blocks are not completely empty,
-/// but must contain at least one space, otherwise code block rendering will have issues.
-///
-/// This tool scans all `.md` files in the docs directory,
-/// and replaces completely empty lines before and after code blocks with blank lines containing a single space.
-const DOCS_DIR: &str = "./docs";
-
-fn main() {
- println_cargo_style!("Fixing: code box empty lines in docs/**/*.md ...");
- let repo_root = find_git_repo().expect("Cannot find git repo root");
- let docs_dir = repo_root.join(DOCS_DIR);
-
- let mut fixed_count = 0;
- let mut file_count = 0;
-
- collect_md_files(&docs_dir, &mut |path| {
- if let Some(name) = path.file_name() {
- let name = name.to_string_lossy();
- if name.to_lowercase() == "_sidebar.md" {
- return;
- }
- }
-
- let content = fs::read_to_string(path).unwrap_or_default();
- if content.is_empty() {
- return;
- }
-
- let new_content = fix_code_box_empty_lines(&content);
- if new_content != content {
- fs::write(path, &new_content).unwrap();
- println_cargo_style!("Fixed: {}", path.display());
- fixed_count += 1;
- }
- file_count += 1;
- });
-
- println_cargo_style!(
- "Done: Scanned {} files, fixed {} files.",
- file_count,
- fixed_count
- );
-}
-
-fn fix_code_box_empty_lines(content: &str) -> String {
- let mut result = String::new();
- let lines: Vec<&str> = content.lines().collect();
- let len = lines.len();
-
- let mut i = 0;
- while i < len {
- let line = lines[i];
-
- // detect beginning of code block: beginning with ```
- if line.trim_start().starts_with("```") {
- // record the beginning line of the code block
- result.push_str(line);
- result.push('\n');
- i += 1;
-
- // find the end of the code block
- let mut found_end = false;
- let code_start = i; // record starting position of code content
- let mut code_end = len; // index of code block end line
-
- while i < len {
- let cline = lines[i];
- if cline.trim_start().starts_with("```") && cline.trim() != "" {
- // this is the closing marker
- code_end = i;
- found_end = true;
- break;
- }
- i += 1;
- }
-
- // check the blank line before the code block
- // if result ends with \n\n, add a space to turn it into \n \n
- ensure_space_before_code_block(&mut result);
-
- // output code content
- for code_line in lines.iter().take(code_end).skip(code_start) {
- if code_line.is_empty() {
- result.push(' ');
- } else {
- result.push_str(code_line);
- }
- result.push('\n');
- }
-
- if found_end {
- result.push_str(lines[code_end]);
- result.push('\n');
- i += 1;
-
- // check the blank line after the code block
- // if the next line is blank, change it to one with a space
- if i < len && lines[i].trim().is_empty() && lines[i].is_empty() {
- // skip the original blank line, write " \n"
- result.push(' ');
- result.push('\n');
- i += 1;
- }
- }
- } else {
- result.push_str(line);
- result.push('\n');
- i += 1;
- }
- }
-
- // remove trailing newlines
- while result.ends_with('\n') {
- result.pop();
- }
- result.push('\n');
-
- result
-}
-
-/// ensure there is a blank line with a space before the code block
-fn ensure_space_before_code_block(result: &mut String) {
- // if result ends with \n\n,
- // turn it into \n \n
- let len = result.len();
- if len >= 2 && result[len - 2..] == *"\n\n" {
- // insert a space before the last \n
- result.insert(len - 1, ' ');
- }
-}
-
-/// recursively collect all .md files in the docs directory
-fn collect_md_files(dir: &Path, callback: &mut dyn FnMut(&Path)) {
- if let Ok(entries) = fs::read_dir(dir) {
- for entry in entries.flatten() {
- let path = entry.path();
- if path.is_dir() {
- collect_md_files(&path, callback);
- } else if path.extension().is_some_and(|ext| ext == "md") {
- callback(&path);
- }
- }
- }
-}
-
-fn find_git_repo() -> Option<std::path::PathBuf> {
- let mut current_dir = std::env::current_dir().ok()?;
-
- loop {
- let git_dir = current_dir.join(".git");
- if git_dir.exists() && git_dir.is_dir() {
- return Some(current_dir);
- }
-
- if !current_dir.pop() {
- break;
- }
- }
-
- None
-}
diff --git a/.run/src/bin/docsify-sidebar-gen.rs b/.run/src/bin/docsify-sidebar-gen.rs
deleted file mode 100644
index 15ae184..0000000
--- a/.run/src/bin/docsify-sidebar-gen.rs
+++ /dev/null
@@ -1,262 +0,0 @@
-use std::collections::BTreeMap;
-use std::fmt::Write;
-use std::path::{Path, PathBuf};
-
-use tools::println_cargo_style;
-
-const SIDEBAR_HEAD: &str = "- [Welcome!](README)\n";
-
-fn main() {
- println_cargo_style!("Refresh: _sidebar.md");
- gen_all_sidebars();
-}
-
-/// Find all README.md under docs/, treat each as a site, and generate _sidebar.md for it.
-fn gen_all_sidebars() {
- let repo_root = find_git_repo().unwrap();
- let docs_root = repo_root.join("docs");
-
- let readme_paths = find_all_readmes(&docs_root);
-
- for readme_path in &readme_paths {
- let site_root = readme_path.parent().unwrap();
-
- let content_dir = find_content_dir(site_root);
-
- if let Some(content_dir) = content_dir {
- let lines = build_sidebar_content(site_root, &content_dir, SIDEBAR_HEAD);
-
- let sidebar_path = site_root.join("_sidebar.md");
- std::fs::write(&sidebar_path, lines).unwrap();
- println_cargo_style!("Generated: {}", sidebar_path.display());
- }
- }
-}
-
-/// Recursively find all README.md files under a directory.
-fn find_all_readmes(dir: &Path) -> Vec<PathBuf> {
- let mut results = Vec::new();
- if let Ok(read_dir) = std::fs::read_dir(dir) {
- let mut entries: Vec<_> = read_dir.flatten().collect();
- entries.sort_by_key(|e| e.path());
- for entry in entries {
- let path = entry.path();
- if path.is_dir() {
- results.extend(find_all_readmes(&path));
- } else if path.file_name().is_some_and(|n| n == "README.md") {
- results.push(path);
- }
- }
- }
- results
-}
-
-/// Find the content directory for a site:
-/// 1. Prefer `pages/` if it exists (backward compatible)
-/// 2. Fall back to the first subdirectory that contains .md files
-fn find_content_dir(site_root: &Path) -> Option<PathBuf> {
- // Try pages/ first
- let pages_dir = site_root.join("pages");
- if pages_dir.exists() && pages_dir.is_dir() {
- return Some(pages_dir);
- }
-
- // Fall back to any subdirectory containing .md files
- if let Ok(read_dir) = std::fs::read_dir(site_root) {
- let mut entries: Vec<_> = read_dir.flatten().collect();
- entries.sort_by_key(|e| e.path());
- for entry in entries {
- let path = entry.path();
- if path.is_dir() && has_markdown_files(&path) {
- return Some(path);
- }
- }
- }
-
- None
-}
-
-/// Check if a directory (recursively) contains any .md files.
-fn has_markdown_files(dir: &Path) -> bool {
- if let Ok(read_dir) = std::fs::read_dir(dir) {
- for entry in read_dir.flatten() {
- let path = entry.path();
- if path.is_dir() {
- if has_markdown_files(&path) {
- return true;
- }
- } else if path.extension().is_some_and(|ext| ext == "md") {
- return true;
- }
- }
- }
- false
-}
-
-/// Build sidebar content: scan .md files in `pages_dir` and return a formatted sidebar string
-fn build_sidebar_content(base_dir: &Path, pages_dir: &Path, sidebar_head: &str) -> String {
- let mut lines = String::from(sidebar_head);
-
- // Collect and sort entries at root level first
- let mut root_files: Vec<SidebarEntry> = Vec::new();
- // Subdirectory name -> its files
- let mut sub_dirs: BTreeMap<String, Vec<SidebarEntry>> = BTreeMap::new();
-
- if let Ok(read_dir) = std::fs::read_dir(pages_dir) {
- for entry in read_dir.flatten() {
- let path = entry.path();
- if path.is_dir() {
- let dir_name = entry.file_name().to_string_lossy().to_string();
- let entries = collect_markdown_files(&path, base_dir);
- if !entries.is_empty() {
- // Check for .name file to override directory display name
- let display_name = get_directory_display_name(&path, &dir_name);
- sub_dirs.insert(display_name, entries);
- }
- } else if path.extension().is_some_and(|ext| ext == "md") {
- let title = extract_title(&path);
- let relative = path
- .strip_prefix(base_dir)
- .unwrap()
- .to_string_lossy()
- .replace('\\', "/");
- let link = relative
- .strip_suffix(".md")
- .unwrap_or(&relative)
- .to_string();
- root_files.push(SidebarEntry { title, link });
- }
- }
- }
-
- // Sort root files — natural order (1, 2, ..., 10, 11)
- root_files.sort_by(|a, b| natural_cmp(&a.link, &b.link));
-
- // Append root-level files
- for f in &root_files {
- let _ = writeln!(lines, "* [{}]({})", f.title, f.link);
- }
-
- // Append subdirectory groups
- for (dir_name, entries) in &sub_dirs {
- let mut sorted_entries = entries.clone();
- sorted_entries.sort_by(|a, b| natural_cmp(&a.link, &b.link));
-
- // Directory header with 2-space indent
- let _ = writeln!(lines, "* {dir_name}");
- for f in &sorted_entries {
- let _ = writeln!(lines, " * [{}]({})", f.title, f.link);
- }
- }
-
- lines
-}
-
-#[derive(Clone)]
-struct SidebarEntry {
- title: String,
- link: String,
-}
-
-/// Collect all `.md` files directly under `dir`
-fn collect_markdown_files(dir: &Path, base_dir: &Path) -> Vec<SidebarEntry> {
- let mut entries = Vec::new();
-
- if let Ok(read_dir) = std::fs::read_dir(dir) {
- for entry in read_dir.flatten() {
- let path = entry.path();
- if path.extension().is_some_and(|ext| ext == "md") {
- let title = extract_title(&path);
- let relative = path
- .strip_prefix(base_dir)
- .unwrap()
- .to_string_lossy()
- .replace('\\', "/");
- let link = relative
- .strip_suffix(".md")
- .unwrap_or(&relative)
- .to_string();
- entries.push(SidebarEntry { title, link });
- }
- }
- }
-
- entries
-}
-
-/// Extract title from the first line `<h1 align="center">TITLE</h1>`.
-/// Fallback to filename stem.
-fn extract_title(path: &Path) -> String {
- let content = std::fs::read_to_string(path).unwrap_or_default();
- if let Some(first_line) = content.lines().next() {
- let trimmed = first_line.trim();
- // Find `>TITLE<` between `<h1 align="center">` and `</h1>`
- if let Some(start) = trimmed.find('>') {
- let after_start = &trimmed[start + 1..];
- if let Some(end) = after_start.find('<') {
- return after_start[..end].to_string();
- }
- }
- }
- // Fallback: use file stem
- path.file_stem().map_or_else(
- || "Untitled".to_string(),
- |s| s.to_string_lossy().to_string(),
- )
-}
-
-/// Read `.name` file inside a directory to get its display name for the sidebar.
-/// Falls back to the directory name itself if no `.name` file exists.
-fn get_directory_display_name(dir_path: &std::path::Path, fallback: &str) -> String {
- let name_file = dir_path.join(".name");
- if name_file.exists() && name_file.is_file() {
- std::fs::read_to_string(&name_file)
- .ok()
- .map(|s| s.trim().to_string())
- .filter(|s| !s.is_empty())
- .unwrap_or_else(|| fallback.to_string())
- } else {
- fallback.to_string()
- }
-}
-
-fn find_git_repo() -> Option<std::path::PathBuf> {
- let mut current_dir = std::env::current_dir().ok()?;
-
- loop {
- let git_dir = current_dir.join(".git");
- if git_dir.exists() && git_dir.is_dir() {
- return Some(current_dir);
- }
-
- if !current_dir.pop() {
- break;
- }
- }
-
- None
-}
-
-/// Natural (numeric-aware) comparison for sidebar links.
-///
-/// Files prefixed with a number (e.g. `1-getting-started`) are sorted by that number;
-/// files without a numeric prefix fall back to lexicographic order (after numbers).
-fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
- let num_a = extract_leading_number(a);
- let num_b = extract_leading_number(b);
- num_a.cmp(&num_b).then_with(|| a.cmp(b))
-}
-
-/// Extract the leading numeric prefix from a sidebar link path.
-///
-/// Looks at the filename stem (after the last `/`) for a number before the first `-`.
-/// Returns `usize::MAX` for entries without a numeric prefix.
-fn extract_leading_number(link: &str) -> usize {
- if let Some(file_stem) = link.rsplit('/').next()
- && let Some(num_end) = file_stem.find('-')
- && let Ok(num) = file_stem[..num_end].parse::<usize>()
- {
- return num;
- }
- usize::MAX
-}
diff --git a/.run/src/bin/refresh-docs.rs b/.run/src/bin/refresh-docs.rs
deleted file mode 100644
index 82ef906..0000000
--- a/.run/src/bin/refresh-docs.rs
+++ /dev/null
@@ -1,178 +0,0 @@
-use std::path::Path;
-
-use just_fmt::snake_case;
-use just_template::{Template, tmpl};
-use tools::println_cargo_style;
-
-const EXAMPLE_ROOT: &str = "./examples/";
-const OUTPUT_PATH: &str = "./mingling/src/example_docs.rs";
-
-const TEMPLATE_CONTENT: &str = include_str!("../../../mingling/src/example_docs.rs.tmpl");
-
-fn main() {
- gen_example_doc_module();
-}
-
-fn gen_example_doc_module() {
- let mut template = Template::from(TEMPLATE_CONTENT);
- let repo_root = find_git_repo().unwrap();
- let example_root = repo_root.join(EXAMPLE_ROOT);
- let mut examples = Vec::new();
- if let Ok(entries) = std::fs::read_dir(&example_root) {
- for entry in entries.flatten() {
- if let Ok(file_type) = entry.file_type()
- && file_type.is_dir()
- {
- let example_name = entry.file_name().to_string_lossy().to_string();
- // Ignore directories that don't start with "example-"
- if !example_name.starts_with("example-") {
- continue;
- }
- let example_content = ExampleContent::read(&example_name);
- examples.push(example_content);
- }
- }
- }
-
- examples.sort();
-
- for example in examples {
- tmpl!(template += {
- examples {
- (
- example_header = example.header,
- example_import = example.cargo_toml,
- example_code = example.code,
- example_name = snake_case!(&example.name)
- )
- }
- });
- println_cargo_style!("Refresh: {}", example.name);
- }
-
- let template_str = template.to_string();
- let template_str = template_str
- .lines()
- .map(str::trim_end)
- .collect::<Vec<_>>()
- .join("\n")
- + "\n";
- std::fs::write(repo_root.join(OUTPUT_PATH), template_str).unwrap();
-}
-
-struct ExampleContent {
- name: String,
- header: String,
- code: String,
- cargo_toml: String,
-}
-
-impl PartialOrd for ExampleContent {
- fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
- Some(self.cmp(other))
- }
-}
-
-impl Ord for ExampleContent {
- fn cmp(&self, other: &Self) -> std::cmp::Ordering {
- self.name.cmp(&other.name)
- }
-}
-
-impl PartialEq for ExampleContent {
- fn eq(&self, other: &Self) -> bool {
- self.name == other.name
- }
-}
-
-impl Eq for ExampleContent {}
-
-impl ExampleContent {
- pub fn read(name: &str) -> Self {
- let repo = find_git_repo().unwrap();
- let cargo_toml = Self::read_cargo_toml(&repo, name);
- let (header, code) = Self::read_header_and_code(&repo, name);
-
- let cargo_toml = cargo_toml
- .lines()
- .map(|line| format!("/// {line}"))
- .collect::<Vec<_>>()
- .join("\n");
-
- let header = header
- .lines()
- .map(|line| format!("/// {line}"))
- .collect::<Vec<_>>()
- .join("\n");
-
- let code = code
- .lines()
- .map(|line| format!("/// {line}"))
- .collect::<Vec<_>>()
- .join("\n");
-
- ExampleContent {
- name: name.to_string(),
- header,
- code,
- cargo_toml,
- }
- }
-
- fn read_header_and_code(repo: &Path, name: &str) -> (String, String) {
- let file_path = repo
- .join(EXAMPLE_ROOT)
- .join(name)
- .join("src")
- .join("main.rs");
- let content = std::fs::read_to_string(&file_path).unwrap_or_default();
- let mut lines = content.lines();
- let mut header = String::new();
- let mut code = String::new();
-
- // Collect header lines (starting with //!)
- for line in lines.by_ref() {
- if line.trim_start().starts_with("//!") {
- let trimmed = line.trim_start_matches("//!");
- header.push_str(trimmed);
- header.push('\n');
- } else {
- // First non-header line found, start collecting code
- code.push_str(line);
- code.push('\n');
- break;
- }
- }
-
- // Collect remaining code lines
- for line in lines {
- code.push_str(line);
- code.push('\n');
- }
-
- (header.trim().to_string(), code.trim().to_string())
- }
-
- fn read_cargo_toml(repo: &Path, name: &str) -> String {
- let file_path = repo.join(EXAMPLE_ROOT).join(name).join("Cargo.toml");
-
- std::fs::read_to_string(&file_path).unwrap_or_default()
- }
-}
-
-fn find_git_repo() -> Option<std::path::PathBuf> {
- let mut current_dir = std::env::current_dir().ok()?;
-
- loop {
- let git_dir = current_dir.join(".git");
- if git_dir.exists() && git_dir.is_dir() {
- return Some(current_dir);
- }
-
- if !current_dir.pop() {
- break;
- }
- }
-
- None
-}
diff --git a/.run/src/bin/refresh-feature-mod.rs b/.run/src/bin/refresh-feature-mod.rs
deleted file mode 100644
index 4cd6532..0000000
--- a/.run/src/bin/refresh-feature-mod.rs
+++ /dev/null
@@ -1,97 +0,0 @@
-use std::collections::BTreeSet;
-use std::path::Path;
-
-use just_fmt::snake_case;
-use just_template::{Template, tmpl};
-use tools::println_cargo_style;
-
-const CARGO_TOML_PATH: &str = "./mingling/Cargo.toml";
-const OUTPUT_PATH: &str = "./mingling/src/features.rs";
-
-const TEMPLATE_CONTENT: &str = include_str!("../../../mingling/src/features.rs.tmpl");
-
-fn main() {
- gen_feature_module();
-}
-
-fn gen_feature_module() {
- let repo_root = find_git_repo().unwrap();
-
- let cargo_toml_path = repo_root.join(CARGO_TOML_PATH);
- let output_path = repo_root.join(OUTPUT_PATH);
-
- let features = parse_features(&cargo_toml_path);
-
- let mut template = Template::from(TEMPLATE_CONTENT);
-
- for feat_name in &features {
- let feat_const_name = snake_case!(feat_name).to_uppercase();
-
- tmpl!(template += {
- features {
- (
- feat_name = feat_name,
- feat_const_name = feat_const_name
- )
- }
- });
- println_cargo_style!("Refresh: feature `{}`", feat_name);
- }
-
- let template_str = template.to_string();
- let template_str = template_str
- .lines()
- .map(str::trim_end)
- .collect::<Vec<_>>()
- .join("\n")
- + "\n";
- std::fs::write(&output_path, template_str).unwrap();
-
- println_cargo_style!("Written: features module to {}", OUTPUT_PATH);
-}
-
-/// Parse all feature names from the `[features]` section of a Cargo.toml.
-fn parse_features(cargo_toml_path: &Path) -> Vec<String> {
- let content = std::fs::read_to_string(cargo_toml_path)
- .unwrap_or_else(|e| panic!("Failed to read {}: {}", cargo_toml_path.display(), e));
-
- let cargo_toml: toml::Value = content
- .parse()
- .unwrap_or_else(|e| panic!("Failed to parse {}: {}", cargo_toml_path.display(), e));
-
- let features_table = cargo_toml
- .get("features")
- .and_then(|v| v.as_table())
- .unwrap_or_else(|| {
- panic!(
- "No [features] section found in {}",
- cargo_toml_path.display()
- )
- });
-
- let mut feature_names: BTreeSet<String> = BTreeSet::new();
- for key in features_table.keys() {
- feature_names.insert(key.clone());
- }
-
- let mut result: Vec<String> = feature_names.into_iter().collect();
- result.sort();
- result
-}
-
-fn find_git_repo() -> Option<std::path::PathBuf> {
- let mut current_dir = std::env::current_dir().ok()?;
-
- loop {
- let git_dir = current_dir.join(".git");
- if git_dir.exists() && git_dir.is_dir() {
- return Some(current_dir);
- }
-
- if !current_dir.pop() {
- break;
- }
- }
-
- None
-}
diff --git a/.run/src/bin/sync-examples.rs b/.run/src/bin/sync-examples.rs
deleted file mode 100644
index 0923b33..0000000
--- a/.run/src/bin/sync-examples.rs
+++ /dev/null
@@ -1,131 +0,0 @@
-use std::fs;
-use std::path::Path;
-
-use serde::{Deserialize, Serialize};
-use tools::println_cargo_style;
-
-#[derive(Serialize)]
-struct ExampleMeta {
- id: String,
- name: String,
- icon: String,
- category: String,
- desc: String,
- tags: Vec<String>,
- files: Vec<String>,
-}
-
-#[derive(Deserialize)]
-struct PageToml {
- example: PageTomlExample,
-}
-
-#[derive(Deserialize)]
-struct PageTomlExample {
- id: String,
- #[serde(default)]
- name: String,
- #[serde(default = "default_icon")]
- icon: String,
- #[serde(default)]
- category: String,
- #[serde(default)]
- desc: String,
- #[serde(default)]
- tags: Vec<String>,
- #[serde(default = "default_files")]
- files: Vec<String>,
-}
-
-fn default_icon() -> String {
- "📦".to_string()
-}
-
-fn default_files() -> Vec<String> {
- vec!["Cargo.toml".to_string(), "src/main.rs".to_string()]
-}
-
-fn main() {
- #[cfg(windows)]
- let _ = colored::control::set_virtual_terminal(true);
-
- let examples_dir = Path::new("examples");
- let output_dir = Path::new("docs/example-pages");
- fs::create_dir_all(output_dir).expect("failed to create docs/example-pages");
-
- let mut examples: Vec<ExampleMeta> = Vec::new();
-
- let entries = fs::read_dir(examples_dir).expect("failed to read examples/");
- for entry in entries.flatten() {
- let path = entry.path();
- if !path.is_dir() {
- continue;
- }
-
- let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
-
- let id = dir_name.to_string();
- let page_toml_path = path.join("page.toml");
-
- let meta = if page_toml_path.exists() {
- match fs::read_to_string(&page_toml_path)
- .map_err(|e| e.to_string())
- .and_then(|content| toml::from_str::<PageToml>(&content).map_err(|e| e.to_string()))
- {
- Ok(page) => {
- let ex = page.example;
- ExampleMeta {
- id: if ex.id.is_empty() { id.clone() } else { ex.id },
- name: if ex.name.is_empty() {
- id.clone()
- } else {
- ex.name
- },
- icon: ex.icon,
- category: ex.category,
- desc: ex.desc,
- tags: ex.tags,
- files: if ex.files.is_empty() {
- default_files()
- } else {
- ex.files
- },
- }
- }
- Err(e) => {
- eprintln!(
- "Warning: failed to parse {}: {}",
- page_toml_path.display(),
- e
- );
- continue;
- }
- }
- } else {
- continue;
- };
-
- examples.push(meta);
- }
-
- // Sort: basic first, then alphabetical
- examples.sort_by(|a, b| {
- if a.id == "example-basic" {
- return std::cmp::Ordering::Less;
- }
- if b.id == "example-basic" {
- return std::cmp::Ordering::Greater;
- }
- a.id.cmp(&b.id)
- });
-
- let json = serde_json::to_string_pretty(&examples).expect("failed to serialize");
- let output_path = output_dir.join("examples.json");
- fs::write(&output_path, &json).expect("failed to write examples.json");
-
- println_cargo_style!(
- "Sync: {} examples -> {}",
- examples.len(),
- output_path.display()
- );
-}
diff --git a/.run/src/bin/test-all-markdown-code.rs b/.run/src/bin/test-all-markdown-code.rs
deleted file mode 100644
index 35c8bbe..0000000
--- a/.run/src/bin/test-all-markdown-code.rs
+++ /dev/null
@@ -1,261 +0,0 @@
-use std::collections::HashMap;
-use std::env;
-use std::path::{Path, PathBuf};
-
-use colored::Colorize;
-use indicatif::ProgressBar;
-use tools::verify::{
- build_block, compute_block_hash, generate_build_rs, generate_cargo_toml, generate_main_rs,
- is_block_testable, parse_code_blocks, write_summary_report,
-};
-use tools::{eprintln_cargo_style, println_cargo_style};
-
-/// Config from verified-docs.toml
-#[derive(serde::Deserialize)]
-struct Config {
- verified: HashMap<String, String>,
-}
-
-#[tokio::main]
-async fn main() {
- #[cfg(windows)]
- let _ = colored::control::set_virtual_terminal(true);
-
- let config_path = PathBuf::from(".config/verified-docs.toml");
- if !config_path.exists() {
- eprintln_cargo_style!("verified-docs.toml not found in current directory");
- std::process::exit(1);
- }
-
- let config: Config = {
- let content = std::fs::read_to_string(&config_path).unwrap_or_else(|_e| {
- eprintln_cargo_style!("Failed to read verified-docs.toml");
- std::process::exit(1);
- });
- toml::from_str(&content).unwrap_or_else(|_e| {
- eprintln_cargo_style!("Failed to parse verified-docs.toml");
- std::process::exit(1);
- })
- };
-
- // Parse optional path argument from env args
- let single_file: Option<PathBuf> = {
- let args: Vec<String> = env::args().collect();
- if args.len() > 1 {
- let p = PathBuf::from(&args[1]);
- if p.exists() {
- Some(p)
- } else {
- eprintln_cargo_style!("error: specified file '{}' does not exist", args[1]);
- std::process::exit(1);
- }
- } else {
- None
- }
- };
-
- // Collect all markdown files from config
- // Keys are used as labels; values are either single file paths or directory globs
- let mut files: Vec<(String, PathBuf)> = Vec::new();
-
- for (key, value) in &config.verified {
- let candidate = PathBuf::from(value);
- if candidate.is_dir() {
- // Directory — walk it for all .md files, using the key as label
- collect_md_files(&candidate, &mut files, key);
- } else if candidate.exists() && candidate.is_file() {
- // Single file
- files.push((key.to_string(), candidate));
- } else if candidate.extension().is_none() {
- // No extension — treat as a glob like "docs/pages/**", walk the base dir instead
- let base = PathBuf::from(value.trim_end_matches("/**").trim_end_matches('*'));
- if base.is_dir() {
- collect_md_files(&base, &mut files, key);
- }
- }
- }
-
- // Sort for deterministic ordering
- files.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
-
- // If a single file was specified, filter the list to only that file
- if let Some(ref target) = single_file {
- let target_canon = std::fs::canonicalize(target).unwrap_or_else(|_| target.clone());
- files.retain(|(_, path)| {
- std::fs::canonicalize(path)
- .map(|p| p == target_canon)
- .unwrap_or(false)
- });
- if files.is_empty() {
- eprintln_cargo_style!(
- "error: specified file '{}' is not among the configured documentation files",
- target.display()
- );
- std::process::exit(1);
- }
- }
-
- if files.is_empty() {
- eprintln_cargo_style!("No markdown files found to verify");
- std::process::exit(1);
- }
-
- // Parse all code blocks into a flat list with global indices
- let mut flat_blocks: Vec<(usize, tools::verify::CodeBlock)> = Vec::new();
-
- for (label, path) in &files {
- let content = std::fs::read_to_string(path).unwrap_or_else(|e| {
- eprintln_cargo_style!("Failed to read {}: {}", path.display(), e);
- String::new()
- });
- let source_file = format!("{label}/{}", path.file_name().unwrap().to_string_lossy());
- let blocks = parse_code_blocks(&content, &source_file);
- let testable: Vec<_> = blocks.into_iter().filter(is_block_testable).collect();
- for block in testable {
- let idx = flat_blocks.len() + 1; // 1-based global index
- flat_blocks.push((idx, block));
- }
- }
-
- let total_testable = flat_blocks.len();
-
- if total_testable == 0 {
- println_cargo_style!("No testable code blocks found");
- return;
- }
-
- // Create a shared progress bar
- let bar = ProgressBar::new(total_testable as u64);
- bar.set_style(
- indicatif::ProgressStyle::default_bar()
- .template(&format!(
- "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}",
- " Testing".bold().bright_cyan()
- ))
- .unwrap()
- .progress_chars("=> "),
- );
- bar.set_message("blocks");
-
- // Group blocks by dependency hash
- let mut groups: HashMap<String, Vec<(usize, tools::verify::CodeBlock)>> = HashMap::new();
- for (idx, block) in flat_blocks {
- let hash = compute_block_hash(&block);
- groups.entry(hash).or_default().push((idx, block));
- }
-
- let temp_base = PathBuf::from(".temp/doc-test");
-
- // Sort groups by hash for deterministic output order
- let mut group_vec: Vec<(String, Vec<(usize, tools::verify::CodeBlock)>)> =
- groups.into_iter().collect();
- group_vec.sort_by(|a, b| a.0.cmp(&b.0));
-
- // Spawn a blocking task per group — groups run in parallel, blocks within a group are serial
- let mut handles = Vec::new();
- for (hash, blocks) in group_vec {
- let temp_base = temp_base.clone();
- let bar = bar.clone(); // clone shares the same underlying progress
- let handle = tokio::task::spawn_blocking(move || {
- let crate_dir = temp_base.join(&hash);
- let src_dir = crate_dir.join("src");
- let manifest_path = crate_dir.join("Cargo.toml");
-
- // Generate a single Cargo.toml for the whole group (all blocks share same deps)
- let first_block = &blocks[0].1;
- let cargo_toml = generate_cargo_toml(first_block, "test-doc", &manifest_path);
-
- let mut group_results: Vec<(String, usize, bool, String)> = Vec::new();
- for (block_idx, block) in &blocks {
- let block_label =
- format!("Block {block_idx} ({}:{})", block.source_file, block.line);
-
- bar.set_message(block_label.clone());
-
- let main_rs = if block.is_build_time {
- // For build-time blocks, write a stub main.rs and generate build.rs
- generate_build_rs(block)
- } else {
- generate_main_rs(block)
- };
- let (ok, err) = build_block(
- &src_dir,
- &manifest_path,
- &cargo_toml,
- &main_rs,
- block.is_build_time,
- );
- if ok {
- bar.inc(1);
- } else {
- bar.inc(1);
- bar.println(format!(" {} {block_label}", "failed".bold().bright_red()));
- bar.println(format!(" {block_label} FAILED:\n{err}"));
- }
- group_results.push((block.source_file.clone(), block.line, ok, err));
- }
- group_results
- });
- handles.push(handle);
- }
-
- // Collect results from all groups
- let mut results: Vec<(String, usize, bool, String)> = Vec::new();
- let mut passed = 0usize;
- let mut failed = 0usize;
-
- for handle in handles {
- match handle.await {
- Ok(group_results) => {
- for (file, line, ok, err) in group_results {
- if ok {
- passed += 1;
- } else {
- failed += 1;
- }
- results.push((file, line, ok, err));
- }
- }
- Err(e) => {
- eprintln_cargo_style!("Task panicked: {}", e);
- std::process::exit(1);
- }
- }
- }
-
- bar.finish_and_clear();
-
- let result_msg = format!("Result: {passed}/{total_testable} blocks passed");
- println_cargo_style!(result_msg);
-
- write_summary_report(
- Path::new(".temp/DOCS-TEST-RESULT.md"),
- "Documentation Code Block Test Report",
- &results,
- total_testable,
- passed,
- failed,
- );
-
- if failed > 0 {
- let fail_msg = format!("{failed} block(s) failed to build");
- eprintln_cargo_style!(fail_msg);
- std::process::exit(1);
- }
-
- println_cargo_style!("Done: All verified code blocks build successfully!");
-}
-
-/// Recursively collect all `.md` files under a directory
-fn collect_md_files(dir: &Path, files: &mut Vec<(String, PathBuf)>, lang: &str) {
- if let Ok(entries) = std::fs::read_dir(dir) {
- for entry in entries.flatten() {
- let path = entry.path();
- if path.is_dir() {
- collect_md_files(&path, files, lang);
- } else if path.extension().is_some_and(|ext| ext == "md") {
- files.push((lang.to_string(), path));
- }
- }
- }
-}
diff --git a/.run/src/bin/test-all.ps1 b/.run/src/bin/test-all.ps1
deleted file mode 100644
index 231698a..0000000
--- a/.run/src/bin/test-all.ps1
+++ /dev/null
@@ -1,8 +0,0 @@
-$starting_dir = Get-Location
-Get-ChildItem -Recurse -Filter "Cargo.toml" | ForEach-Object {
- $project_dir = $_.DirectoryName
- Push-Location $project_dir
- cargo test
- Pop-Location
-}
-Set-Location $starting_dir
diff --git a/.run/src/bin/test-all.sh b/.run/src/bin/test-all.sh
deleted file mode 100644
index b387463..0000000
--- a/.run/src/bin/test-all.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/bash
-
-find . -name "Cargo.toml" -type f | while read -r cargo_file; do
- project_dir=$(dirname "$cargo_file")
- (cd "$project_dir" && cargo test)
-done
diff --git a/.run/src/bin/test-examples.rs b/.run/src/bin/test-examples.rs
deleted file mode 100644
index 617a745..0000000
--- a/.run/src/bin/test-examples.rs
+++ /dev/null
@@ -1,197 +0,0 @@
-use std::path::Path;
-
-use colored::Colorize;
-use indicatif::ProgressBar;
-use serde::Deserialize;
-use tools::{eprintln_cargo_style, println_cargo_style, run_parallel};
-
-/// An example's `test.toml` (`[[runs]]` entries).
-#[derive(Deserialize)]
-struct TestConfig {
- runs: Vec<TestCase>,
-}
-
-/// A single `[[runs]]` entry of an example's `test.toml`.
-#[derive(Deserialize)]
-struct TestCase {
- input: Vec<String>,
- expect: Expect,
-}
-
-#[derive(Deserialize)]
-struct Expect {
- #[serde(rename = "exit-code")]
- exit_code: i32,
- result: String,
-}
-
-fn main() {
- #[cfg(windows)]
- let _ = colored::control::set_virtual_terminal(true);
-
- let configs = load_all_test_configs();
-
- // Phase 1: build all examples in parallel.
- if let Err(code) = build_all_examples(&configs) {
- // `run_parallel` already printed every failed build above.
- std::process::exit(code);
- }
-
- // Phase 2: run the tests serially against the pre-built binaries.
- let total: usize = configs.iter().map(|(_, cases)| cases.len()).sum();
- let bar = ProgressBar::new(total as u64);
- bar.set_style(
- indicatif::ProgressStyle::default_bar()
- .template(&format!(
- "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}",
- " Testing".bold().bright_cyan()
- ))
- .unwrap()
- .progress_chars("=> "),
- );
- bar.set_message("examples");
-
- let passed = run_all_tests(&configs, &bar);
-
- bar.finish_and_clear();
-
- println_cargo_style!("Result: {}/{} tests passed", passed, total);
-
- if passed != total {
- eprintln_cargo_style!("{} test(s) failed", total - passed);
- std::process::exit(1);
- }
-}
-
-/// Load `examples/<name>/test.toml` for every example that has one, in
-/// alphabetical order of the example directory name.
-fn load_all_test_configs() -> Vec<(String, Vec<TestCase>)> {
- let examples_dir = Path::new("examples");
- let mut configs = Vec::new();
-
- let entries = std::fs::read_dir(examples_dir).unwrap_or_else(|e| {
- eprintln_cargo_style!("Failed to read examples dir: {}", e);
- std::process::exit(1);
- });
-
- for entry in entries.flatten() {
- let path = entry.path();
- if !path.is_dir() {
- continue;
- }
- let test_toml = path.join("test.toml");
- if !test_toml.is_file() {
- continue;
- }
- let name = path
- .file_name()
- .and_then(|n| n.to_str())
- .unwrap_or_default()
- .to_string();
- let content = std::fs::read_to_string(&test_toml).unwrap_or_else(|e| {
- eprintln_cargo_style!("Failed to read {}: {}", test_toml.display(), e);
- std::process::exit(1);
- });
- let config: TestConfig = toml::from_str(&content).unwrap_or_else(|e| {
- eprintln_cargo_style!("Failed to parse {}: {}", test_toml.display(), e);
- std::process::exit(1);
- });
- configs.push((name, config.runs));
- }
-
- configs.sort_by(|a, b| a.0.cmp(&b.0));
- configs
-}
-
-/// Phase 1: build every example that has a `test.toml` in parallel.
-///
-/// Build tasks are spawned in parallel (like `ci.rs`'s `build_all`); on any
-/// build failure the whole run aborts with the first failure's exit code.
-fn build_all_examples(configs: &[(String, Vec<TestCase>)]) -> Result<(), i32> {
- let tasks: Vec<(String, String, String)> = configs
- .iter()
- .map(|(name, _)| {
- (
- format!("Build: {name}"),
- name.clone(),
- format!("cargo build --manifest-path examples/{name}/Cargo.toml --color always"),
- )
- })
- .collect();
- run_parallel("Building", tasks)
-}
-
-/// Phase 2: run all example test groups serially, return number passed
-fn run_all_tests(configs: &[(String, Vec<TestCase>)], bar: &ProgressBar) -> usize {
- let mut passed = 0;
-
- for (example_name, test_cases) in configs {
- bar.set_message(example_name.clone());
-
- for test_case in test_cases {
- if run_single_test(example_name, test_case, bar) {
- passed += 1;
- }
- bar.inc(1);
- }
- }
-
- passed
-}
-
-/// Run a single test case, return true on pass
-fn run_single_test(example_name: &str, test_case: &TestCase, bar: &ProgressBar) -> bool {
- let binary_path = format!(".temp/target/debug/{}", get_binary_name(example_name));
- let command = test_case.input.join(" ");
-
- let output = match std::process::Command::new(&binary_path)
- .args(&test_case.input)
- .output()
- {
- Ok(o) => o,
- Err(e) => {
- bar.println(format!("'{command}' - failed to run: {e}"));
- return false;
- }
- };
-
- let actual_exit_code = output.status.code().unwrap_or(-1);
- let actual_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
- let actual_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
-
- let exit_ok = actual_exit_code == test_case.expect.exit_code;
- let result_ok = actual_stdout == test_case.expect.result
- || actual_stdout.contains(&test_case.expect.result);
-
- if exit_ok && result_ok {
- true
- } else {
- bar.println(format!("failed: '{command}'"));
- if !exit_ok {
- bar.println(format!(
- " Expected exit code: {}, actual: {}",
- test_case.expect.exit_code, actual_exit_code
- ));
- }
- if !result_ok {
- bar.println(format!(" Expected output: {:?}", test_case.expect.result));
- bar.println(format!(" Actual stdout: {:?}", actual_stdout));
- if !actual_stderr.is_empty() {
- bar.println(format!(" Actual stderr: {:?}", actual_stderr));
- }
- }
- false
- }
-}
-
-/// Resolve binary filename for the given example
-///
-/// The binary name matches the package name. On Windows, the `.exe` suffix is required.
-fn get_binary_name(example_name: &str) -> String {
- let base = example_name;
- if cfg!(target_os = "windows") {
- format!("{base}.exe")
- } else {
- base.to_string()
- }
-}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 71ced58..6a905a8 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,6 +4,7 @@
"rust-analyzer.files.exclude": ["**/target/**", "**/.temp/**"],
"rust-analyzer.linkedProjects": [
".run/Cargo.toml",
+ "mingling_ci/Cargo.toml",
"mingling_pathf/test/Cargo.toml",
"arg_picker/Cargo.toml",
"arg_picker/test/Cargo.toml",
diff --git a/.zed/settings.json b/.zed/settings.json
index 7378109..f4a55eb 100644
--- a/.zed/settings.json
+++ b/.zed/settings.json
@@ -9,6 +9,7 @@
},
"linkedProjects": [
".run/Cargo.toml",
+ "mingling_ci/Cargo.toml",
"mingling_pathf/test/Cargo.toml",
"arg_picker/Cargo.toml",
"arg_picker/test/Cargo.toml",
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 779bc3e..5f86c3d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -19,8 +19,10 @@ Before contributing, we recommend reading [README](README.md) to get an overview
| **Documents** | `docs/` | All documents |
| **Dev Documents** | `docs/dev/` | Internal documents |
| **Resources** | `docs/res/` | All resources |
-| **Development tools** | `.run/src/bin` | Contains scripts and Rust tools |
-| **CI** | `.run/src/bin/ci.rs` | Can be invoked directly via `cargo ci` |
+| **CI system** | `mingling_ci/` | CI crate built on the Mingling framework, invoked via `cargo ci` |
+| **CI orchestration** | `.run/src/bin/ci.py` | Full pipeline script (lock → checks → refresh → unlock) |
+| **Development tools** | `.run/src/bin` | Contains scripts and Rust tools (`deploy-api-docs`, `install-mling`, …) |
+| **CI configs** | `.config/` | `ci-ignored-dirs.txt`, `verified-docs.toml`, `docs-lang.txt` |
| **Temporary files** | `.temp/` | Ignored by `.gitignore` |
## 2. How to Contribute
@@ -30,7 +32,13 @@ Before contributing, we recommend reading [README](README.md) to get an overview
If you'd like to contribute to `mingling`, `mingling_core`, `mingling_macros`, or `mingling_pathf`, first share your idea on the [Github Issue](https://github.com/mingling-rs/mingling/issues) page to confirm before starting work.
- **Before making changes**, make sure your branch stays **as close as possible** to the upstream `main` branch.
-- **After finishing**, run `cargo ci` locally (see [ABOUT CI](https://mingling-rs.github.io/mingling/docs/dev/#/pages/abouts/ci) for how it works). If `cargo ci` passes locally, your changes are most likely correct.
+- **After finishing**, run the full CI pipeline locally (see [ABOUT CI](https://mingling-rs.github.io/mingling/docs/dev/#/pages/abouts/ci) for how it works):
+
+```bash
+./run.sh ci
+```
+
+If it passes locally, your changes are most likely correct. (`./run.sh ci` runs `python .run/src/bin/ci.py`, which locks the workspace, runs every check and refresh step, then unlocks — the final unlock fails if the run left the tree dirty.)
### Example Code Contribution
@@ -51,7 +59,7 @@ tags = ["tag1", "tag2"] # Tags (optional)
files = ["Cargo.toml", "src/main.rs"]
```
-Optionally, each example may contain a `test.toml` file declaring expected output tests, which are executed by CI (`./run.sh test-examples`):
+Optionally, each example may contain a `test.toml` file declaring expected output tests, which are executed by CI (`cargo ci example-check`):
```toml
[[runs]]
@@ -66,23 +74,20 @@ expect.result = "Hello, Alice!"
If you change expected behavior, update the assertions in the example's `test.toml`.
-After editing examples, run these scripts to keep things in sync:
+After editing examples, run these commands to keep things in sync:
```bash
# Ensure code compiles
-./run.sh build-all
+cargo ci build-check
# Ensure code style
-./run.sh clippy
-
-# Sync page.toml info to docs/example-pages/examples.json
-./run.sh sync-examples
+cargo ci clippy-check
# Check all examples behave as expected
-./run.sh test-examples
+cargo ci example-check
-# Sync examples content into mingling/src/example_docs.rs
-./run.sh refresh-docs
+# Sync examples content into mingling/src/example_docs.rs and examples.json
+cargo ci example-refresh
# (Optional) Preview the Example Viewer in a browser
# Requires: Python
@@ -99,14 +104,14 @@ To contribute docs, edit files under `docs/`. For other language translations, r
- **Before submitting**, always run:
```bash
-# Fix code block issues in docsify
-./run.sh docs-code-box-fix
-
-# Generate sidebar
-./run.sh docsify-sidebar-gen
+# Fix code box issues and regenerate sidebars
+cargo ci docsify-refresh
# Verify all Markdown code blocks compile
-./run.sh test-all-markdown-code
+cargo ci markdown-check-all
+
+# Verify translated docs mirror the reference structure
+cargo ci markdown-compare-all
```
### Web Frontend Contribution
@@ -121,7 +126,7 @@ No strict requirements here — just modify the relevant `*.html` files. Preview
### Dev Tool Contribution
-`Mingling CI` code is under strict review. If you want to improve `mingling`'s CI pipeline or other dev tools (under `.run/`), **please** first file an [Issue](https://github.com/mingling-rs/mingling/issues) and contact [Weicao-CatilGrass](https://github.com/Weicao-CatilGrass)!
+`Mingling CI` code is under strict review. If you want to improve `mingling`'s CI pipeline (`mingling_ci/`) or other dev tools (under `.run/`), **please** first file an [Issue](https://github.com/mingling-rs/mingling/issues) and contact [Weicao-CatilGrass](https://github.com/Weicao-CatilGrass)!
## 3. Submission Guide 🖊
@@ -161,15 +166,14 @@ No strict requirements here — just modify the relevant `*.html` files. Preview
After editing documentation, refresh relevant files:
```bash
-# Refresh sidebar and README sync
-./run.sh docsify-sidebar-gen
-./run.sh refresh-docs
+# Fix code block blank line issues and regenerate sidebars
+cargo ci docsify-refresh
-# Fix code block blank line issues
-./run.sh docs-code-box-fix
+# Regenerate example docs module and examples index
+cargo ci example-refresh
```
-These steps are included in `cargo ci`; running `cargo ci` will execute them automatically.
+These steps are part of the full pipeline; `./run.sh ci` executes them automatically and fails if they leave the tree dirty (i.e. the generated files were stale).
> [!TIP]
> You can check the [ABOUT CI](https://mingling-rs.github.io/mingling/docs/dev/#/pages/abouts/ci) section to learn how "Mingling CI" works.
diff --git a/arg_picker/mingling-ci.toml b/arg_picker/mingling-ci.toml
new file mode 100644
index 0000000..73a9935
--- /dev/null
+++ b/arg_picker/mingling-ci.toml
@@ -0,0 +1,2 @@
+[test]
+command = ["cargo", "test", "-p", "arg-picker"]
diff --git a/arg_picker_macros/mingling-ci.toml b/arg_picker_macros/mingling-ci.toml
new file mode 100644
index 0000000..62fc2b1
--- /dev/null
+++ b/arg_picker_macros/mingling-ci.toml
@@ -0,0 +1,2 @@
+[test]
+command = ["cargo", "test", "-p", "arg-picker-macros"]
diff --git a/docs/dev/pages/abouts/ci.md b/docs/dev/pages/abouts/ci.md
index 9f638d7..f9a58be 100644
--- a/docs/dev/pages/abouts/ci.md
+++ b/docs/dev/pages/abouts/ci.md
@@ -3,9 +3,9 @@
CI workflow and local execution guide for Mingling
</p>
-Mingling's CI process is built into the project, with its execution logic located in `.run/src/bin/ci.rs`. You can run it locally via the `cargo ci` command, which produces the same results as the `CI` workflow in GitHub Actions.
+Mingling's CI process is built into the project itself: the execution logic lives in `mingling_ci/`, a separate crate **built on the Mingling framework** — it dogfoods the very library it validates. You can run it locally via the `cargo ci` command, which produces the same results as the `CI` workflow in GitHub Actions.
-During development, you can run `cargo ci` at any time to verify that your code hasn't introduced regressions.
+During development, you can run `cargo ci <command>` at any time to verify that your code hasn't introduced regressions.
## Running Locally
@@ -13,82 +13,140 @@ An alias is defined in `.cargo/config.toml` at the project root:
```toml
[alias]
-ci = "run --manifest-path .run/Cargo.toml --bin ci --quiet --"
+ci = "run --manifest-path mingling_ci/Cargo.toml --bin ci --quiet --"
```
-Simply execute:
+Run a single step:
```bash
-cargo ci
+cargo ci build-check
```
-## CI Steps
+Run the full pipeline (lock → all checks → refresh → unlock) via the orchestration script:
-Every CI step is an independent switch (`--check-*`). Running `cargo ci` with no options executes **all** steps in the order below; pass one or more `--check-*` flags to run only the selected steps.
+```bash
+python .run/src/bin/ci.py
+```
+
+The script is also picked up by `run.sh`:
+
+```bash
+./run.sh ci
+```
+
+## Commands
-| Step | Flag | What it does |
-| --------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Build | `--check-build` | Recursively finds all `Cargo.toml` files and runs `cargo build` for each crate in parallel (workspace members build with all documented features). |
-| Clippy | `--check-clippy` | Runs `cargo clippy ... -- -D warnings` for every crate in parallel; any warning fails the check. |
-| Test | `--check-test` | Runs `cargo test` for every crate in parallel (workspace tests run with all documented features; `arg-picker` is excluded). |
-| Arg picker | `--check-arg-picker` | Runs `cargo test -p arg-picker` with its default features. |
-| Markdown code | `--check-markdown-code` | Runs the `test-all-markdown-code` tool to verify code blocks in all `*.md` files compile. See [ABOUT_CODE_VERIFY](docs/_ABOUT_CODE_VERIFY.md). |
-| Examples | `--check-examples` | Runs the `test-examples` tool to verify all examples behave as expected. Each example declares its expected output tests in `examples/<example>/test.toml`. |
-| Docs up to date | `--check-docs-refresh` | Runs the documentation refresh tools and `cargo fmt`, then fails if the working tree is no longer clean (i.e. the docs were stale). |
-| API docs | `--check-api-docs` | Builds API docs with the `[package.metadata.docs.rs]` features and fails if `docs/api-docs/` is out of date. |
+Every CI step is one subcommand. `cargo ci` with no subcommand prints the help page.
-### Docs up to date in detail
+### UTILS
-`--check-docs-refresh` runs the following documentation refresh tools in sequence:
+| Command | What it does |
+| --------------- | ----------------------------------------------------------------------- |
+| `report-collect` | Assembles the collected logs in `.temp/reports/collect/` into `.temp/reports/result.md` |
+| `report-clean` | Deletes all collected logs and the generated report |
+| `git-lock` | Locks the workspace for a CI run (temporary commit, see below) |
+| `git-unlock` | Restores the workspace and checks idempotency (see below) |
+| `show-manifests` | Prints every crate path that CI will check |
+| `show-features` | Prints the `docs.rs` feature list of `mingling` |
-- `docs-code-box-fix`
-- `docsify-sidebar-gen`
-- `refresh-docs`
-- `refresh-feature-mod`
-- `sync-examples`
+### TOOLS (refresh)
-Finally, it runs `cargo fmt` to unify code formatting. Because the refresh tools regenerate derived files, running this check against stale documentation modifies the working tree — and `ci.rs` fails the run in that case. (Using `--dirty` skips the cleanliness check, which makes this flag behave like a plain "refresh docs" command.)
+| Command | What it does |
+| ------------------- | --------------------------------------------------------------------------------- |
+| `example-refresh` | Regenerates `mingling/src/example_docs.rs` and `docs/example-pages/examples.json` |
+| `docsify-refresh` | Fixes docsify code-box blank lines and regenerates `_sidebar.md` files |
+| `features-refresh` | Regenerates `mingling/src/features.rs` from `mingling/Cargo.toml` |
-### Examples in detail
+These tools **write files**. Running them inside a `git-lock` / `git-unlock` pair turns them into an up-to-date check: if the generated files are stale, the tree becomes dirty and `git-unlock` fails.
-`--check-examples` runs the `test-examples` tool in two phases:
+### TASKS (checks)
-1. **Build** — every example that has a `test.toml` is built in parallel (one `cargo build` task per example, reusing the shared `.temp/target` cache).
-2. **Test** — each `[[runs]]` entry in `examples/*/test.toml` is executed serially against the pre-built binary, asserting the CLI arguments (`input`) and the expected `exit-code` / `result`.
+| Command | What it does |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------- |
+| `build-check` | Finds all `Cargo.toml` files (minus `.config/ci-ignored-dirs.txt`) and runs `cargo build` per crate in parallel. |
+| `clippy-check` | Runs `cargo clippy ... -- -D warnings` for every crate in parallel; any warning fails the check. |
+| `test-all` | Runs `cargo test` for every crate in parallel. Each base crate can override its command in its `mingling-ci.toml` (`[test].command`, with `<<<features>>>` expanded from the docs.rs feature list); `arg-picker` uses this to run `cargo test -p arg-picker`. |
+| `example-check` | Builds every example and runs the expected-output tests declared in `examples/<example>/test.toml`. |
+| `docs-check` | Builds the `mingling` API docs with the `[package.metadata.docs.rs]` features and `-D warnings`. |
+| `markdown-check <PATH>` | Verifies the rust code blocks of a single markdown file compile. See [ABOUT_CODE_VERIFY](docs/_ABOUT_CODE_VERIFY.md). |
+| `markdown-check-all` | Verifies all markdown files declared in `.config/verified-docs.toml`. |
+| `markdown-compare <A> <B>` | Compares the *structure* of two markdown files or directories. |
+| `markdown-compare-all` | Checks every translated docs directory mirrors the reference `./docs/pages/` (per `.config/docs-lang.txt`). |
-An example that changes its behavior only needs its own `test.toml` updated.
+## Reports
-### Combining steps
+Every check exports its per-item outcome through the reporter into `.temp/reports/collect/`:
-When several `--check-*` flags are combined, the steps run in the order listed above. In "run all" mode (no flags given), the documentation steps all execute even if one of them fails, so every problem is reported in a single run.
+- `{Task}.{Platform}.ok` — one `item = location` per line (aggregated on flush).
+- `{Task}.{Platform}.{item}.err` — failures, first line is the location.
-## File Normalization
+`cargo ci report-collect` reads that directory and renders the consolidated report (per-task tables + failure details) to `.temp/reports/result.md`:
-Regardless of which steps run, `cargo ci` finishes with `git add --renormalize .` to ensure file attributes such as line endings conform to the repository configuration.
+```bash
+cargo ci report-collect
+cat .temp/reports/result.md
+```
+
+`cargo ci report-clean` wipes both the collect directory and the report.
## Workspace Cleanliness and Temporary Commits
-To ensure reproducible CI results, `ci.rs` imposes strict requirements on the workspace state:
+To ensure reproducible CI results, CI runs inside a `git-lock` / `git-unlock` pair.
+
+### git-lock
+
+1. Pins the current HEAD to a backup branch: `git branch -f mingling/bkup HEAD` (created or force-reset).
+2. If the working tree is dirty, all changes are packed into a plain temporary commit `[DO NOT PUSH] TEMP [DO NOT PUSH]`.
+3. A marker file `MINGLING-CI-CHECKING` is written — content `true` when the tree was dirty, `false` when it was clean.
+4. Everything is committed as `[DO NOT PUSH] CI TEMP [DO NOT PUSH]`.
-- If the current workspace is not clean and `--dirty` has not been specified, the script will prompt whether to create a temporary commit:
- - The commit message is `[DO NOT PUSH] CI TEMP [DO NOT PUSH]`.
- - Use `-y` to auto-confirm without interaction.
-- After CI finishes, the script automatically restores the workspace:
- - First, `git reset --hard` discards all changes.
- - If a temporary commit was created, it then runs `git reset --soft HEAD~1` and unstages everything, restoring the state to before CI started.
-- If `--dirty` is specified, the temporary commit and the final cleanliness check are skipped.
+```
+clean: A ── CI TEMP (marker = false)
+dirty: A ── TEMP (your changes) ── CI TEMP (marker = true)
+```
+
+### git-unlock
+
+Only acts when the HEAD commit message contains `CI TEMP` (case-sensitive); otherwise it refuses with a non-zero exit. The restore path is picked by the marker:
+
+- `true` — hard reset past the marker commit, then soft reset + unstage, so **your pre-lock changes are restored into the working tree**.
+- `false` — a single hard reset back to the original HEAD.
+
+If the working tree is dirty when unlocking (e.g. CI left tracked changes behind, such as stale generated docs), the restore still runs but the command reports a **non-zero exit code** — this is the idempotency check. In CI, that fails the job. The dirtiness check compares **content**, not file timestamps, so merely touching files during a build never fails the check.
+
+Pass `--show-diff` to print the diff of those tracked changes before they are discarded:
+
+```bash
+cargo ci git-unlock --show-diff
+```
+
+This is what the CI workflow uses: an idempotency failure shows exactly what contaminated the workspace in the job logs.
-> **Warning**: `git reset --hard` is executed at the end of CI. If you use `--dirty`, ensure you have no unsaved important changes.
+> **Warning**: when unlocking a `true` lock, changes made *during* CI are discarded. Anything you had before locking comes back.
## GitHub Actions Workflow
`.github/workflows/ci.yml` defines the project's CI:
- Triggered on `push` to the `main` branch.
-- A single `Check` job runs every `--check-*` step in a **step × platform** matrix (`ubuntu-latest` and `windows-latest`), i.e. `cargo ci --check-<item>` for each combination. The `.temp` build cache is no longer used; every matrix job starts from a clean workspace.
+- A `Check` job runs in a **item × platform** matrix (`ubuntu-latest`, `windows-latest`, `macos-latest`), each combination being `cargo ci <command>` inside a `git-lock` / `git-unlock` pair:
+
+| Matrix item | Command |
+| -------------- | -------------------------------------------------------------- |
+| `build` | `cargo ci build-check` |
+| `clippy` | `cargo ci clippy-check` |
+| `test` | `cargo ci test-all` |
+| `arg-picker` | `cargo ci test-all` (covered via its `mingling-ci.toml` override) |
+| `markdown-code` | `cargo ci markdown-check-all && cargo ci markdown-compare-all` |
+| `examples` | `cargo ci example-check` |
+| `docs-refresh` | `cargo ci example-refresh` + `docsify-refresh` + `features-refresh` |
+| `api-docs` | `cargo ci docs-check` |
+
+- Every matrix job uploads its `.temp/reports/collect/` as an artifact — **even on failure**, so failures are always collected.
+- A `Report` job (runs even when some checks failed) downloads all collect artifacts, runs `cargo ci report-collect`, and publishes `result.md` to the job summary via `$GITHUB_STEP_SUMMARY`.
- After CI passes, the `unreleased` tag is automatically moved to the latest commit on `main`.
-For non-`main` branches and pull requests, `.github/workflows/ci-check-only.yml` runs the same matrix without moving the tag or deploying.
+For non-`main` branches and pull requests, `.github/workflows/ci-check-only.yml` runs the same matrix and report collection without moving the tag or deploying.
### API Documentation Deployment
diff --git a/docs/dev/pages/abouts/code-verify-system.md b/docs/dev/pages/abouts/code-verify-system.md
index 20da045..c2a9215 100644
--- a/docs/dev/pages/abouts/code-verify-system.md
+++ b/docs/dev/pages/abouts/code-verify-system.md
@@ -7,16 +7,28 @@ This system automatically extracts and compiles Rust code blocks from docs, ensu
## Config
-Specify which Markdown files to verify via [`verified-docs.toml`](https://github.com/mingling-rs/mingling/blob/main/verified-docs.toml) in the project root.
+Specify which Markdown files to verify via `.config/verified-docs.toml`:
-You can also test a single file via command-line arg:
+```toml
+[verified]
+readme = "./README.md"
+getting_started = "./GETTING-STARTED.md"
+documents_en_us = "./docs/pages/**"
+documents_zh_cn = "./docs/_zh_CN/pages/**"
+```
+
+Each key is a label used to name the report items; values are single files, directories, or `**` globs.
+
+Run all configured files:
```sh
-./run-tools.sh test-all-markdown-code docs/pages/1-getting-started.md
+cargo ci markdown-check-all
```
-```powershell
-.\run-tools.ps1 test-all-markdown-code docs/pages/1-getting-started.md
+You can also test a single file via command-line arg (path is joined onto the current directory):
+
+```sh
+cargo ci markdown-check docs/pages/1-getting-started.md
```
## Default Rules
@@ -74,7 +86,7 @@ After the **default rules** are applied, each block goes through:
### 2. Temp Project Generation
-Each block (or each dedup-hash group) gets its own Cargo project:
+Each dedup-hash group gets its own Cargo project:
```
.temp/doc-test/<hash>/
@@ -85,18 +97,16 @@ Each block (or each dedup-hash group) gets its own Cargo project:
### 3. Build Verification
-Compiled with `cargo build --release`, stderr inherited to the terminal for real-time progress.
+Compiled with `cargo check --manifest-path ... --color=always`, stderr inherited to the terminal for real-time progress. Blocks within a group are serial (they share the crate directory); groups run in parallel.
- **Build OK** → **PASS**
- **Build FAIL** → **FAIL**, last 20 lines of error captured.
### 4. Report
-After all tests, a report is written to `.temp/DOCS-TEST-RESULT.md`, containing:
+Each file's result is exported through the reporter: an `ok` entry when every block passed, otherwise an `err` entry carrying the failed blocks' details. All entries land in `.temp/reports/collect/` (e.g. `Markdown-Check-All.Linux.ok`, `Markdown-Check-All.Linux.<item>.err`).
-- Total tests, passed, failed
-- Table of results per block (block #, file, line, status)
-- Detailed errors for failed blocks
+`cargo ci report-collect` then assembles everything into `.temp/reports/result.md` (also published to the GitHub Actions job summary).
### 5. Exit Code
@@ -121,7 +131,7 @@ fn placeholder() {}
### `// BUILD TIME`
-Marks the block as a `build.rs` script instead of `src/main.rs`. The block code is wrapped in `fn main() { }` and written to `build.rs`. A stub `fn main() {}` is generated for `src/main.rs`.
+Marks the block as a `build.rs` script instead of `src/main.rs`. The block code is wrapped in `fn main() { }` and written to `build.rs`. A stub `fn main() {}` is generated for `src/main.rs`. The declared features are mirrored into `[build-dependencies]` so `build.rs` sees the same feature set.
```rust
// BUILD TIME
@@ -202,11 +212,16 @@ Use `@@@` for:
| Module | Responsibility |
| --------------------------------------------- | ----------------------------------------------------------------------------------- |
-| `dev_tools/src/verify.rs` | Block parsing, Cargo.toml/main.rs generation, build exec, hash dedup, report output |
-| `dev_tools/src/bin/test-all-markdown-code.rs` | Entry point: read config, collect files, orchestrate tests, aggregate results |
-| `verified-docs.toml` | Specifies which doc files to verify |
+| `mingling_ci/src/markdown/project.rs` | Block parsing, Cargo.toml/main.rs generation, FNV-1a dep hash |
+| `mingling_ci/src/markdown/test.rs` | Grouping by dep hash, parallel `cargo check` execution |
+| `mingling_ci/src/task/cmd_markdown_check.rs` | `markdown-check` / `markdown-check-all` commands: read config, collect files, report |
+| `mingling_ci/src/markdown/compare.rs` | Structural signature comparison (for `markdown-compare`) |
+| `mingling_ci/src/task/cmd_markdown_compare.rs`| `markdown-compare` / `markdown-compare-all` commands |
+| `.config/verified-docs.toml` | Specifies which doc files to verify |
----
+### Structure Comparison
+
+`markdown-compare` (two files or directories) and `markdown-compare-all` (all languages from `.config/docs-lang.txt`, whose first line is the reference directory) check that every translated docs directory **mirrors the structure** of the reference docs exactly: one token per line classifying headings, fenced code blocks (with language tag), `@@@` lines, blank lines, blockquotes, lists and plain text. Translated text may differ; the structure may not.
## Full Example
diff --git a/index.html b/index.html
index 15a72d6..5a57f30 100644
--- a/index.html
+++ b/index.html
@@ -1289,7 +1289,7 @@ pub fn hello() { /* ... */ }
pub fn bye() { /* ... */ }
#[completion(EntryHello)]
-pub fn complete_hello(ctx: &amp;ShellContext) -&gt; Suggest {
+pub fn complete_hello(ctx: ShellContext) -&gt; Suggest {
if ctx.previous_word == "hello" {
suggest! {
"Alice": "The Sender",
diff --git a/mingling/mingling-ci.toml b/mingling/mingling-ci.toml
new file mode 100644
index 0000000..767c133
--- /dev/null
+++ b/mingling/mingling-ci.toml
@@ -0,0 +1,2 @@
+[test]
+command = ["cargo", "test", "-p", "mingling", "--features", "<<<features>>>"]
diff --git a/mingling/src/setups/exit_code.rs b/mingling/src/setups/exit_code.rs
index 0558892..49d5f9f 100644
--- a/mingling/src/setups/exit_code.rs
+++ b/mingling/src/setups/exit_code.rs
@@ -37,12 +37,6 @@ use crate::res::ResExitCode;
/// - Overrides the program's exit code with that value if it is non-zero.
/// - Leaves the exit code untouched if the resource still holds its default
/// value of `0`.
-///
-/// # Notes
-///
-/// - Use [`update_exit_code`](crate::update_exit_code) to set a custom exit code
-/// during program execution.
-/// - Use [`current_exit_code`](crate::current_exit_code) to query the current value.
pub struct ExitCodeSetup;
impl<C> ProgramSetup<C> for ExitCodeSetup
diff --git a/mingling_ci/Cargo.lock b/mingling_ci/Cargo.lock
new file mode 100644
index 0000000..796176a
--- /dev/null
+++ b/mingling_ci/Cargo.lock
@@ -0,0 +1,777 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "arg-picker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e326de90a1c279562cc1583470daccfd4defe882661c61607edff75b797e483e"
+dependencies = [
+ "arg-picker-macros",
+ "just_fmt 0.2.1",
+]
+
+[[package]]
+name = "arg-picker-macros"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5f9bbe04b69744a30def8bee5526268b986616314bd1696804d7f819f03d1f3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "colored"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "console"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "unicode-width 0.2.2",
+ "windows-sys",
+]
+
+[[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "indicatif"
+version = "0.18.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
+dependencies = [
+ "console",
+ "portable-atomic",
+ "unicode-width 0.2.2",
+ "unit-prefix",
+ "web-time",
+]
+
+[[package]]
+name = "is-terminal"
+version = "0.4.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "just_fmt"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5454cda0d57db59778608d7a47bff5b16c6705598265869fb052b657f66cf05e"
+
+[[package]]
+name = "just_fmt"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91b935090fce9a995a79798a22d523f1742b202f57ad2d8fcab6ad3dff528baf"
+
+[[package]]
+name = "just_progress"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef1a564328a5061a4828b4f82b7275a7f3dbc7d4ed5778da986f6ab48563c88"
+dependencies = [
+ "tokio",
+]
+
+[[package]]
+name = "just_template"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2a56a31287e7397340dd8d9997c8fd936ee68b27cb3f090ece2984e8a84d5bc"
+dependencies = [
+ "just_fmt 0.1.2",
+ "just_template_macros",
+]
+
+[[package]]
+name = "just_template_macros"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74f4328c632a33ef4c2c811e02bd2506f2db215bf7fbecbf934b0a61a17e7bea"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libredox"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "might_be_async"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eca871cea620b07bd2e6da0c883891a25bead698c43a9ab64b0fd663a7a78d5f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "toml",
+]
+
+[[package]]
+name = "mingling"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe3138d939ed9c987301b38a1f0577e4db5b0e9b14e16f73ebe37dad8e25380e"
+dependencies = [
+ "arg-picker",
+ "mingling_core",
+ "mingling_macros",
+]
+
+[[package]]
+name = "mingling-ci-system"
+version = "0.1.0"
+dependencies = [
+ "colored",
+ "indicatif",
+ "just_fmt 0.2.1",
+ "just_progress",
+ "just_template",
+ "mingling",
+ "prettytable-rs",
+ "serde",
+ "serde_json",
+ "tokio",
+ "toml",
+]
+
+[[package]]
+name = "mingling_core"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9165f58f51569b6627c09fd4292fa298c11ef2c467c0b77640a52339760249fb"
+dependencies = [
+ "just_fmt 0.2.1",
+ "might_be_async",
+ "mingling_pathf",
+]
+
+[[package]]
+name = "mingling_macros"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28cb7912933889738451b181d8bf958d215265555596328c5c4a8053c1be78d"
+dependencies = [
+ "just_fmt 0.2.1",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "mingling_pathf"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ed8d3adebe58b1c914f1fddce1b7a2afa2c9fca80665f0af251ea64662b8905"
+dependencies = [
+ "just_fmt 0.2.1",
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "prettytable-rs"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a"
+dependencies = [
+ "csv",
+ "encode_unicode",
+ "is-terminal",
+ "lazy_static",
+ "term",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "term"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
+dependencies = [
+ "dirs-next",
+ "rustversion",
+ "winapi",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "unit-prefix"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/mingling_ci/Cargo.toml b/mingling_ci/Cargo.toml
new file mode 100644
index 0000000..c96043b
--- /dev/null
+++ b/mingling_ci/Cargo.toml
@@ -0,0 +1,47 @@
+# This CI system is built on Mingling and is used to validate the next
+# version of Mingling.
+
+[package]
+name = "mingling-ci-system"
+edition = "2024"
+version = "0.1.0"
+publish = false
+
+[workspace]
+
+[dependencies]
+# NOTE: Do not use the current version of Mingling. The CI must be built on an
+# earlier version than the mainline Mingling.
+#
+# > You know the reason! ... the **grandfather paradox**
+mingling = { version = "0.4.0", features = [
+ "async",
+ "dispatch_tree",
+ "extras",
+ "pathf",
+ "picker",
+] }
+
+just_progress = "0.1.3"
+colored = "3.1.1"
+indicatif = "0.18.4"
+tokio = { version = "1.53.1", features = [
+ "rt",
+ "rt-multi-thread",
+ "macros",
+ "process",
+] }
+
+prettytable-rs = "0.10.0"
+toml = "0.8"
+just_template = "0.2.1"
+just_fmt = "0.2.1"
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+
+[build-dependencies]
+mingling = { version = "0.4.0", features = [
+ "build",
+ "dispatch_tree",
+ "pathf"
+] }
diff --git a/mingling_ci/build.rs b/mingling_ci/build.rs
new file mode 100644
index 0000000..e0bcc0c
--- /dev/null
+++ b/mingling_ci/build.rs
@@ -0,0 +1,6 @@
+use mingling::build::analyze_and_build_type_mapping;
+
+fn main() {
+ analyze_and_build_type_mapping().unwrap();
+}
+
diff --git a/mingling_ci/help.txt b/mingling_ci/help.txt
new file mode 100644
index 0000000..de006bb
--- /dev/null
+++ b/mingling_ci/help.txt
@@ -0,0 +1,34 @@
+This program is used to check the code quality of the Mingling project itself.
+
+USAGE: cargo ci <FLAGS> [SUBCOMMAND] <ARGS...>
+
+FLAGS:
+ -h, --help Print this help page
+ -q, --quiet Quiet output
+
+COMMANDS:
+ UTILS:
+ report-collect Collect and organize all inspection reports
+ report-clean Clean up all reports
+
+ git-lock Temporarily commit the workspace for CI
+ git-unlock Restore the workspace after CI
+
+ show-features Print the docs.rs feature list of mingling
+ show-manifests Print all crate paths that need to be checked
+
+ TOOLS:
+ example-refresh Regenerate example docs module and examples index
+ docsify-refresh Fix docsify code boxes and regenerate sidebars
+ features-refresh Regenerate the features module
+
+ TASKS:
+ markdown-check <PATH> Verify rust code blocks in one markdown file
+ markdown-check-all Verify rust code blocks in all configured markdown files
+ markdown-compare <PATH> <PATH> Compare the structure of two markdown files/dirs
+ markdown-compare-all Compare all translated docs against the reference
+ build-check Build all crates
+ clippy-check Run clippy with -D warnings on all crates
+ test-all Test all crates
+ example-check Build examples and run their test.toml cases
+ docs-check Build mingling docs with -D warnings
diff --git a/mingling_ci/src/bin/ci.rs b/mingling_ci/src/bin/ci.rs
new file mode 100644
index 0000000..5b1d748
--- /dev/null
+++ b/mingling_ci/src/bin/ci.rs
@@ -0,0 +1,30 @@
+use mingling::setup::{
+ ConfirmSetup, DirectoryEnvironmentSetup, ExitCodeSetup,
+ picker::{ConfirmFlagSetup, HelpFlagSetup, QuietFlagSetup},
+};
+
+use mingling_ci_system::ThisProgram;
+use mingling_ci_system::res::*;
+
+#[tokio::main]
+async fn main() {
+ let mut program = ThisProgram::new();
+
+ // Plugins
+ program.with_setup(ExitCodeSetup::default());
+ program.with_setup(DirectoryEnvironmentSetup::default());
+
+ program.with_setup(HelpFlagSetup::default());
+ program.with_setup(ConfirmFlagSetup::default());
+ program.with_setup(QuietFlagSetup::default());
+
+ program.with_setup(ConfirmSetup);
+
+ // CI Plugins
+ program.with_setup(ManifestsSetup);
+ program.with_setup(FeaturesSetup);
+ program.with_setup(CrateConfigSetup);
+ program.with_setup(ReportSetup);
+
+ program.exec_and_exit().await;
+}
diff --git a/mingling_ci/src/cmd.rs b/mingling_ci/src/cmd.rs
new file mode 100644
index 0000000..b9a02dc
--- /dev/null
+++ b/mingling_ci/src/cmd.rs
@@ -0,0 +1,6 @@
+pub(crate) mod cmd_git_lock;
+pub(crate) mod cmd_git_unlock;
+pub(crate) mod cmd_report_clean;
+pub(crate) mod cmd_report_collect;
+pub(crate) mod cmd_show_features;
+pub(crate) mod cmd_show_manifests;
diff --git a/mingling_ci/src/cmd/cmd_git_lock.rs b/mingling_ci/src/cmd/cmd_git_lock.rs
new file mode 100644
index 0000000..0e9bf22
--- /dev/null
+++ b/mingling_ci/src/cmd/cmd_git_lock.rs
@@ -0,0 +1,77 @@
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{buffer, command, r_println, renderer},
+ res::ResExitCode,
+};
+
+use crate::Next;
+use crate::git::{CI_TEMP_COMMIT_MESSAGE, LOCK_FILE, TEMP_COMMIT_MESSAGE, run_git, worktree_clean};
+use crate::res::{CargoError, MessagePrinter};
+
+/// Temporarily commits the workspace so CI can run on a stable tree.
+///
+/// First pins the current HEAD to the `mingling/bkup` backup branch (created
+/// or force-reset). When the tree is dirty, all changes are packed into a
+/// plain `TEMP` commit first so they can be restored later; the `CI TEMP`
+/// commit then carries only the `MINGLING-CI-CHECKING` marker file, whose
+/// content (`true`/`false`) tells `git-unlock` which restore path to take.
+#[command(node = "git-lock")]
+pub fn git_lock() -> Next {
+ if let Err(e) = run_git(["branch", "-f", "mingling/bkup", "HEAD"]) {
+ return ErrorGitLock(e).to_chain();
+ }
+
+ let dirty = !worktree_clean();
+ if dirty {
+ if let Err(e) = run_git(["add", "."]) {
+ return ErrorGitLock(e).to_chain();
+ }
+ if let Err(e) = run_git(["commit", "-m", TEMP_COMMIT_MESSAGE]) {
+ return ErrorGitLock(e).to_chain();
+ }
+ }
+
+ let marker = if dirty { "true" } else { "false" };
+ if let Err(e) = std::fs::write(LOCK_FILE, marker) {
+ return ErrorGitLock(format!("failed to create {LOCK_FILE}: {e}")).to_chain();
+ }
+
+ if let Err(e) = run_git(["add", "."]) {
+ return ErrorGitLock(e).to_chain();
+ }
+ if let Err(e) = run_git(["commit", "-m", CI_TEMP_COMMIT_MESSAGE]) {
+ return ErrorGitLock(e).to_chain();
+ }
+
+ ResultGitLock { dirty }.to_chain()
+}
+
+/// Whether the tree was dirty (a base `TEMP` commit exists) when locking.
+#[derive(Grouped)]
+pub struct ResultGitLock {
+ dirty: bool,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorGitLock(pub String);
+
+#[renderer(buffer)]
+pub fn render_git_lock(r: ResultGitLock) {
+ if r.dirty {
+ r_println!("Locked: dirty workspace committed for CI");
+ } else {
+ r_println!("Locked: clean workspace marked for CI");
+ }
+}
+
+#[renderer]
+pub fn render_error_git_lock(
+ e: ErrorGitLock,
+ error: &CargoError,
+ exit_code: &mut ResExitCode,
+) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![format!("Git-Lock: {}", e.0)]);
+ exit_code.exit_code = 1;
+ render_result
+}
diff --git a/mingling_ci/src/cmd/cmd_git_unlock.rs b/mingling_ci/src/cmd/cmd_git_unlock.rs
new file mode 100644
index 0000000..41efefc
--- /dev/null
+++ b/mingling_ci/src/cmd/cmd_git_unlock.rs
@@ -0,0 +1,116 @@
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{arg, buffer, command, r_println, renderer},
+ picker::{EntryPicker, value::Flag},
+ res::ResExitCode,
+};
+
+use crate::git::{LOCK_FILE, TEMP_COMMIT_MARK, head_message, run_git, worktree_clean};
+use crate::res::{CargoError, MessagePrinter};
+use crate::{Entry, Next};
+
+/// Undoes a CI temporary commit created by [`crate::cmd::cmd_git_lock`].
+///
+/// Only acts when the HEAD commit message contains `CI TEMP` (case-sensitive).
+/// The restore path is picked by the marker file content:
+///
+/// - `true`: a base `TEMP` commit with the dirty changes sits below; restore
+/// by hard-resetting past the marker commit, then soft-resetting and
+/// unstaging to put the user's changes back into the working tree.
+/// - `false`: the tree was clean; a single hard reset back to the original
+/// HEAD is enough.
+///
+/// When the working tree is dirty (e.g. CI left tracked changes behind) the
+/// restore still runs, but the command reports a non-zero exit code so the
+/// caller knows the CI phase contaminated the repository. With `--show-diff`
+/// the diff of those changes is printed before they are discarded.
+#[command(node = "git-unlock")]
+// `#[command]` rewrites an owned first param into the entry type, so the args
+// must be passed by value even though the body only reads them.
+#[allow(clippy::needless_pass_by_value)]
+pub fn git_unlock(args: Entry) -> Next {
+ let head = head_message().unwrap_or_default();
+ if !head.contains(TEMP_COMMIT_MARK) {
+ return ErrorGitUnlock(format!("HEAD is not a CI temporary commit: `{head}`")).to_chain();
+ }
+
+ // Record dirtiness before restoring: the restore discards those changes.
+ let dirty = !worktree_clean();
+
+ // The marker file lives in the HEAD (CI TEMP) commit, so it is readable
+ // from the working tree; a missing marker falls back to the clean path.
+ let based_on_dirty =
+ std::fs::read_to_string(LOCK_FILE).is_ok_and(|content| content.trim() == "true");
+
+ if dirty && *args.pick(&arg![show_diff: Flag]).unwrap() {
+ show_diff();
+ }
+
+ if let Err(e) = undo_ci_phase(based_on_dirty) {
+ return ErrorGitUnlock(e).to_chain();
+ }
+
+ ResultGitUnlock { dirty }.to_chain()
+}
+
+/// Prints the tracked changes the CI run left behind, before the restore
+/// discards them. Untracked files are not shown (they are removed by clean).
+fn show_diff() {
+ let Ok(diff) = run_git(["diff", "HEAD"]) else {
+ return;
+ };
+ if diff.is_empty() {
+ return;
+ }
+ println!("{diff}");
+}
+
+/// Restores the workspace, keeping the user's pre-lock changes.
+///
+/// With a base `TEMP` commit (`true`) the marker commit is dropped by a hard
+/// reset to `HEAD~1`, the `TEMP` commit is unwrapped into the staging area by
+/// a soft reset, and a plain reset unstages it back into the working tree.
+/// Without one (`false`) a single hard reset to `HEAD~1` removes the marker
+/// commit and lands on the original HEAD.
+fn undo_ci_phase(based_on_dirty: bool) -> Result<(), String> {
+ run_git(["reset", "--hard", "HEAD~1"])?;
+ if based_on_dirty {
+ // Unwrap the `TEMP` commit into the staging area, then unstage it
+ // back into the working tree.
+ run_git(["reset", "--soft", "HEAD~1"])?;
+ run_git(["reset"])?;
+ }
+ std::fs::remove_file(LOCK_FILE).ok();
+ Ok(())
+}
+
+/// Whether the working tree was dirty when the unlock started.
+#[derive(Grouped)]
+pub struct ResultGitUnlock {
+ dirty: bool,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorGitUnlock(pub String);
+
+#[renderer(buffer)]
+pub fn render_git_unlock(r: ResultGitUnlock, exit_code: &mut ResExitCode) {
+ if r.dirty {
+ r_println!("Unlocked: workspace restored (working tree was dirty)");
+ exit_code.exit_code = 1;
+ } else {
+ r_println!("Unlocked: workspace restored");
+ }
+}
+
+#[renderer]
+pub fn render_error_git_unlock(
+ e: ErrorGitUnlock,
+ error: &CargoError,
+ exit_code: &mut ResExitCode,
+) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![format!("Git-Unlock: {}", e.0)]);
+ exit_code.exit_code = 1;
+ render_result
+}
diff --git a/mingling_ci/src/cmd/cmd_report_clean.rs b/mingling_ci/src/cmd/cmd_report_clean.rs
new file mode 100644
index 0000000..976851e
--- /dev/null
+++ b/mingling_ci/src/cmd/cmd_report_clean.rs
@@ -0,0 +1,54 @@
+use std::path::PathBuf;
+
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{buffer, command, r_println, renderer},
+};
+
+use crate::Next;
+use crate::reporter::{COLLECT_DIR, REPORT_PATH};
+use crate::res::{CargoError, MessagePrinter};
+
+/// Removes collected logs and the generated report.
+#[command(node = "report-clean")]
+pub fn report_clean() -> Next {
+ let mut removed = Vec::new();
+ for path in [PathBuf::from(COLLECT_DIR), PathBuf::from(REPORT_PATH)] {
+ match std::fs::remove_dir_all(&path).or_else(|_| std::fs::remove_file(&path)) {
+ Ok(()) => removed.push(path),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => {
+ return ErrorReportClean(format!("failed to remove {}: {e}", path.display()))
+ .to_chain();
+ }
+ }
+ }
+ ResultReportClean { removed }.to_chain()
+}
+
+/// Paths removed by `report-clean`.
+#[derive(Grouped)]
+pub struct ResultReportClean {
+ pub removed: Vec<PathBuf>,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorReportClean(pub String);
+
+#[renderer(buffer)]
+pub fn render_report_clean(r: ResultReportClean) {
+ if r.removed.is_empty() {
+ r_println!("Report data already clean");
+ } else {
+ for path in r.removed {
+ r_println!("Removed {}", path.display());
+ }
+ }
+}
+
+#[renderer]
+pub fn render_error_report_clean(e: ErrorReportClean, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![format!("Report: {}", e.0)]);
+ render_result
+}
diff --git a/mingling_ci/src/cmd/cmd_report_collect.rs b/mingling_ci/src/cmd/cmd_report_collect.rs
new file mode 100644
index 0000000..2eff074
--- /dev/null
+++ b/mingling_ci/src/cmd/cmd_report_collect.rs
@@ -0,0 +1,150 @@
+use std::collections::{BTreeMap, HashMap};
+use std::path::PathBuf;
+
+use just_template::Template;
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{buffer, command, r_println, renderer},
+};
+
+use crate::Next;
+use crate::reporter::{COLLECT_DIR, REPORT_PATH};
+use crate::res::{CargoError, MessagePrinter, ResCollectLogs};
+
+const REPORT_TEMPLATE: &str = include_str!("../../tmpls/report.md");
+const TASK_SECTION_TEMPLATE: &str = include_str!("../../tmpls/task_section.md");
+
+/// Maps a package to its per-OS pass/fail status.
+type OsStatuses = BTreeMap<String, bool>;
+
+/// A row in a task section: item name and its per-OS statuses.
+type TaskRow<'a> = (&'a String, &'a OsStatuses);
+
+/// Rows grouped by task name.
+type RowsByTask<'a> = BTreeMap<&'a String, Vec<TaskRow<'a>>>;
+
+#[command(node = "report-collect")]
+pub fn report_collect(logs: &ResCollectLogs) -> Next {
+ if !PathBuf::from(COLLECT_DIR).is_dir() {
+ return ErrorNoCollectDir.to_chain();
+ }
+
+ // Group rows by task: task -> [(item, os_statuses)].
+ let by_task: RowsByTask =
+ logs.statuses
+ .iter()
+ .fold(BTreeMap::new(), |mut acc, ((task, item), os_statuses)| {
+ acc.entry(task).or_default().push((item, os_statuses));
+ acc
+ });
+
+ // Render one section per task (table rows + this task's failures).
+ let mut fail_count = 0;
+ let mut sections: Vec<HashMap<String, String>> = Vec::new();
+ for (task, rows) in by_task {
+ let mut row_arms = Vec::new();
+ let mut fail_arms = Vec::new();
+ for (item, os_statuses) in rows {
+ let location = logs
+ .locations
+ .get(&(task.clone(), item.clone()))
+ .cloned()
+ .unwrap_or_default();
+ row_arms.push(HashMap::from([
+ ("item_name".to_string(), item.clone()),
+ ("location".to_string(), location),
+ (
+ "pass_win".to_string(),
+ pass_cell(os_statuses.get("Windows")),
+ ),
+ (
+ "pass_linux".to_string(),
+ pass_cell(os_statuses.get("Linux")),
+ ),
+ ("pass_mac".to_string(), pass_cell(os_statuses.get("MacOS"))),
+ ]));
+
+ for (os, ok) in os_statuses {
+ if !ok {
+ let stdout = logs
+ .err_outputs
+ .get(&(task.clone(), os.clone(), item.clone()))
+ .cloned()
+ .unwrap_or_default();
+ fail_arms.push(HashMap::from([
+ ("item_name".to_string(), item.clone()),
+ ("stdout".to_string(), stdout),
+ ]));
+ fail_count += 1;
+ }
+ }
+ }
+
+ let mut section = Template::from(TASK_SECTION_TEMPLATE);
+ section.insert_param("task_name".to_string(), task.clone());
+ *section.add_impl("rows".to_string()) = row_arms;
+ *section.add_impl("fails".to_string()) = fail_arms;
+ sections.push(HashMap::from([(
+ "section".to_string(),
+ section.expand().unwrap_or_default(),
+ )]));
+ }
+
+ let mut template = Template::from(REPORT_TEMPLATE);
+
+ template.insert_param("date".to_string(), logs.git.date.clone());
+ template.insert_param("commit_hash".to_string(), logs.git.commit_hash.clone());
+ *template.add_impl("task_sections".to_string()) = sections;
+
+ let expanded = template.expand().unwrap_or_default();
+ let output = PathBuf::from(REPORT_PATH);
+ let parent = output.parent().expect("output path has a parent");
+
+ if let Err(e) = std::fs::create_dir_all(parent).and_then(|()| std::fs::write(&output, expanded))
+ {
+ return ErrorReportWrite(format!("failed to write {}: {e}", output.display())).to_chain();
+ }
+
+ ResultCollectResults { output, fail_count }.to_chain()
+}
+
+fn pass_cell(status: Option<&bool>) -> String {
+ match status {
+ Some(true) => "✅".to_string(),
+ Some(false) => "❌".to_string(),
+ None => "—".to_string(),
+ }
+}
+
+/// The generated report.
+#[derive(Grouped)]
+pub struct ResultCollectResults {
+ pub output: PathBuf,
+ pub fail_count: usize,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorNoCollectDir;
+
+#[derive(Grouped, Default)]
+pub struct ErrorReportWrite(pub String);
+
+#[renderer(buffer)]
+pub fn render_collect_results(r: ResultCollectResults) {
+ r_println!("Collected {} failing logs", r.fail_count);
+ r_println!("Report generated at {}", r.output.display());
+}
+
+#[renderer]
+pub fn render_error_no_collect_dir(_: ErrorNoCollectDir, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![format!("No collect directory: {COLLECT_DIR}")]);
+ render_result
+}
+
+#[renderer]
+pub fn render_error_report_write(e: ErrorReportWrite, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![format!("Report: {}", e.0)]);
+ render_result
+}
diff --git a/mingling_ci/src/cmd/cmd_show_features.rs b/mingling_ci/src/cmd/cmd_show_features.rs
new file mode 100644
index 0000000..5fff0c5
--- /dev/null
+++ b/mingling_ci/src/cmd/cmd_show_features.rs
@@ -0,0 +1,26 @@
+use mingling::{
+ Grouped,
+ macros::{buffer, command, r_println, renderer},
+};
+
+use crate::res::ResFeatureList;
+
+#[command(node = "show-features")]
+pub fn show_features(features: &ResFeatureList) -> ResultShowFeatures {
+ ResultShowFeatures {
+ features: features.list.clone(),
+ }
+}
+
+/// The docs.rs feature list of `mingling`.
+#[derive(Grouped)]
+pub struct ResultShowFeatures {
+ pub features: Vec<String>,
+}
+
+#[renderer(buffer)]
+pub fn render_show_features(r: ResultShowFeatures) {
+ for feature in r.features {
+ r_println!("{feature}");
+ }
+}
diff --git a/mingling_ci/src/cmd/cmd_show_manifests.rs b/mingling_ci/src/cmd/cmd_show_manifests.rs
new file mode 100644
index 0000000..2be82d2
--- /dev/null
+++ b/mingling_ci/src/cmd/cmd_show_manifests.rs
@@ -0,0 +1,71 @@
+use std::path::PathBuf;
+
+use mingling::{
+ Grouped,
+ macros::{buffer, command, r_println, renderer},
+};
+
+use prettytable::{
+ Cell, Row, Table,
+ format::{FormatBuilder, LinePosition, LineSeparator},
+};
+
+use crate::res::Manifests;
+
+#[command(node = "show-manifests")]
+pub fn show_manifests(manifests: &Manifests) -> ResultPrintManifests {
+ let mut entries: Vec<ManifestEntry> = manifests
+ .package_dirs
+ .iter()
+ .map(|(name, path)| ManifestEntry {
+ name: name.clone(),
+ path: path.clone(),
+ })
+ .collect();
+ entries.sort_by(|a, b| a.path.cmp(&b.path));
+ ResultPrintManifests { entries }
+}
+
+/// All manifests the CI will check, sorted by path.
+#[derive(Grouped)]
+pub struct ResultPrintManifests {
+ pub entries: Vec<ManifestEntry>,
+}
+
+#[derive(Debug, Clone)]
+pub struct ManifestEntry {
+ pub name: String,
+ pub path: PathBuf,
+}
+
+#[renderer(buffer)]
+pub fn render_print_manifests(r: ResultPrintManifests) {
+ let mut table = Table::new();
+
+ table.set_format(
+ FormatBuilder::new()
+ .column_separator('│')
+ .borders('│')
+ .separator(LinePosition::Top, LineSeparator::new('─', '┬', '┌', '┐'))
+ .separator(LinePosition::Title, LineSeparator::new('─', '┼', '├', '┤'))
+ .separator(LinePosition::Bottom, LineSeparator::new('─', '┴', '└', '┘'))
+ .padding(1, 1)
+ .build(),
+ );
+
+ table.set_titles(Row::new(vec![
+ Cell::new("#"),
+ Cell::new("Package-Name"),
+ Cell::new("Package-Path"),
+ ]));
+
+ for (index, entry) in r.entries.iter().enumerate() {
+ table.add_row(Row::new(vec![
+ Cell::new(&(index + 1).to_string()),
+ Cell::new(&entry.name),
+ Cell::new(&entry.path.to_string_lossy()),
+ ]));
+ }
+
+ r_println!("{table}");
+}
diff --git a/mingling_ci/src/examples.rs b/mingling_ci/src/examples.rs
new file mode 100644
index 0000000..92d3475
--- /dev/null
+++ b/mingling_ci/src/examples.rs
@@ -0,0 +1,186 @@
+//! Example binary testing: build each example and run its `test.toml` cases.
+
+use std::process::Output;
+
+/// A single `[[runs]]` entry of an example's `test.toml`.
+pub(crate) struct TestCase {
+ input: Vec<String>,
+ expect: Expect,
+}
+
+struct Expect {
+ exit_code: i32,
+ result: String,
+}
+
+/// One example and its test cases.
+pub(crate) struct ExampleCase {
+ name: String,
+ cases: Vec<TestCase>,
+}
+
+/// Outcome of checking one example.
+pub(crate) struct ExampleOutcome {
+ pub name: String,
+ pub location: String,
+ pub ok: bool,
+ pub output: String,
+}
+
+/// Loads `examples/<name>/test.toml` for every example that has one, in
+/// alphabetical order of the example directory name.
+pub(crate) fn load_test_configs() -> Vec<ExampleCase> {
+ let mut configs = Vec::new();
+ if let Ok(entries) = std::fs::read_dir("examples") {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_dir() {
+ continue;
+ }
+ let test_toml = path.join("test.toml");
+ if !test_toml.is_file() {
+ continue;
+ }
+ let name = path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or_default()
+ .to_string();
+ let Ok(content) = std::fs::read_to_string(&test_toml) else {
+ continue;
+ };
+ let Ok(table) = content.parse::<toml::Value>() else {
+ continue;
+ };
+ let Some(cases) = parse_cases(&table) else {
+ continue;
+ };
+ configs.push(ExampleCase { name, cases });
+ }
+ }
+ configs.sort_by(|a, b| a.name.cmp(&b.name));
+ configs
+}
+
+fn parse_cases(table: &toml::Value) -> Option<Vec<TestCase>> {
+ let runs = table.get("runs")?.as_array()?;
+ let mut cases = Vec::new();
+ for run in runs {
+ let input: Vec<String> = run
+ .get("input")?
+ .as_array()?
+ .iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect();
+ let expect = run.get("expect")?;
+ let exit_code = expect
+ .get("exit-code")?
+ .as_integer()
+ .and_then(|e| i32::try_from(e).ok())
+ .unwrap_or(-1);
+ let result = expect
+ .get("result")
+ .and_then(|r| r.as_str())
+ .unwrap_or_default()
+ .to_string();
+ cases.push(TestCase {
+ input,
+ expect: Expect { exit_code, result },
+ });
+ }
+ Some(cases)
+}
+
+/// Builds the example, then runs all of its test cases.
+pub(crate) fn check_example(example: ExampleCase) -> ExampleOutcome {
+ let location = format!("./examples/{}", example.name);
+
+ // Phase 1: build.
+ let manifest = format!("examples/{}/Cargo.toml", example.name);
+ let build = std::process::Command::new("cargo")
+ .args(["build", "--manifest-path", &manifest])
+ .output();
+ match build {
+ Ok(output) if !output.status.success() => ExampleOutcome {
+ name: example.name,
+ location,
+ ok: false,
+ output: build_error(&output),
+ },
+ Err(e) => ExampleOutcome {
+ name: example.name,
+ location,
+ ok: false,
+ output: format!("failed to run cargo: {e}"),
+ },
+ Ok(_) => {
+ // Phase 2: run the test cases against the built binary.
+ let mut failures = Vec::new();
+ for case in &example.cases {
+ if let Err(detail) = run_case(&example.name, case) {
+ failures.push(detail);
+ }
+ }
+ ExampleOutcome {
+ name: example.name,
+ location,
+ ok: failures.is_empty(),
+ output: failures.join("\n\n"),
+ }
+ }
+ }
+}
+
+/// Runs a single test case against the built binary.
+fn run_case(name: &str, case: &TestCase) -> Result<(), String> {
+ let exe = if cfg!(target_os = "windows") {
+ ".exe"
+ } else {
+ ""
+ };
+ let binary = format!(".temp/target/debug/{name}{exe}");
+
+ let output = std::process::Command::new(&binary)
+ .args(&case.input)
+ .output();
+ let Ok(output) = output else {
+ return Err(format!("failed to run {binary}"));
+ };
+
+ let actual_exit_code = output.status.code().unwrap_or(-1);
+ let actual_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ let actual_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+
+ let exit_ok = actual_exit_code == case.expect.exit_code;
+ let result_ok =
+ actual_stdout == case.expect.result || actual_stdout.contains(&case.expect.result);
+
+ if exit_ok && result_ok {
+ return Ok(());
+ }
+
+ let mut details = vec![format!("input: {}", case.input.join(" "))];
+ if !exit_ok {
+ details.push(format!(
+ "expected exit code {}, actual {actual_exit_code}",
+ case.expect.exit_code
+ ));
+ }
+ if !result_ok {
+ details.push(format!("expected output {:?}", case.expect.result));
+ details.push(format!("actual stdout {actual_stdout:?}"));
+ if !actual_stderr.is_empty() {
+ details.push(format!("actual stderr {actual_stderr:?}"));
+ }
+ }
+ Err(details.join("\n"))
+}
+
+/// Tail of a failed build's combined output.
+fn build_error(output: &Output) -> String {
+ let mut log = String::from_utf8_lossy(&output.stdout).into_owned();
+ log.push_str(&String::from_utf8_lossy(&output.stderr));
+ let lines: Vec<&str> = log.lines().collect();
+ let tail = &lines[lines.len().saturating_sub(20)..];
+ format!("build failed\n{}", tail.join("\n"))
+}
diff --git a/mingling_ci/src/git.rs b/mingling_ci/src/git.rs
new file mode 100644
index 0000000..a6fab2c
--- /dev/null
+++ b/mingling_ci/src/git.rs
@@ -0,0 +1,69 @@
+//! Thin wrappers around the `git` CLI used by the CI phase lock/unlock pair.
+
+use std::ffi::OsStr;
+use std::process::Command;
+
+/// Marker file created by `git-lock` in the CI temporary commit; its content
+/// is `true` when the tree was dirty (a base TEMP commit exists below) or
+/// `false` when it was clean. `git-unlock` reads it to pick the restore path.
+pub(crate) const LOCK_FILE: &str = "MINGLING-CI-CHECKING";
+
+/// First temporary commit: packs the dirty workspace changes so they can be
+/// restored later. Only created when the tree is dirty.
+pub(crate) const TEMP_COMMIT_MESSAGE: &str = "[DO NOT PUSH] TEMP [DO NOT PUSH]";
+
+/// Second temporary commit: carries the marker file, and its message is what
+/// `git-unlock` matches to confirm the CI phase.
+pub(crate) const CI_TEMP_COMMIT_MESSAGE: &str = "[DO NOT PUSH] CI TEMP [DO NOT PUSH]";
+
+/// Case-sensitive substring that identifies a CI temporary commit in the HEAD
+/// commit message.
+pub(crate) const TEMP_COMMIT_MARK: &str = "CI TEMP";
+
+/// Runs `git <args>`, returning stdout on success.
+///
+/// # Errors
+///
+/// Returns the git error message (stderr) when the command exits non-zero, or
+/// when git itself cannot be spawned.
+pub(crate) fn run_git<I, S>(args: I) -> Result<String, String>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<OsStr>,
+{
+ let output = Command::new("git")
+ .args(args)
+ .output()
+ .map_err(|e| format!("failed to run git: {e}"))?;
+ if output.status.success() {
+ Ok(String::from_utf8_lossy(&output.stdout).into_owned())
+ } else {
+ Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
+ }
+}
+
+/// Returns `true` when the working tree has no tracked changes relative to
+/// HEAD. Git failures count as "not clean" so the caller falls back to the
+/// marker-file path.
+///
+/// Uses the porcelain `git diff --quiet HEAD` rather than the plumbing
+/// `git diff-index --quiet HEAD`: after a full compile the source files'
+/// mtimes can be newer than the index stat records even though their content
+/// is unchanged, and `diff-index` reports that stale stat as a change. The
+/// porcelain diff refreshes the index first (via `diff.autoRefreshIndex`),
+/// so it only reports real content differences.
+pub(crate) fn worktree_clean() -> bool {
+ Command::new("git")
+ .args(["diff", "--quiet", "HEAD", "--"])
+ .status()
+ .is_ok_and(|status| status.success())
+}
+
+/// The subject line of the HEAD commit.
+///
+/// # Errors
+///
+/// Returns the git error message when the log command fails.
+pub(crate) fn head_message() -> Result<String, String> {
+ run_git(["log", "-1", "--pretty=%s"]).map(|subject| subject.trim().to_string())
+}
diff --git a/mingling_ci/src/lib.rs b/mingling_ci/src/lib.rs
new file mode 100644
index 0000000..32a0cbd
--- /dev/null
+++ b/mingling_ci/src/lib.rs
@@ -0,0 +1,28 @@
+#![deny(clippy::pedantic)]
+#![deny(clippy::nursery)]
+#![allow(clippy::redundant_pub_crate)]
+#![allow(clippy::missing_const_for_fn)]
+
+use mingling::macros::{gen_program, help};
+
+pub(crate) mod cmd;
+pub(crate) mod git;
+pub(crate) mod task;
+
+/// Mingling CI's Resources
+pub mod res;
+
+/// Log exporter for CI reports
+pub mod reporter;
+
+pub(crate) mod examples;
+pub(crate) mod markdown;
+pub(crate) mod progress;
+pub(crate) mod tools;
+
+#[help]
+pub fn render_fallback(_: EntryFallback) -> String {
+ include_str!("../help.txt").to_string()
+}
+
+gen_program!();
diff --git a/mingling_ci/src/markdown.rs b/mingling_ci/src/markdown.rs
new file mode 100644
index 0000000..75f2cbe
--- /dev/null
+++ b/mingling_ci/src/markdown.rs
@@ -0,0 +1,3 @@
+pub(crate) mod compare;
+pub(crate) mod project;
+pub(crate) mod test;
diff --git a/mingling_ci/src/markdown/compare.rs b/mingling_ci/src/markdown/compare.rs
new file mode 100644
index 0000000..1bf3c57
--- /dev/null
+++ b/mingling_ci/src/markdown/compare.rs
@@ -0,0 +1,203 @@
+//! Structural comparison of markdown docs (reference vs translation).
+//!
+//! For each file pair the comparison uses a *structural signature*: one token
+//! per line, classifying headings (both Markdown `#` and HTML `<hN>`), fenced
+//! code blocks (including their language tag), `@@@` hidden-compilation lines,
+//! blank lines, blockquotes, lists and plain text. Translated text is allowed
+//! to differ; the structure is not.
+
+use std::path::{Path, PathBuf};
+
+/// Collects all `.md` files under `dir`, returned relative to it.
+pub(crate) fn collect_md_files(dir: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![dir.to_path_buf()];
+ while let Some(current) = stack.pop() {
+ let Ok(entries) = std::fs::read_dir(&current) else {
+ continue;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else if path.extension().is_some_and(|e| e == "md") {
+ out.push(path.strip_prefix(dir).unwrap_or(&path).to_path_buf());
+ }
+ }
+ }
+ out.sort();
+ out
+}
+
+/// Compares the structural signatures of two markdown files.
+///
+/// Returns the human-readable diff lines (up to a small window) on the first
+/// structural difference.
+pub(crate) fn compare_signature(ref_path: &Path, lang_path: &Path) -> Result<(), Vec<String>> {
+ let ref_content = std::fs::read_to_string(ref_path).unwrap_or_default();
+ let lang_content = std::fs::read_to_string(lang_path).unwrap_or_default();
+
+ let ref_sig = signature_of(&ref_content);
+ let lang_sig = signature_of(&lang_content);
+
+ if ref_sig == lang_sig {
+ return Ok(());
+ }
+
+ let ref_lines: Vec<&str> = ref_content.lines().collect();
+ let lang_lines: Vec<&str> = lang_content.lines().collect();
+
+ let mut diffs = Vec::new();
+ let mut window = 0;
+ let max = ref_sig.len().max(lang_sig.len());
+ for i in 0..max {
+ let ref_tok = ref_sig.get(i);
+ let lang_tok = lang_sig.get(i);
+ if ref_tok == lang_tok {
+ continue;
+ }
+ if window >= 5 {
+ diffs.push(format!("... ({}-line window truncated)", max - i));
+ break;
+ }
+ window += 1;
+ let ref_line = ref_lines.get(i).copied().unwrap_or("<missing>");
+ let lang_line = lang_lines.get(i).copied().unwrap_or("<missing>");
+ diffs.push(format!("line {}", i + 1));
+ diffs.push(format!(
+ "expect `{}` {}",
+ token_label(ref_tok.map_or("<eof>", String::as_str)),
+ display_line(ref_line)
+ ));
+ diffs.push(format!(
+ "found `{}` {}",
+ token_label(lang_tok.map_or("<eof>", String::as_str)),
+ display_line(lang_line)
+ ));
+ if ref_sig.len() != lang_sig.len() && window >= 5 {
+ diffs.push(format!(
+ "note: reference has {} lines, translation has {} lines",
+ ref_sig.len(),
+ lang_sig.len()
+ ));
+ break;
+ }
+ }
+ if diffs.is_empty() {
+ diffs.push("signatures differ in length (see line count note)".to_string());
+ }
+ Err(diffs)
+}
+
+/// Builds the structural signature of a markdown file.
+fn signature_of(content: &str) -> Vec<String> {
+ let mut sig = Vec::new();
+ let mut in_fence = false;
+ let mut fence_lang = String::new();
+
+ for raw_line in content.lines() {
+ let line = raw_line.trim();
+
+ if in_fence {
+ if line.starts_with("```") {
+ in_fence = false;
+ sig.push(format!("F:{fence_lang}"));
+ } else if line.starts_with("@@@") {
+ sig.push("A".to_string());
+ } else if line.is_empty() {
+ sig.push("B".to_string());
+ } else {
+ sig.push("P".to_string());
+ }
+ continue;
+ }
+
+ if line.starts_with("```") {
+ in_fence = true;
+ fence_lang = line.trim_start_matches("```").trim().to_string();
+ sig.push(format!("F:{fence_lang}"));
+ } else if line.starts_with('#') {
+ let level = line.chars().take_while(|c| *c == '#').count();
+ sig.push(format!("H{level}"));
+ } else if line.starts_with("<h") || line.starts_with("</h") {
+ // HTML headings (e.g. `<h1 align="center">` / `</h1>`)
+ let level = line
+ .trim_start_matches(['<', '/'])
+ .chars()
+ .next()
+ .and_then(|c| c.to_digit(10))
+ .unwrap_or(1);
+ sig.push(format!("H{level}"));
+ } else if line.starts_with("@@@") {
+ sig.push("A".to_string());
+ } else if line.is_empty() {
+ sig.push("B".to_string());
+ } else if line.starts_with('>') {
+ sig.push("Q".to_string());
+ } else if is_list_line(line) {
+ sig.push("L".to_string());
+ } else {
+ sig.push("P".to_string());
+ }
+ }
+ sig
+}
+
+/// Human-readable label for a structural token.
+fn token_label(token: &str) -> String {
+ match token {
+ "B" => "blank".to_string(),
+ "A" => "@@@".to_string(),
+ "Q" => "quote".to_string(),
+ "L" => "list".to_string(),
+ "P" => "text".to_string(),
+ t if t.starts_with('H') => format!("heading-{}", &t[1..]),
+ t if t.starts_with("F:") => {
+ let lang = &t[2..];
+ if lang.is_empty() {
+ "fence".to_string()
+ } else {
+ format!("fence:{lang}")
+ }
+ }
+ _ => token.to_string(),
+ }
+}
+
+/// Renders a source line for display: blank lines become `<blank>`.
+fn display_line(line: &str) -> String {
+ if line.trim().is_empty() {
+ "<blank>".to_string()
+ } else {
+ truncate(line)
+ }
+}
+
+fn truncate(line: &str) -> String {
+ const MAX: usize = 60;
+ if line.chars().count() <= MAX {
+ line.to_string()
+ } else {
+ let cut: String = line.chars().take(MAX).collect();
+ format!("{cut}...")
+ }
+}
+
+fn is_list_line(line: &str) -> bool {
+ let trimmed = line.trim_start();
+ trimmed.starts_with("- ")
+ || trimmed.starts_with("* ")
+ || trimmed.starts_with("+ ")
+ || is_numbered_list(trimmed)
+}
+
+/// A numbered list item: `1. text`, `1) text`, `10. text`, ...
+fn is_numbered_list(line: &str) -> bool {
+ let digit_count = line.chars().take_while(char::is_ascii_digit).count();
+ if digit_count == 0 {
+ return false;
+ }
+ let rest = &line[digit_count..];
+ (rest.starts_with(". ") || rest.starts_with(") "))
+ && rest.chars().nth(1).is_some_and(|c| c == ' ' || c == '\t')
+}
diff --git a/mingling_ci/src/markdown/project.rs b/mingling_ci/src/markdown/project.rs
new file mode 100644
index 0000000..d781b7e
--- /dev/null
+++ b/mingling_ci/src/markdown/project.rs
@@ -0,0 +1,347 @@
+//! Model of a testable rust code block extracted from markdown: its dependency
+//! configuration (features + deps) and the code itself.
+
+use std::fmt::Write as _;
+use std::path::Path;
+
+/// A single testable `rust` code block, modeled as a test project.
+pub(crate) struct MarkdownTestProject {
+ pub features: Vec<String>,
+ pub deps: Vec<(String, String)>,
+ pub code: String,
+ pub is_build_time: bool,
+ pub has_main: bool,
+ pub has_gen_program: bool,
+ pub source_file: String,
+ pub line: usize,
+}
+
+impl MarkdownTestProject {
+ /// FNV-1a 64-bit hash over the dependency configuration (features + deps).
+ ///
+ /// Blocks with the same hash share one temporary crate and avoid redundant
+ /// recompilation. The input is sorted so the hash is stable.
+ #[must_use]
+ pub fn compute_hash(&self) -> String {
+ let mut features: Vec<&str> = self.features.iter().map(String::as_str).collect();
+ features.sort_unstable();
+ let mut dep_names: Vec<&str> = self.deps.iter().map(|(n, _)| n.as_str()).collect();
+ dep_names.sort_unstable();
+ let mut dep_versions: Vec<&str> = self.deps.iter().map(|(_, v)| v.as_str()).collect();
+ dep_versions.sort_unstable();
+ let mut deps: Vec<String> = self.deps.iter().map(|(n, v)| format!("{n}={v}")).collect();
+ deps.sort();
+
+ let canonical = format!(
+ "{}\n{}\n{}\n{}",
+ features.join(","),
+ dep_names.join(","),
+ dep_versions.join(","),
+ deps.join(",")
+ );
+
+ // FNV-1a 64-bit — stable across runs (no random seed).
+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
+ for &byte in canonical.as_bytes() {
+ hash ^= u64::from(byte);
+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
+ }
+ format!("{hash:016x}")
+ }
+}
+
+/// Parses all fenced `rust` blocks from markdown content.
+///
+/// Blocks marked `// NOT VERIFIED` are skipped.
+pub(crate) fn parse_markdown(content: &str, source_file: &str) -> Vec<MarkdownTestProject> {
+ let mut projects = Vec::new();
+ let lines: Vec<&str> = content.lines().collect();
+ let mut i = 0;
+ while i < lines.len() {
+ if lines[i].trim() == "```rust" {
+ if let Some(proj) = parse_block(&lines, i, source_file) {
+ projects.push(proj);
+ }
+ while i < lines.len() && lines[i].trim() != "```" {
+ i += 1;
+ }
+ }
+ i += 1;
+ }
+ projects
+}
+
+/// Parses a single code block starting at a `rust` fence line.
+fn parse_block(lines: &[&str], start: usize, source_file: &str) -> Option<MarkdownTestProject> {
+ let mut code_lines: Vec<String> = Vec::new();
+ let mut features: Vec<String> = Vec::new();
+ let mut not_verified = false;
+ let mut deps: Vec<(String, String)> = Vec::new();
+ let mut has_main = false;
+ let mut has_gen_program = false;
+ let mut is_build_time = false;
+
+ let mut idx = start + 1;
+ let mut in_header = true;
+
+ while idx < lines.len() {
+ let raw_line = lines[idx];
+ let trimmed = raw_line.trim();
+
+ if trimmed == "```" {
+ break;
+ }
+
+ // `@@@` lines: hidden in the rendered docs (filtered by a docsify
+ // plugin) but must still compile.
+ if let Some(stripped) = trimmed.strip_prefix("@@@") {
+ in_header = false;
+ let code = stripped.trim_start();
+ if code.contains("fn main") {
+ has_main = true;
+ }
+ if code.contains("gen_program!") {
+ has_gen_program = true;
+ }
+ code_lines.push(code.to_string());
+ idx += 1;
+ continue;
+ }
+
+ if in_header && trimmed == "// NOT VERIFIED" {
+ not_verified = true;
+ idx += 1;
+ continue;
+ }
+ if in_header && trimmed == "// BUILD TIME" {
+ is_build_time = true;
+ idx += 1;
+ continue;
+ }
+ if in_header && trimmed.starts_with("// ") {
+ if let Some(feat_str) = trimmed.strip_prefix("// Features:") {
+ let feat_str = feat_str.trim();
+ if feat_str.starts_with('[') && feat_str.ends_with(']') {
+ let inner = &feat_str[1..feat_str.len() - 1];
+ if !inner.is_empty() {
+ features = inner
+ .split(',')
+ .map(|s| s.trim().trim_matches('"').to_string())
+ .filter(|s| !s.is_empty())
+ .collect();
+ }
+ }
+ idx += 1;
+ continue;
+ }
+ if trimmed == "// Dependencies:" {
+ idx += 1;
+ while idx < lines.len() {
+ let next = lines[idx].trim();
+ if next == "```" {
+ break;
+ }
+ if let Some(dep_line) = next.strip_prefix("// ") {
+ if let Some((name, ver)) = dep_line.split_once(" = ") {
+ deps.push((
+ name.trim().to_string(),
+ ver.trim().trim_matches('"').to_string(),
+ ));
+ }
+ idx += 1;
+ } else {
+ break;
+ }
+ }
+ continue;
+ }
+ }
+
+ in_header = false;
+ if raw_line.contains("fn main") {
+ has_main = true;
+ }
+ if raw_line.contains("gen_program!") {
+ has_gen_program = true;
+ }
+ code_lines.push(raw_line.to_string());
+ idx += 1;
+ }
+
+ if code_lines.is_empty() || not_verified {
+ return None;
+ }
+
+ Some(MarkdownTestProject {
+ features,
+ deps,
+ code: code_lines.join("\n"),
+ is_build_time,
+ has_main,
+ has_gen_program,
+ source_file: source_file.to_string(),
+ line: start + 1,
+ })
+}
+
+/// Builds the extra `[dependencies]` entries declared by a block's
+/// `// Dependencies:` header comments.
+///
+/// Markdown blocks declare companion crates like this:
+///
+/// ```text
+/// // Dependencies:
+/// // serde = "1"
+/// // clap = "4"
+/// // tokio = { version = "1", features = ["full"] }
+/// ```
+///
+/// Each `name = value` pair becomes one dependency of the generated test
+/// crate (in addition to `mingling` itself), so doc blocks can freely use
+/// external crates without repeating the whole manifest.
+///
+/// # Special case: serde / clap
+///
+/// Doc blocks pervasively derive serialization and argument parsing:
+/// structural-renderer examples use `#[derive(Serialize)]`, the clap examples
+/// use `#[derive(Parser)]` — and those derives live behind the `derive`
+/// feature of `serde` / `clap`. Requiring every block to spell out
+/// `// serde = { version = "1", features = ["derive"] }` would be
+/// boilerplate repeated dozens of times, so the two crates automatically get
+/// `features = ["derive"]` appended.
+///
+/// Version values starting with `{` are inline tables (e.g. `tokio` with a
+/// `features` list above) and are passed through verbatim — they already
+/// carry their own features and must not be rewritten.
+fn build_extra_deps(proj: &MarkdownTestProject) -> String {
+ let mut extra_deps = String::new();
+ for (name, version) in &proj.deps {
+ if version.starts_with('{') {
+ // Inline table (path/features/…): the block already expressed its
+ // full dependency, so emit it unchanged.
+ let _ = writeln!(extra_deps, "{name} = {version}");
+ } else if name == "serde" || name == "clap" {
+ // serde/clap derive: `#[derive(Serialize, Deserialize)]` and
+ // `#[derive(Parser)]` are used everywhere in the docs; auto-enable
+ // the `derive` feature to keep blocks terse.
+ let _ = writeln!(
+ extra_deps,
+ "{name} = {{ version = \"{version}\", features = [\"derive\"] }}"
+ );
+ } else {
+ // Plain `name = "version"`.
+ let _ = writeln!(extra_deps, "{name} = \"{version}\"");
+ }
+ }
+ extra_deps
+}
+
+/// Generates the `Cargo.toml` for a project.
+///
+/// `manifest_path` is used to compute the relative path to the `mingling` crate.
+pub(crate) fn generate_cargo_toml(proj: &MarkdownTestProject, manifest_path: &Path) -> String {
+ let features_str = if proj.features.is_empty() {
+ String::new()
+ } else {
+ let feats: Vec<String> = proj.features.iter().map(|f| format!("\"{f}\"")).collect();
+ format!("features = [{}]", feats.join(", "))
+ };
+
+ let extra_deps = build_extra_deps(proj);
+
+ let mingling_path = find_mingling_relative_path(manifest_path);
+ let deps_section = if proj.features.is_empty() {
+ format!("[dependencies]\nmingling = {{ path = \"{mingling_path}\" }}\n{extra_deps}")
+ } else {
+ format!(
+ "[dependencies]\nmingling = {{ path = \"{mingling_path}\", {features_str} }}\n{extra_deps}"
+ )
+ };
+
+ // Build-time projects mirror the features into [build-dependencies] so
+ // build.rs sees the same feature set.
+ let build_deps_section = if proj.is_build_time {
+ let feats: Vec<String> = proj.features.iter().map(|f| format!("\"{f}\"")).collect();
+ let build_feats = if feats.is_empty() {
+ String::new()
+ } else {
+ format!("features = [{}]", feats.join(", "))
+ };
+ format!(
+ "\n[build-dependencies]\nmingling = {{ path = \"{mingling_path}\", {build_feats} }}\n"
+ )
+ } else {
+ String::new()
+ };
+
+ format!(
+ r#"[package]
+ name = "test-doc"
+ version = "0.0.0"
+ edition = "2024"
+
+{deps_section}{build_deps_section}
+[workspace]
+"#
+ )
+}
+
+/// Computes the relative path from a manifest's parent directory to `mingling`.
+///
+/// The process current directory is expected to be the project root.
+fn find_mingling_relative_path(manifest_path: &Path) -> String {
+ let manifest_dir = manifest_path
+ .parent()
+ .expect("manifest path has no parent directory");
+ let cwd = std::env::current_dir().expect("failed to get current directory");
+
+ let relative_to_root = manifest_dir.strip_prefix(&cwd).unwrap_or(manifest_dir);
+ let depth = relative_to_root.components().count();
+
+ let mut result = String::new();
+ for _ in 0..depth {
+ result.push_str("../");
+ }
+ result.push_str("mingling");
+ result
+}
+
+/// Generates `main.rs` for a project.
+///
+/// Automatically prepends `use mingling::prelude::*;` and appends `fn main() {}`
+/// and `gen_program!()` when the block does not provide them.
+pub(crate) fn generate_main_rs(proj: &MarkdownTestProject) -> String {
+ let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n");
+
+ if !proj.code.contains("use mingling::prelude::*;") {
+ output.push_str("#[allow(unused_imports)]\nuse mingling::prelude::*;\n\n");
+ }
+ output.push_str(&proj.code);
+ output.push('\n');
+
+ if !proj.has_main {
+ output.push_str("\nfn main() {}\n");
+ }
+ if !proj.has_gen_program {
+ output.push_str("\nmingling::macros::gen_program!();\n");
+ }
+ output
+}
+
+/// Generates `build.rs` for a build-time project: the code wrapped in
+/// `fn main() { }` unless the block already provides one.
+pub(crate) fn generate_build_rs(proj: &MarkdownTestProject) -> String {
+ let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n");
+ if proj.has_main {
+ output.push_str(&proj.code);
+ } else {
+ output.push_str("fn main() {\n");
+ for line in proj.code.lines() {
+ output.push_str(" ");
+ output.push_str(line);
+ output.push('\n');
+ }
+ output.push_str("}\n");
+ }
+ output
+}
diff --git a/mingling_ci/src/markdown/test.rs b/mingling_ci/src/markdown/test.rs
new file mode 100644
index 0000000..8ecf18d
--- /dev/null
+++ b/mingling_ci/src/markdown/test.rs
@@ -0,0 +1,152 @@
+//! Parallel execution of markdown test projects.
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use colored::Colorize;
+
+use crate::progress::task_progress_bar;
+
+use super::project::{
+ MarkdownTestProject, generate_build_rs, generate_cargo_toml, generate_main_rs,
+};
+
+/// Temporary root for the generated test crates.
+const TEMP_BASE: &str = ".temp/doc-test";
+
+/// Outcome of testing one code block.
+pub(crate) struct MarkdownBlockOutcome {
+ pub source_file: String,
+ pub line: usize,
+ pub ok: bool,
+ /// Failure detail; empty when `ok`.
+ pub output: String,
+}
+
+/// Runs the given projects in parallel.
+///
+/// Projects sharing a dependency hash share one temporary crate (written
+/// serially within the group); groups run in parallel. Progress is shown on
+/// stderr; failures print there too. Returns one outcome per block.
+pub(crate) async fn try_test_markdown_project(
+ projs: Vec<MarkdownTestProject>,
+) -> Vec<MarkdownBlockOutcome> {
+ // Group by dependency hash for crate sharing.
+ let mut groups: BTreeMap<String, Vec<MarkdownTestProject>> = BTreeMap::new();
+ for proj in projs {
+ groups.entry(proj.compute_hash()).or_default().push(proj);
+ }
+
+ let total: usize = groups.values().map(Vec::len).sum();
+ let pb = task_progress_bar(total, "Testing");
+ pb.set_message("blocks");
+
+ // One blocking task per group; blocks within a group are serial because
+ // they share the same crate directory.
+ let mut handles = Vec::new();
+ for (hash, blocks) in groups {
+ let pb = pb.clone();
+ handles.push(tokio::task::spawn_blocking(move || {
+ let crate_dir = PathBuf::from(TEMP_BASE).join(&hash);
+ let src_dir = crate_dir.join("src");
+ let manifest_path = crate_dir.join("Cargo.toml");
+ let cargo_toml = generate_cargo_toml(&blocks[0], &manifest_path);
+
+ let mut group_outcomes = Vec::new();
+ for proj in &blocks {
+ let label = format!("{}:{}", proj.source_file, proj.line);
+ pb.set_message(label.clone());
+
+ let main_rs = if proj.is_build_time {
+ generate_build_rs(proj)
+ } else {
+ generate_main_rs(proj)
+ };
+ let (ok, err) = build_block(
+ &src_dir,
+ &manifest_path,
+ &cargo_toml,
+ &main_rs,
+ proj.is_build_time,
+ );
+ pb.inc(1);
+
+ if !ok {
+ // Plain stderr: `pb.println` is swallowed on non-TTY (CI).
+ eprintln!(" {} {label}", "failed".bold().bright_red());
+ eprintln!(" {label} FAILED:\n{err}");
+ }
+ group_outcomes.push(MarkdownBlockOutcome {
+ source_file: proj.source_file.clone(),
+ line: proj.line,
+ ok,
+ output: err,
+ });
+ }
+ group_outcomes
+ }));
+ }
+
+ let mut all_outcomes = Vec::new();
+ for handle in handles {
+ if let Ok(group_outcomes) = handle.await {
+ all_outcomes.extend(group_outcomes);
+ }
+ }
+
+ pb.finish_and_clear();
+ all_outcomes
+}
+
+/// Writes the temporary crate files and runs `cargo check`.
+///
+/// When `is_build_time` is true, the content goes to `build.rs` with a stub
+/// `main.rs`; otherwise it goes to `src/main.rs`.
+fn build_block(
+ src_dir: &Path,
+ manifest_path: &Path,
+ cargo_toml: &str,
+ content: &str,
+ is_build_time: bool,
+) -> (bool, String) {
+ if let Err(e) = std::fs::create_dir_all(src_dir) {
+ return (false, format!("mkdir: {e}"));
+ }
+ if let Err(e) = std::fs::write(manifest_path, cargo_toml) {
+ return (false, format!("write Cargo.toml: {e}"));
+ }
+
+ if is_build_time {
+ let crate_dir = manifest_path
+ .parent()
+ .expect("manifest path has a parent directory");
+ if let Err(e) = std::fs::write(crate_dir.join("build.rs"), content) {
+ return (false, format!("write build.rs: {e}"));
+ }
+ if let Err(e) = std::fs::write(src_dir.join("main.rs"), "fn main() {}\n") {
+ return (false, format!("write main.rs: {e}"));
+ }
+ } else if let Err(e) = std::fs::write(src_dir.join("main.rs"), content) {
+ return (false, format!("write main.rs: {e}"));
+ }
+
+ let output = std::process::Command::new("cargo")
+ .args(["check", "--color=always", "--manifest-path"])
+ .arg(manifest_path)
+ .output();
+ match output {
+ Ok(output) if output.status.success() => (true, String::new()),
+ Ok(output) => {
+ let mut log = String::from_utf8_lossy(&output.stdout).into_owned();
+ log.push_str(&String::from_utf8_lossy(&output.stderr));
+ let lines: Vec<&str> = log.lines().collect();
+ let tail = &lines[lines.len().saturating_sub(20)..];
+ let exit = output
+ .status
+ .code()
+ .map_or_else(|| "?".to_string(), |c| c.to_string());
+ (false, format!("exit code {exit}\n{}", tail.join("\n")))
+ }
+ Err(e) => (false, format!("failed to run cargo: {e}")),
+ }
+}
diff --git a/mingling_ci/src/progress.rs b/mingling_ci/src/progress.rs
new file mode 100644
index 0000000..bd62ae1
--- /dev/null
+++ b/mingling_ci/src/progress.rs
@@ -0,0 +1,24 @@
+//! Shared task progress bar.
+
+use colored::Colorize;
+use indicatif::{ProgressBar, ProgressStyle};
+
+/// Creates a task progress bar with the CI's standard style.
+///
+/// `prefix` is the phase label shown before the bar, right-aligned to 12
+/// columns (e.g. `Building`, `Clippy`, `Testing`). The caller sets the
+/// initial message and drives the position.
+pub(crate) fn task_progress_bar(len: usize, prefix: &str) -> ProgressBar {
+ let padding = " ".repeat(12usize.saturating_sub(prefix.len()));
+ let styled_prefix = format!("{padding}{}", prefix.bold().bright_cyan());
+ let pb = ProgressBar::new(len as u64);
+ pb.set_style(
+ ProgressStyle::default_bar()
+ .template(&format!(
+ "{styled_prefix} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}"
+ ))
+ .unwrap()
+ .progress_chars("=> "),
+ );
+ pb
+}
diff --git a/mingling_ci/src/reporter.rs b/mingling_ci/src/reporter.rs
new file mode 100644
index 0000000..1a1ac08
--- /dev/null
+++ b/mingling_ci/src/reporter.rs
@@ -0,0 +1,208 @@
+//! Minimal log exporter for CI reports.
+//!
+//! Writes per-package results into `collect/{task}/{platform}/{package}.{ok|err}`
+//! so that the [`crate::cmd::collect_results`] command can assemble the final
+//! report. The task name is set once per CI phase via [`set_task`].
+
+use std::collections::HashMap;
+use std::fs;
+use std::path::Path;
+use std::sync::{LazyLock, Mutex};
+
+/// Root of the collected CI logs (relative to the repo root).
+pub const COLLECT_DIR: &str = "./.temp/reports/collect";
+
+/// Generated report output (relative to the repo root).
+pub const REPORT_PATH: &str = "./.temp/reports/result.md";
+
+/// The platform a package check ran on.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
+pub enum ReportPlatform {
+ Windows,
+ Linux,
+ MacOS,
+}
+
+impl ReportPlatform {
+ /// Directory name used under the task folder.
+ const fn dir_name(self) -> &'static str {
+ match self {
+ Self::Windows => "Windows",
+ Self::Linux => "Linux",
+ Self::MacOS => "MacOS",
+ }
+ }
+}
+
+/// The outcome of a package check.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ReportResult {
+ /// Check passed.
+ Ok,
+ /// Check failed, with the captured output.
+ Error(String),
+}
+
+/// Current task name (e.g. `Build-All`); set via [`set_task`].
+static CURRENT_TASK: Mutex<Option<String>> = Mutex::new(None);
+
+/// Pending success entries: `(item, location)`.
+type PendingOk = (String, String);
+
+/// Successful items pending a [`flush`], grouped by platform.
+static OK_BUFFER: LazyLock<Mutex<HashMap<ReportPlatform, Vec<PendingOk>>>> =
+ LazyLock::new(|| Mutex::new(HashMap::new()));
+
+/// Sets the task that subsequent [`export`] calls write under.
+///
+/// # Panics
+///
+/// Panics if the internal mutex is poisoned.
+pub fn set_task(task: &str) {
+ *CURRENT_TASK.lock().unwrap() = Some(task.to_string());
+}
+
+/// Exports one item result.
+///
+/// `item` and `location` are free-form strings chosen by the generator.
+/// Successes are buffered and written to the `ok` file by [`flush`]; failures
+/// write `{task}.{platform}.{item}.err` immediately (first line is the
+/// location). Errors are reported to stderr and otherwise ignored.
+///
+/// # Panics
+///
+/// Panics if the internal task mutex is poisoned.
+pub fn export(item: &str, location: &str, result: ReportResult) {
+ export_on(item, location, current_platform(), result);
+}
+
+/// The `ReportPlatform` for the currently compiling target.
+fn current_platform() -> ReportPlatform {
+ if cfg!(target_os = "windows") {
+ ReportPlatform::Windows
+ } else if cfg!(target_os = "macos") {
+ ReportPlatform::MacOS
+ } else {
+ ReportPlatform::Linux
+ }
+}
+
+/// Exports one item result for a specific platform.
+///
+/// `item` and `location` are free-form strings chosen by the generator.
+/// Successes are buffered and written to the `ok` file by [`flush`]; failures
+/// write `{task}.{platform}.{item}.err` immediately (first line is the
+/// location). Errors are reported to stderr and otherwise ignored.
+///
+/// # Panics
+///
+/// Panics if the internal task mutex is poisoned.
+pub fn export_on(item: &str, location: &str, platform: ReportPlatform, result: ReportResult) {
+ match result {
+ ReportResult::Ok => OK_BUFFER
+ .lock()
+ .unwrap()
+ .entry(platform)
+ .or_default()
+ .push((item.to_string(), location.to_string())),
+ ReportResult::Error(output) => write_err(item, location, platform, &output),
+ }
+}
+
+/// Writes buffered successes to `collect/{task}.{platform}.ok`, one `item` (or
+/// `item = location`) per line.
+///
+/// # Panics
+///
+/// Panics if the internal task mutex is poisoned.
+pub fn flush() {
+ let Some(task) = CURRENT_TASK.lock().unwrap().clone() else {
+ eprintln!("reporter: no current task; call reporter::set_task first");
+ return;
+ };
+
+ let buffered = std::mem::take(&mut *OK_BUFFER.lock().unwrap());
+ if buffered.is_empty() {
+ return;
+ }
+
+ if let Err(e) = fs::create_dir_all(COLLECT_DIR) {
+ eprintln!("reporter: failed to create {COLLECT_DIR}: {e}");
+ return;
+ }
+
+ for (platform, items) in buffered {
+ let lines: Vec<String> = items
+ .iter()
+ .map(|(item, location)| {
+ if location.is_empty() {
+ item.clone()
+ } else {
+ format!("{item} = {location}")
+ }
+ })
+ .collect();
+ let content = if lines.is_empty() {
+ String::new()
+ } else {
+ lines.join("\n") + "\n"
+ };
+ let platform_name = platform.dir_name();
+ let path = Path::new(COLLECT_DIR).join(format!("{task}.{platform_name}.ok"));
+ if let Err(e) = fs::write(&path, content) {
+ eprintln!("reporter: failed to write {}: {e}", path.display());
+ }
+ }
+}
+
+/// Writes a failure entry to `collect/{task}.{platform}.{item}.err`, with the
+/// location as the first line (empty when unknown).
+fn write_err(item: &str, location: &str, platform: ReportPlatform, output: &str) {
+ let Some(task) = CURRENT_TASK.lock().unwrap().clone() else {
+ eprintln!("reporter: no current task; call reporter::set_task first");
+ return;
+ };
+
+ if let Err(e) = fs::create_dir_all(COLLECT_DIR) {
+ eprintln!("reporter: failed to create {COLLECT_DIR}: {e}");
+ return;
+ }
+
+ let platform_name = platform.dir_name();
+ let path = Path::new(COLLECT_DIR).join(format!("{task}.{platform_name}.{item}.err"));
+ if let Err(e) = fs::write(&path, format!("{location}\n{output}")) {
+ eprintln!("reporter: failed to write {}: {e}", path.display());
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn export_writes_ok_and_err_files() {
+ set_task("reporter-test");
+ let platform_name = current_platform().dir_name();
+ let ok_path = Path::new(COLLECT_DIR).join(format!("reporter-test.{platform_name}.ok"));
+ let err_path =
+ Path::new(COLLECT_DIR).join(format!("reporter-test.{platform_name}.pkg-b.err"));
+ fs::remove_file(&ok_path).ok();
+ fs::remove_file(&err_path).ok();
+
+ export("pkg-a", "./pkg-a", ReportResult::Ok);
+ export("pkg-b", "./pkg-b", ReportResult::Error("boom".to_string()));
+ export("pkg-c", "", ReportResult::Ok); // no location
+ flush();
+
+ assert!(ok_path.is_file());
+ assert_eq!(
+ fs::read_to_string(&ok_path).unwrap(),
+ "pkg-a = ./pkg-a\npkg-c\n"
+ );
+ assert!(err_path.is_file());
+ assert_eq!(fs::read_to_string(&err_path).unwrap(), "./pkg-b\nboom");
+
+ fs::remove_file(ok_path).ok();
+ fs::remove_file(err_path).ok();
+ }
+}
diff --git a/mingling_ci/src/res.rs b/mingling_ci/src/res.rs
new file mode 100644
index 0000000..54ed503
--- /dev/null
+++ b/mingling_ci/src/res.rs
@@ -0,0 +1,14 @@
+mod collect_logs;
+pub use collect_logs::*;
+
+mod crate_config;
+pub use crate_config::*;
+
+mod features;
+pub use features::*;
+
+mod manifests;
+pub use manifests::*;
+
+mod print;
+pub use print::*;
diff --git a/mingling_ci/src/res/collect_logs.rs b/mingling_ci/src/res/collect_logs.rs
new file mode 100644
index 0000000..6017168
--- /dev/null
+++ b/mingling_ci/src/res/collect_logs.rs
@@ -0,0 +1,203 @@
+//! IO side of the report command: reads the collect directory once and keeps
+//! the parsed data in a resource, so chains only do computation.
+
+use std::collections::BTreeMap;
+
+use mingling::{Program, macros::program_setup};
+
+use crate::ThisProgram;
+use crate::reporter::COLLECT_DIR;
+
+/// Git commit date and short hash for the report.
+#[derive(Default, Clone, Debug)]
+pub struct GitInfo {
+ pub date: String,
+ pub commit_hash: String,
+}
+
+/// Parsed contents of the collect directory.
+#[derive(Default, Clone)]
+pub struct ResCollectLogs {
+ /// `(task, item) -> os -> ok`
+ pub statuses: BTreeMap<(String, String), BTreeMap<String, bool>>,
+ /// `(task, item) -> location`
+ pub locations: BTreeMap<(String, String), String>,
+ /// `(task, os, item) -> stripped error output (location line removed)`
+ pub err_outputs: BTreeMap<(String, String, String), String>,
+ pub git: GitInfo,
+}
+
+impl ResCollectLogs {
+ /// Reads the flat `collect/` directory — aggregate `{task}.{os}.ok` files
+ /// (`item` or `item = location` per line) and per-item
+ /// `{task}.{os}.{item}.err` files (first line is the location) — plus the
+ /// git info.
+ #[must_use]
+ pub fn read() -> Self {
+ let mut logs = Self::default();
+
+ if let Ok(entries) = std::fs::read_dir(COLLECT_DIR) {
+ for entry in entries.flatten() {
+ let file_name = entry.file_name().to_string_lossy().into_owned();
+ if let Some((task, os)) = parse_ok_name(&file_name) {
+ // Aggregate success file: `item` or `item = location` per line.
+ if let Ok(content) = std::fs::read_to_string(entry.path()) {
+ for line in content.lines().filter(|l| !l.is_empty()) {
+ let (item, location) = line
+ .split_once('=')
+ .map_or((line, ""), |(name, loc)| (name.trim(), loc.trim()));
+ logs.statuses
+ .entry((task.clone(), item.to_string()))
+ .or_default()
+ .insert(os.clone(), true);
+ logs.locations
+ .insert((task.clone(), item.to_string()), location.to_string());
+ }
+ }
+ } else if let Some((task, os, item)) = parse_err_name(&file_name) {
+ let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
+ let mut lines = content.splitn(2, '\n');
+ let location = lines.next().unwrap_or_default().to_string();
+ let output = lines.next().unwrap_or_default().to_string();
+ logs.statuses
+ .entry((task.clone(), item.clone()))
+ .or_default()
+ .insert(os.clone(), false);
+ logs.locations
+ .insert((task.clone(), item.clone()), location);
+ logs.err_outputs
+ .insert((task, os, item), strip_ansi(&output));
+ }
+ }
+ }
+
+ logs.git = git_info();
+ logs
+ }
+}
+
+/// Parses a `{task}.{os}.ok` file name.
+fn parse_ok_name(file_name: &str) -> Option<(String, String)> {
+ let name = file_name.strip_suffix(".ok")?;
+ let mut parts = name.rsplitn(2, '.');
+ let os = parts.next()?.to_string();
+ let task = parts.next()?.to_string();
+ Some((task, os))
+}
+
+/// Parses a `{task}.{os}.{package}.err` file name.
+///
+/// Split from the right: package names cannot contain dots (cargo forbids
+/// them), while task names may.
+fn parse_err_name(file_name: &str) -> Option<(String, String, String)> {
+ let name = file_name.strip_suffix(".err")?;
+ let mut parts = name.rsplitn(3, '.');
+ let package = parts.next()?.to_string();
+ let os = parts.next()?.to_string();
+ let task = parts.next()?.to_string();
+ Some((task, os, package))
+}
+
+#[program_setup]
+pub fn report_setup(p: &mut Program<ThisProgram>) {
+ p.with_resource(ResCollectLogs::read());
+}
+
+/// Strips ANSI escape sequences from `input`.
+///
+/// Handles CSI (`ESC [ ...`), OSC (`ESC ] ...` terminated by BEL or `ESC \`)
+/// and other single-character escapes, while preserving UTF-8 text. Literal
+/// `^[` (caret-bracket, produced by some terminal captures) is normalized to
+/// `ESC` first.
+fn strip_ansi(input: &str) -> String {
+ // Normalize literal `^[` (0x5E 0x5B) to a real ESC byte.
+ let normalized = input.replace("^[", "\u{1b}");
+ let mut out = String::with_capacity(normalized.len());
+ let mut rest = normalized.as_str();
+ while let Some(idx) = rest.find('\u{1b}') {
+ out.push_str(&rest[..idx]);
+ rest = &rest[idx..];
+ rest = &rest[ansi_len(rest)..];
+ }
+ out.push_str(rest);
+ out
+}
+
+/// Byte length of the ANSI escape sequence starting at `s[0]` (`s[0]` is `ESC`).
+fn ansi_len(s: &str) -> usize {
+ let b = s.as_bytes();
+ match b.get(1) {
+ Some(b'[') => {
+ // CSI: `ESC [` params/intermediates (0x20-0x3F) then a final byte (0x40-0x7E).
+ let mut i = 2;
+ while i < b.len() {
+ let byte = b[i];
+ i += 1;
+ if (0x40..=0x7E).contains(&byte) {
+ break;
+ }
+ if !(0x20..=0x3F).contains(&byte) {
+ break;
+ }
+ }
+ i
+ }
+ Some(b']') => {
+ // OSC: `ESC ]` ... terminated by BEL (0x07) or `ESC \`.
+ let mut i = 2;
+ while i < b.len() {
+ let byte = b[i];
+ i += 1;
+ if byte == 0x07 {
+ break;
+ }
+ if byte == 0x1b {
+ if b.get(i) == Some(&b'\\') {
+ i += 1;
+ }
+ break;
+ }
+ }
+ i
+ }
+ Some(_) => 2.min(b.len()),
+ None => 1,
+ }
+}
+
+/// Commit date (`YYYY-MM-DD`) and short commit hash; empty on failure.
+fn git_info() -> GitInfo {
+ let run = |args: &[&str]| {
+ std::process::Command::new("git")
+ .args(args)
+ .output()
+ .ok()
+ .filter(|o| o.status.success())
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+ .unwrap_or_default()
+ };
+ GitInfo {
+ date: run(&["log", "-1", "--format=%cs"]),
+ commit_hash: run(&["rev-parse", "--short", "HEAD"]),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::strip_ansi;
+
+ #[test]
+ fn strips_csi_and_osc_and_literal_caret() {
+ let input =
+ "\u{1b}[1m\u{1b}[92mok\u{1b}[0m \u{1b}]8;;https://x\u{1b}\\done\u{1b}]8;;\u{1b}\\\n";
+ assert_eq!(strip_ansi(input), "ok done\n");
+
+ // Literal `^[` (caret-bracket) captured by some terminals.
+ assert_eq!(strip_ansi("^[[31mred^[[0m"), "red");
+ }
+
+ #[test]
+ fn preserves_utf8() {
+ assert_eq!(strip_ansi("你好\u{1b}[1m世界!\u{1b}[0m"), "你好世界!");
+ }
+}
diff --git a/mingling_ci/src/res/crate_config.rs b/mingling_ci/src/res/crate_config.rs
new file mode 100644
index 0000000..b20e83d
--- /dev/null
+++ b/mingling_ci/src/res/crate_config.rs
@@ -0,0 +1,79 @@
+use std::collections::HashMap;
+use std::path::Path;
+
+use mingling::{Program, macros::program_setup};
+
+use crate::ThisProgram;
+use crate::res::{Manifests, ResFeatureList};
+
+/// Per-crate CI overrides from `mingling-ci.toml` (optional, crate root).
+///
+/// Currently only `[test] command` is read; `clippy.command` / `build.command`
+/// will follow the same shape.
+#[derive(Default, Clone)]
+pub struct ResCrateConfig {
+ /// Package name -> test command argv (with `<<<features>>>` expanded).
+ test_commands: HashMap<String, Vec<String>>,
+}
+
+impl ResCrateConfig {
+ /// The configured `[test] command` for a package, if any.
+ #[must_use]
+ pub fn test_command(&self, package: &str) -> Option<&[String]> {
+ self.test_commands.get(package).map(Vec::as_slice)
+ }
+}
+
+#[program_setup]
+pub fn crate_config_setup(p: &mut Program<ThisProgram>) {
+ let features = p
+ .res::<ResFeatureList>()
+ .map(|f| f.list.clone())
+ .unwrap_or_default();
+ let joined_features = features.join(",");
+
+ let Some(manifests) = p.res::<Manifests>() else {
+ return;
+ };
+
+ let mut test_commands = HashMap::new();
+ for (name, manifest_path) in &manifests.package_dirs {
+ let config_path = manifest_path
+ .parent()
+ .unwrap_or_else(|| Path::new("."))
+ .join("mingling-ci.toml");
+
+ let Ok(content) = std::fs::read_to_string(&config_path) else {
+ continue;
+ };
+
+ let Ok(table) = content.parse::<toml::Value>() else {
+ continue;
+ };
+
+ let Some(command) = table
+ .get("test")
+ .and_then(|t| t.get("command"))
+ .and_then(|c| c.as_array())
+ else {
+ continue;
+ };
+
+ let argv: Vec<String> = command
+ .iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect();
+
+ if argv.is_empty() {
+ continue;
+ }
+
+ let argv = argv
+ .into_iter()
+ .map(|arg| arg.replace("<<<features>>>", &joined_features))
+ .collect();
+ test_commands.insert(name.clone(), argv);
+ }
+
+ p.with_resource(ResCrateConfig { test_commands });
+}
diff --git a/mingling_ci/src/res/features.rs b/mingling_ci/src/res/features.rs
new file mode 100644
index 0000000..8009514
--- /dev/null
+++ b/mingling_ci/src/res/features.rs
@@ -0,0 +1,47 @@
+use mingling::{Program, macros::program_setup};
+
+use crate::ThisProgram;
+
+/// Manifest that declares the documented feature list.
+///
+/// Path is relative to the repo root (the CI's working directory).
+const FEATURES_MANIFEST: &str = "./mingling/Cargo.toml";
+
+/// The docs.rs feature list of `mingling`, the single source of truth for the
+/// feature combinations used by CI checks.
+#[derive(Default, Clone)]
+pub struct ResFeatureList {
+ pub list: Vec<String>,
+}
+
+#[program_setup]
+pub fn features_setup(p: &mut Program<ThisProgram>) {
+ p.with_resource(ResFeatureList {
+ list: docs_rs_features(),
+ });
+}
+
+/// Reads `[package.metadata.docs.rs].features` from `mingling/Cargo.toml`.
+#[must_use]
+fn docs_rs_features() -> Vec<String> {
+ let Ok(content) = std::fs::read_to_string(FEATURES_MANIFEST) else {
+ return Vec::new();
+ };
+ let Ok(toml_value) = content.parse::<toml::Value>() else {
+ return Vec::new();
+ };
+ toml_value
+ .get("package")
+ .and_then(|p| p.get("metadata"))
+ .and_then(|m| m.get("docs"))
+ .and_then(|d| d.get("rs"))
+ .and_then(|rs| rs.get("features"))
+ .and_then(|f| f.as_array())
+ .map(|features| {
+ features
+ .iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default()
+}
diff --git a/mingling_ci/src/res/manifests.rs b/mingling_ci/src/res/manifests.rs
new file mode 100644
index 0000000..91836d6
--- /dev/null
+++ b/mingling_ci/src/res/manifests.rs
@@ -0,0 +1,103 @@
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use mingling::{Program, macros::program_setup};
+
+use crate::ThisProgram;
+
+/// Directories whose manifests are excluded from CI checks.
+///
+/// Path is relative to the crate source file (`mingling_ci/src/res/`).
+const IGNORED_DIRS_FILE: &str = include_str!("../../../.config/ci-ignored-dirs.txt");
+
+/// All `Cargo.toml` manifests the CI will check.
+#[derive(Default, Clone)]
+pub struct Manifests {
+ pub path: Vec<PathBuf>,
+ /// Package name -> its manifest path.
+ pub package_dirs: HashMap<String, PathBuf>,
+}
+
+#[program_setup]
+pub fn manifests_setup(p: &mut Program<ThisProgram>) {
+ let path = cargo_tomls();
+ let package_dirs = path.iter().map(|p| (package_name(p), p.clone())).collect();
+ p.with_resource(Manifests { path, package_dirs });
+}
+
+/// Recursively collects every `Cargo.toml` under the current directory,
+/// skipping the legacy `.run` CI directory and any directory listed in
+/// `.config/ci-ignored-dirs.txt`.
+#[must_use]
+fn cargo_tomls() -> Vec<PathBuf> {
+ let ignored = ignored_dirs();
+ let mut cargo_tomls = Vec::new();
+ let mut dirs = vec![PathBuf::from(".")];
+ while let Some(dir) = dirs.pop() {
+ if is_ignored(&dir.to_string_lossy(), &ignored) {
+ continue;
+ }
+ if let Ok(entries) = std::fs::read_dir(&dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ // Skip the legacy `.run` CI directory
+ if path.file_name().and_then(|n| n.to_str()) == Some(".run") {
+ continue;
+ }
+ dirs.push(path);
+ } else if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
+ cargo_tomls.push(path);
+ }
+ }
+ }
+ }
+ cargo_tomls
+}
+
+/// Parses `.config/ci-ignored-dirs.txt` into directory prefixes:
+/// non-empty lines that do not start with `#`, with the trailing `/` stripped
+/// (e.g. `./.temp/` → `./.temp`).
+fn ignored_dirs() -> Vec<String> {
+ IGNORED_DIRS_FILE
+ .lines()
+ .map(str::trim)
+ .filter(|line| !line.is_empty() && !line.starts_with('#'))
+ .map(|line| line.trim_end_matches('/').to_string())
+ .collect()
+}
+
+/// Whether `path` (a walk directory, e.g. `./.temp` or `./examples`) is inside
+/// one of the ignored directories.
+fn is_ignored(path: &str, ignored: &[String]) -> bool {
+ ignored.iter().any(|dir| {
+ path.strip_prefix(dir.as_str())
+ .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
+ })
+}
+
+/// Extracts the package name from a `Cargo.toml`.
+///
+/// Falls back to the parent directory name (e.g. `mingling_core/Cargo.toml` →
+/// `mingling_core`, workspace root → `(root)`), matching the legacy CI.
+fn package_name(path: &Path) -> String {
+ let fallback = || {
+ path.parent()
+ .and_then(|p| p.file_name())
+ .and_then(|n| n.to_str())
+ .unwrap_or("(root)")
+ .to_string()
+ };
+
+ let Ok(content) = std::fs::read_to_string(path) else {
+ return fallback();
+ };
+ let Ok(toml_value) = content.parse::<toml::Table>() else {
+ return fallback();
+ };
+ toml_value
+ .get("package")
+ .and_then(|p| p.get("name"))
+ .and_then(|n| n.as_str())
+ .map_or_else(fallback, str::to_string)
+}
diff --git a/mingling_ci/src/res/print.rs b/mingling_ci/src/res/print.rs
new file mode 100644
index 0000000..9d844a6
--- /dev/null
+++ b/mingling_ci/src/res/print.rs
@@ -0,0 +1,174 @@
+use colored::Colorize;
+use mingling::config::ErrorOutput;
+use mingling::hook::ProgramHook;
+use mingling::{Program, macros::program_setup};
+use mingling::{StringVec, this};
+
+use crate::ThisProgram;
+
+#[program_setup]
+pub fn print_setup(p: &mut Program<ThisProgram>) {
+ p.with_resource(CargoError::default());
+ p.with_resource(CargoWarn::default());
+ p.with_resource(CargoHelp::default());
+ p.with_resource(CargoStatus::default());
+
+ p.with_hook(ProgramHook::empty().on_begin::<_, ()>(move |_| {
+ let p = this::<ThisProgram>();
+ let silence_err = p.stdout_setting.error_output == ErrorOutput::Hide;
+
+ p.modify_res(|r: &mut CargoError| r.silence = silence_err);
+ p.modify_res(|r: &mut CargoWarn| r.silence = silence_err);
+ p.modify_res(|r: &mut CargoHelp| r.silence = silence_err);
+ p.modify_res(|r: &mut CargoStatus| r.silence = silence_err);
+ }));
+}
+
+#[derive(Default, Clone)]
+pub struct CargoError {
+ silence: bool,
+}
+
+impl MessagePrinter for CargoError {
+ fn format(&self, msg: impl Into<StringVec>) -> String {
+ format!("{}: {}", "error".bold().bright_red(), msg.into().join(""))
+ }
+
+ fn std_mode(&self) -> StandardOutMode {
+ if self.silence {
+ StandardOutMode::Silence
+ } else {
+ StandardOutMode::Error
+ }
+ }
+}
+
+#[derive(Default, Clone)]
+pub struct CargoWarn {
+ silence: bool,
+}
+
+impl MessagePrinter for CargoWarn {
+ fn format(&self, msg: impl Into<StringVec>) -> String {
+ format!("{}: {}", "warning".bright_yellow(), msg.into().join(""))
+ }
+
+ fn std_mode(&self) -> StandardOutMode {
+ if self.silence {
+ StandardOutMode::Silence
+ } else {
+ StandardOutMode::Error
+ }
+ }
+}
+
+#[derive(Default, Clone)]
+pub struct CargoHelp {
+ silence: bool,
+}
+
+impl MessagePrinter for CargoHelp {
+ fn format(&self, msg: impl Into<StringVec>) -> String {
+ format!("{}: {}", "help".bright_white(), msg.into().join(""))
+ }
+
+ fn std_mode(&self) -> StandardOutMode {
+ if self.silence {
+ StandardOutMode::Silence
+ } else {
+ StandardOutMode::Error
+ }
+ }
+}
+
+#[derive(Default, Clone)]
+pub struct CargoStatus {
+ silence: bool,
+}
+
+impl MessagePrinter for CargoStatus {
+ fn format(&self, msg: impl Into<StringVec>) -> String {
+ let parts: Vec<String> = msg.into().to_vec();
+ let first = if parts.is_empty() {
+ String::new()
+ } else {
+ parts[0].trim().to_string()
+ };
+
+ let (prefix, content) = if first.is_empty() {
+ // Empty: fall back to Info with full message
+ ("Info".to_string(), parts.join(" "))
+ } else if first.chars().count() == 1 {
+ // Single character: prefix is Info, entire message is content
+ ("Info".to_string(), parts.join(" "))
+ } else if first.chars().count() <= 12 {
+ // Single part that is a status prefix (no message after it)
+ if parts.len() == 1 {
+ ("Info".to_string(), first)
+ } else {
+ // First part is a status prefix, remaining parts are the message
+ let content = parts[1..].join(" ").trim_start().to_string();
+ (first, content)
+ }
+ } else {
+ // First part too long: all is message, fall back to Info
+ ("Info".to_string(), parts.join(" "))
+ };
+
+ let padding = " ".repeat(12usize.saturating_sub(prefix.chars().count()));
+
+ format!(
+ "{}{} {}",
+ padding,
+ prefix.bold().bright_green(),
+ content.trim()
+ )
+ }
+
+ fn std_mode(&self) -> StandardOutMode {
+ if self.silence {
+ StandardOutMode::Silence
+ } else {
+ StandardOutMode::Out
+ }
+ }
+}
+
+pub trait MessagePrinter {
+ #[doc(hidden)]
+ fn println(&self, msg: impl Into<StringVec>) {
+ match self.std_mode() {
+ StandardOutMode::Out => println!("{}", self.format(msg)),
+ StandardOutMode::Error => eprintln!("{}", self.format(msg)),
+ StandardOutMode::Silence => {}
+ }
+ }
+
+ #[doc(hidden)]
+ fn print(&self, msg: impl Into<StringVec>) {
+ match self.std_mode() {
+ StandardOutMode::Out => print!("{}", self.format(msg)),
+ StandardOutMode::Error => eprint!("{}", self.format(msg)),
+ StandardOutMode::Silence => {}
+ }
+ }
+
+ /// Formats the message string before output.
+ fn format(&self, msg: impl Into<StringVec>) -> String;
+
+ /// Returns the standard output mode (stdout or stderr).
+ fn std_mode(&self) -> StandardOutMode;
+}
+
+/// Specifies where standard output messages should be directed.
+///
+/// This enum determines whether messages are printed to stdout, stderr, or suppressed entirely.
+#[repr(u8)]
+pub enum StandardOutMode {
+ /// Print messages to standard output (stdout).
+ Out,
+ /// Print messages to standard error (stderr).
+ Error,
+ /// Suppress all output.
+ Silence,
+}
diff --git a/mingling_ci/src/task.rs b/mingling_ci/src/task.rs
new file mode 100644
index 0000000..a42e458
--- /dev/null
+++ b/mingling_ci/src/task.rs
@@ -0,0 +1,9 @@
+pub(crate) mod cmd_build_check;
+pub(crate) mod cmd_clippy_check;
+pub(crate) mod cmd_docs_check;
+pub(crate) mod cmd_example_check;
+pub(crate) mod cmd_markdown_check;
+pub(crate) mod cmd_markdown_compare;
+pub(crate) mod cmd_test;
+pub(crate) mod run;
+
diff --git a/mingling_ci/src/task/cmd_build_check.rs b/mingling_ci/src/task/cmd_build_check.rs
new file mode 100644
index 0000000..f67fe2e
--- /dev/null
+++ b/mingling_ci/src/task/cmd_build_check.rs
@@ -0,0 +1,47 @@
+use std::ffi::OsString;
+use std::path::Path;
+
+use mingling::{
+ Grouped, Routable,
+ macros::{buffer, command, renderer},
+ res::ResExitCode,
+};
+
+use crate::Next;
+use crate::res::Manifests;
+use crate::task::run::{location, run_parallel_checks};
+
+#[command(node = "build-check")]
+pub async fn build_check(manifests: &Manifests) -> Next {
+ let tasks = manifests
+ .package_dirs
+ .iter()
+ .map(|(name, path)| (name.clone(), location(path), build_args(path)))
+ .collect();
+ let fail_count = run_parallel_checks("Build-Check", "Building", tasks).await;
+ ResultBuildCheck { fail_count }.to_chain()
+}
+
+/// `cargo build --manifest-path <path>`
+fn build_args(path: &Path) -> Vec<OsString> {
+ vec![
+ "cargo".into(),
+ "build".into(),
+ "--manifest-path".into(),
+ path.as_os_str().to_os_string(),
+ ]
+}
+
+/// Number of packages that failed to build.
+#[derive(Grouped)]
+pub struct ResultBuildCheck {
+ pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when any build failed.
+#[renderer(buffer)]
+pub fn render_build_check(r: ResultBuildCheck, exit_code: &mut ResExitCode) {
+ if r.fail_count > 0 {
+ exit_code.exit_code = 1;
+ }
+}
diff --git a/mingling_ci/src/task/cmd_clippy_check.rs b/mingling_ci/src/task/cmd_clippy_check.rs
new file mode 100644
index 0000000..a0dd46e
--- /dev/null
+++ b/mingling_ci/src/task/cmd_clippy_check.rs
@@ -0,0 +1,50 @@
+use std::ffi::OsString;
+use std::path::Path;
+
+use mingling::{
+ Grouped, Routable,
+ macros::{buffer, command, renderer},
+ res::ResExitCode,
+};
+
+use crate::Next;
+use crate::res::Manifests;
+use crate::task::run::{location, run_parallel_checks};
+
+#[command(node = "clippy-check")]
+pub async fn clippy_check(manifests: &Manifests) -> Next {
+ let tasks = manifests
+ .package_dirs
+ .iter()
+ .map(|(name, path)| (name.clone(), location(path), clippy_args(path)))
+ .collect();
+ let fail_count = run_parallel_checks("Clippy-Check", "Clippy", tasks).await;
+ ResultClippyCheck { fail_count }.to_chain()
+}
+
+/// `cargo clippy --manifest-path <path> -- -D warnings`
+fn clippy_args(path: &Path) -> Vec<OsString> {
+ vec![
+ "cargo".into(),
+ "clippy".into(),
+ "--manifest-path".into(),
+ path.as_os_str().to_os_string(),
+ "--".into(),
+ "-D".into(),
+ "warnings".into(),
+ ]
+}
+
+/// Number of packages that failed clippy.
+#[derive(Grouped)]
+pub struct ResultClippyCheck {
+ pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when any clippy check failed.
+#[renderer(buffer)]
+pub fn render_clippy_check(r: ResultClippyCheck, exit_code: &mut ResExitCode) {
+ if r.fail_count > 0 {
+ exit_code.exit_code = 1;
+ }
+}
diff --git a/mingling_ci/src/task/cmd_docs_check.rs b/mingling_ci/src/task/cmd_docs_check.rs
new file mode 100644
index 0000000..3a77d4d
--- /dev/null
+++ b/mingling_ci/src/task/cmd_docs_check.rs
@@ -0,0 +1,44 @@
+use std::ffi::OsString;
+
+use mingling::{
+ Grouped, Routable,
+ macros::{buffer, command, renderer},
+ res::ResExitCode,
+};
+
+use crate::Next;
+use crate::res::ResFeatureList;
+use crate::task::run::run_parallel_checks;
+
+#[command(node = "docs-check")]
+pub async fn docs_check(features: &ResFeatureList) -> Next {
+ let args = vec![
+ OsString::from("cargo"),
+ OsString::from("rustdoc"),
+ OsString::from("--features"),
+ OsString::from(features.list.join(",")),
+ OsString::from("-p"),
+ OsString::from("mingling"),
+ OsString::from("--"),
+ OsString::from("-D"),
+ OsString::from("warnings"),
+ ];
+ let tasks = vec![("mingling".to_string(), "./mingling".to_string(), args)];
+ let fail_count = run_parallel_checks("Docs-Check", "Docs", tasks).await;
+
+ ResultDocsCheck { fail_count }.to_chain()
+}
+
+/// Number of failed doc builds (0 or 1).
+#[derive(Grouped)]
+pub struct ResultDocsCheck {
+ pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when the doc build failed.
+#[renderer(buffer)]
+pub fn render_docs_check(r: ResultDocsCheck, exit_code: &mut ResExitCode) {
+ if r.fail_count > 0 {
+ exit_code.exit_code = 1;
+ }
+}
diff --git a/mingling_ci/src/task/cmd_example_check.rs b/mingling_ci/src/task/cmd_example_check.rs
new file mode 100644
index 0000000..1b9f440
--- /dev/null
+++ b/mingling_ci/src/task/cmd_example_check.rs
@@ -0,0 +1,69 @@
+use colored::Colorize;
+use mingling::{
+ Grouped, Routable,
+ macros::{buffer, command, renderer},
+ res::ResExitCode,
+};
+
+use crate::Next;
+use crate::examples::{check_example, load_test_configs};
+use crate::progress::task_progress_bar;
+use crate::reporter::{self, ReportResult};
+
+#[command(node = "example-check")]
+pub async fn example_check() -> Next {
+ reporter::set_task("Example-Check");
+
+ let configs = load_test_configs();
+ let total = configs.len();
+ let pb = task_progress_bar(total, "Testing");
+ pb.set_message("examples");
+
+ // One blocking task per example: build + run its test cases.
+ let mut handles = Vec::new();
+ for example in configs {
+ handles.push(tokio::task::spawn_blocking(move || check_example(example)));
+ }
+
+ let mut fail_count = 0;
+ for handle in handles {
+ let Ok(outcome) = handle.await else {
+ continue;
+ };
+ pb.set_message(outcome.name.clone());
+ pb.inc(1);
+
+ if outcome.ok {
+ reporter::export(&outcome.name, &outcome.location, ReportResult::Ok);
+ } else {
+ fail_count += 1;
+ // Plain stderr: `pb.println` is swallowed on non-TTY (CI).
+ eprintln!(" {} {}", "failed".bright_red(), outcome.name);
+ eprintln!(" {}", outcome.output);
+ reporter::export(
+ &outcome.name,
+ &outcome.location,
+ ReportResult::Error(outcome.output),
+ );
+ }
+ }
+
+ pb.finish_and_clear();
+ reporter::flush();
+
+ ResultExampleCheck { fail_count }.to_chain()
+}
+
+/// Number of examples that failed to build or pass their tests.
+#[derive(Grouped)]
+pub struct ResultExampleCheck {
+ pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when any example failed.
+#[renderer(buffer)]
+pub fn render_example_check(r: ResultExampleCheck, exit_code: &mut ResExitCode) {
+ if r.fail_count > 0 {
+ exit_code.exit_code = 1;
+ }
+}
diff --git a/mingling_ci/src/task/cmd_markdown_check.rs b/mingling_ci/src/task/cmd_markdown_check.rs
new file mode 100644
index 0000000..2408636
--- /dev/null
+++ b/mingling_ci/src/task/cmd_markdown_check.rs
@@ -0,0 +1,192 @@
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use just_fmt::snake_case;
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{buffer, command, renderer},
+ res::ResExitCode,
+};
+
+use crate::Next;
+use crate::markdown::project::parse_markdown;
+use crate::markdown::test::{MarkdownBlockOutcome, try_test_markdown_project};
+use crate::reporter::{self, ReportResult};
+use crate::res::{CargoError, MessagePrinter};
+
+const VERIFIED_DOCS: &str = ".config/verified-docs.toml";
+
+#[command(node = "markdown-check")]
+pub async fn markdown_check(args: Vec<String>) -> Next {
+ let Some(path_str) = args.first() else {
+ return ErrorMarkdownArgs("missing <path> argument".to_string()).to_chain();
+ };
+ let path =
+ std::env::current_dir().map_or_else(|_| PathBuf::from(path_str), |cwd| cwd.join(path_str));
+ if !path.is_file() {
+ return ErrorMarkdownArgs(format!("{} is not a file", path.display())).to_chain();
+ }
+ let Ok(content) = std::fs::read_to_string(&path) else {
+ return ErrorMarkdownArgs(format!("failed to read {}", path.display())).to_chain();
+ };
+
+ let location = path.to_string_lossy().into_owned();
+ let item = format!("doc-{}", snake_case!(&stem_of(&path)));
+ reporter::set_task("Markdown-Check");
+
+ let projects = parse_markdown(&content, &location);
+ let outcomes = try_test_markdown_project(projects).await;
+ let file_info = HashMap::from([(location.clone(), (item, location))]);
+ let fail_count = report_files(&outcomes, &file_info);
+ reporter::flush();
+
+ ResultMarkdownCheck { fail_count }.to_chain()
+}
+
+#[command(node = "markdown-check-all")]
+pub async fn markdown_check_all() -> Next {
+ let Some(files) = verified_md_files() else {
+ return ErrorMarkdownConfig.to_chain();
+ };
+ reporter::set_task("Markdown-Check-All");
+
+ // Collect all projects; remember each file's report identity
+ // (`{key}-{snake_case(file_stem)}` -> location).
+ let mut projects = Vec::new();
+ let mut file_info: HashMap<String, (String, String)> = HashMap::new();
+ for (label, path) in files {
+ let Ok(content) = std::fs::read_to_string(&path) else {
+ continue;
+ };
+ let file_name = path.file_name().unwrap().to_string_lossy();
+ let source_file = format!("{label}/{file_name}");
+ let item = format!("{label}-{}", snake_case!(&stem_of(&path)));
+ let location = path.to_string_lossy().into_owned();
+ file_info.insert(source_file.clone(), (item, location));
+ projects.extend(parse_markdown(&content, &source_file));
+ }
+
+ let outcomes = try_test_markdown_project(projects).await;
+ let fail_count = report_files(&outcomes, &file_info);
+ reporter::flush();
+
+ ResultMarkdownCheck { fail_count }.to_chain()
+}
+
+/// The file name without extension, e.g. `README.md` → `README`.
+pub(crate) fn stem_of(path: &Path) -> String {
+ path.file_stem()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .into_owned()
+}
+
+/// Exports one report entry per source file: `ok` when every block passed,
+/// otherwise an error carrying the failed blocks' details.
+fn report_files(
+ outcomes: &[MarkdownBlockOutcome],
+ file_info: &HashMap<String, (String, String)>,
+) -> usize {
+ let mut by_file: HashMap<&str, (bool, Vec<String>)> = HashMap::new();
+ for outcome in outcomes {
+ let (ok, outputs) = by_file
+ .entry(outcome.source_file.as_str())
+ .or_insert((true, Vec::new()));
+ if !outcome.ok {
+ *ok = false;
+ outputs.push(format!(
+ "{}:{}:\n{}",
+ outcome.source_file, outcome.line, outcome.output
+ ));
+ }
+ }
+
+ let mut fail_count = 0;
+ for (source_file, (ok, outputs)) in by_file {
+ let Some((item, location)) = file_info.get(source_file) else {
+ continue;
+ };
+ if ok {
+ reporter::export(item, location, ReportResult::Ok);
+ } else {
+ fail_count += outputs.len();
+ reporter::export(item, location, ReportResult::Error(outputs.join("\n\n")));
+ }
+ }
+ fail_count
+}
+
+/// Reads `verified-docs.toml` and collects all `.md` files: single files,
+/// directories, or `**` globs (walked from the base directory).
+fn verified_md_files() -> Option<Vec<(String, PathBuf)>> {
+ let content = std::fs::read_to_string(VERIFIED_DOCS).ok()?;
+ let table: toml::Table = content.parse().ok()?;
+
+ let mut files: Vec<(String, PathBuf)> = Vec::new();
+ for (label, value) in table.get("verified")?.as_table()? {
+ let value_str = value.as_str()?;
+ let candidate = PathBuf::from(value_str);
+ if candidate.is_dir() {
+ collect_md_files(&candidate, &mut files, label);
+ } else if candidate.is_file() {
+ files.push((label.clone(), candidate));
+ } else if candidate.extension().is_none() {
+ // Glob like "docs/pages/**": walk the base directory.
+ let base = PathBuf::from(value_str.trim_end_matches("/**").trim_end_matches('*'));
+ if base.is_dir() {
+ collect_md_files(&base, &mut files, label);
+ }
+ }
+ }
+
+ files.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
+ Some(files)
+}
+
+/// Recursively collects all `.md` files under a directory.
+fn collect_md_files(dir: &Path, files: &mut Vec<(String, PathBuf)>, label: &str) {
+ if let Ok(entries) = std::fs::read_dir(dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_md_files(&path, files, label);
+ } else if path.extension().is_some_and(|ext| ext == "md") {
+ files.push((label.to_string(), path));
+ }
+ }
+ }
+}
+
+/// Number of code blocks that failed to build.
+#[derive(Grouped)]
+pub struct ResultMarkdownCheck {
+ pub fail_count: usize,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorMarkdownArgs(pub String);
+
+#[derive(Grouped, Default)]
+pub struct ErrorMarkdownConfig;
+
+/// Silently sets a non-zero exit code when any block failed.
+#[renderer(buffer)]
+pub fn render_markdown_check(r: ResultMarkdownCheck, exit_code: &mut ResExitCode) {
+ if r.fail_count > 0 {
+ exit_code.exit_code = 1;
+ }
+}
+
+#[renderer]
+pub fn render_error_markdown_args(e: ErrorMarkdownArgs, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![e.0]);
+ render_result
+}
+
+#[renderer]
+pub fn render_error_markdown_config(_: ErrorMarkdownConfig, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![format!("failed to read {VERIFIED_DOCS}")]);
+ render_result
+}
diff --git a/mingling_ci/src/task/cmd_markdown_compare.rs b/mingling_ci/src/task/cmd_markdown_compare.rs
new file mode 100644
index 0000000..b014f1e
--- /dev/null
+++ b/mingling_ci/src/task/cmd_markdown_compare.rs
@@ -0,0 +1,221 @@
+use std::collections::BTreeSet;
+use std::path::{Path, PathBuf};
+
+use colored::Colorize;
+use just_fmt::snake_case;
+use mingling::{
+ Grouped, Routable,
+ macros::{buffer, command, renderer},
+ res::ResExitCode,
+};
+
+use crate::Next;
+use crate::markdown::compare::{collect_md_files, compare_signature};
+use crate::reporter::{self, ReportResult};
+use crate::task::cmd_markdown_check::{ErrorMarkdownArgs, ErrorMarkdownConfig, stem_of};
+
+const DOCS_DIR: &str = "./docs";
+const LANG_CONFIG: &str = ".config/docs-lang.txt";
+
+/// One file-pair outcome of a structure comparison.
+struct CompareOutcome {
+ item: String,
+ location: String,
+ ok: bool,
+ output: String,
+}
+
+#[command(node = "markdown-compare")]
+// `#[command]` rewrites an owned first param into the entry type, so the args
+// must be passed by value even though the body only reads them.
+#[allow(clippy::needless_pass_by_value)]
+pub fn markdown_compare(args: Vec<String>) -> Next {
+ let [ref_arg, trans_arg] = args.as_slice() else {
+ return ErrorMarkdownArgs("missing <reference> and <translation> arguments".to_string())
+ .to_chain();
+ };
+ let ref_path = cwd().join(ref_arg);
+ let trans_path = cwd().join(trans_arg);
+
+ reporter::set_task("Markdown-Compare");
+ let outcomes = if ref_path.is_dir() && trans_path.is_dir() {
+ compare_dirs(&ref_path, &trans_path, "doc")
+ } else if ref_path.is_file() && trans_path.is_file() {
+ compare_files(&ref_path, &trans_path, "doc")
+ } else {
+ return ErrorMarkdownArgs(
+ "both arguments must be files or both must be directories".to_string(),
+ )
+ .to_chain();
+ };
+ let fail_count = export_outcomes(&outcomes);
+ reporter::flush();
+
+ ResultMarkdownCompare { fail_count }.to_chain()
+}
+
+#[command(node = "markdown-compare-all")]
+pub fn markdown_compare_all() -> Next {
+ let Some(langs) = lang_config() else {
+ return ErrorMarkdownConfig.to_chain();
+ };
+ let Some(reference) = langs.first() else {
+ return ErrorMarkdownConfig.to_chain();
+ };
+ let ref_dir = PathBuf::from(DOCS_DIR).join(reference);
+ if !ref_dir.is_dir() {
+ return ErrorMarkdownArgs(format!(
+ "reference docs directory `{}` does not exist",
+ ref_dir.display()
+ ))
+ .to_chain();
+ }
+
+ reporter::set_task("Markdown-Compare-All");
+ let mut fail_count = 0;
+ for lang in &langs[1..] {
+ let lang_dir = PathBuf::from(DOCS_DIR).join(lang);
+ if !lang_dir.is_dir() {
+ eprintln!(
+ " {}: `{}` does not exist",
+ "ERROR".bright_red(),
+ lang_dir.display()
+ );
+ fail_count += 1;
+ continue;
+ }
+ let outcomes = compare_dirs(&ref_dir, &lang_dir, &lang_key(lang));
+ fail_count += export_outcomes(&outcomes);
+ }
+ reporter::flush();
+
+ ResultMarkdownCompare { fail_count }.to_chain()
+}
+
+/// Compares one file pair (reference vs translation).
+fn compare_files(ref_path: &Path, trans_path: &Path, prefix: &str) -> Vec<CompareOutcome> {
+ let item = format!("{prefix}-{}", snake_case!(&stem_of(ref_path)));
+ let location = trans_path.to_string_lossy().into_owned();
+ match compare_signature(ref_path, trans_path) {
+ Ok(()) => vec![CompareOutcome {
+ item,
+ location,
+ ok: true,
+ output: String::new(),
+ }],
+ Err(diffs) => vec![CompareOutcome {
+ item,
+ location,
+ ok: false,
+ output: diffs.join("\n"),
+ }],
+ }
+}
+
+/// Compares two directories: every `.md` file in the reference must exist in
+/// the translation with the same structural signature; extra files are errors.
+fn compare_dirs(ref_dir: &Path, trans_dir: &Path, prefix: &str) -> Vec<CompareOutcome> {
+ let ref_files = collect_md_files(ref_dir);
+ let ref_set: BTreeSet<PathBuf> = ref_files.iter().cloned().collect();
+ let trans_set: BTreeSet<PathBuf> = collect_md_files(trans_dir).into_iter().collect();
+
+ let mut outcomes = Vec::new();
+ for file in ref_files {
+ let item = format!("{prefix}-{}", snake_case!(&stem_of(&file)));
+ let trans_path = trans_dir.join(&file);
+ let location = trans_path.to_string_lossy().into_owned();
+ if !trans_set.contains(&file) {
+ outcomes.push(CompareOutcome {
+ item,
+ location,
+ ok: false,
+ output: "missing in translation".to_string(),
+ });
+ continue;
+ }
+ outcomes.push(match compare_signature(&ref_dir.join(&file), &trans_path) {
+ Ok(()) => CompareOutcome {
+ item,
+ location,
+ ok: true,
+ output: String::new(),
+ },
+ Err(diffs) => CompareOutcome {
+ item,
+ location,
+ ok: false,
+ output: diffs.join("\n"),
+ },
+ });
+ }
+
+ for file in trans_set.difference(&ref_set) {
+ let item = format!("{prefix}-{}", snake_case!(&stem_of(file)));
+ let trans_path = trans_dir.join(file);
+ outcomes.push(CompareOutcome {
+ item,
+ location: trans_path.to_string_lossy().into_owned(),
+ ok: false,
+ output: "extra file, not in reference".to_string(),
+ });
+ }
+ outcomes
+}
+
+/// Exports the outcomes via `reporter`; failures also print to stderr.
+fn export_outcomes(outcomes: &[CompareOutcome]) -> usize {
+ let mut fail_count = 0;
+ for outcome in outcomes {
+ if outcome.ok {
+ reporter::export(&outcome.item, &outcome.location, ReportResult::Ok);
+ } else {
+ fail_count += 1;
+ eprintln!(" {} {}", "failed".bright_red(), outcome.item);
+ eprintln!(" {}\n{}", outcome.location, outcome.output);
+ reporter::export(
+ &outcome.item,
+ &outcome.location,
+ ReportResult::Error(outcome.output.clone()),
+ );
+ }
+ }
+ fail_count
+}
+
+/// Reads `.config/docs-lang.txt`: the first line is the reference directory
+/// (relative to `./docs/`), the rest are translations that must mirror it.
+fn lang_config() -> Option<Vec<String>> {
+ let content = std::fs::read_to_string(LANG_CONFIG).ok()?;
+ Some(
+ content
+ .lines()
+ .map(str::trim)
+ .filter(|l| !l.is_empty() && !l.starts_with('#'))
+ .map(|l| l.trim_start_matches("./").to_string())
+ .collect(),
+ )
+}
+
+/// Turns a lang directory path into a report-item key, e.g.
+/// `./_zh_CN/pages/` → `_zh_CN_pages`.
+fn lang_key(lang: &str) -> String {
+ lang.trim_matches('/').replace('/', "_")
+}
+
+fn cwd() -> PathBuf {
+ std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
+}
+
+/// Number of files that failed the structure comparison.
+#[derive(Grouped)]
+pub struct ResultMarkdownCompare {
+ pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when any comparison failed.
+#[renderer(buffer)]
+pub fn render_markdown_compare(r: ResultMarkdownCompare, exit_code: &mut ResExitCode) {
+ if r.fail_count > 0 {
+ exit_code.exit_code = 1;
+ }
+}
diff --git a/mingling_ci/src/task/cmd_test.rs b/mingling_ci/src/task/cmd_test.rs
new file mode 100644
index 0000000..5b9f55a
--- /dev/null
+++ b/mingling_ci/src/task/cmd_test.rs
@@ -0,0 +1,54 @@
+use std::ffi::OsString;
+use std::path::Path;
+
+use mingling::{
+ Grouped, Routable,
+ macros::{buffer, command, renderer},
+ res::ResExitCode,
+};
+
+use crate::Next;
+use crate::res::{Manifests, ResCrateConfig};
+use crate::task::run::{location, run_parallel_checks};
+
+#[command(node = "test-all")]
+pub async fn test_all(manifests: &Manifests, config: &ResCrateConfig) -> Next {
+ let tasks = manifests
+ .package_dirs
+ .iter()
+ .map(|(name, path)| {
+ let args = config.test_command(name).map_or_else(
+ || test_args(path),
+ |cmd| cmd.iter().map(|s| OsString::from(s.as_str())).collect(),
+ );
+ (name.clone(), location(path), args)
+ })
+ .collect();
+ let fail_count = run_parallel_checks("Test-All", "Testing", tasks).await;
+ ResultTestAll { fail_count }.to_chain()
+}
+
+/// Default: `cargo test --manifest-path <path>` (crates without a
+/// `mingling-ci.toml` override).
+fn test_args(path: &Path) -> Vec<OsString> {
+ vec![
+ "cargo".into(),
+ "test".into(),
+ "--manifest-path".into(),
+ path.as_os_str().to_os_string(),
+ ]
+}
+
+/// Number of packages that failed tests.
+#[derive(Grouped)]
+pub struct ResultTestAll {
+ pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when any test failed.
+#[renderer(buffer)]
+pub fn render_test_all(r: ResultTestAll, exit_code: &mut ResExitCode) {
+ if r.fail_count > 0 {
+ exit_code.exit_code = 1;
+ }
+}
diff --git a/mingling_ci/src/task/run.rs b/mingling_ci/src/task/run.rs
new file mode 100644
index 0000000..ba752dd
--- /dev/null
+++ b/mingling_ci/src/task/run.rs
@@ -0,0 +1,114 @@
+use std::ffi::OsString;
+use std::path::Path;
+
+use colored::Colorize;
+
+use crate::progress::task_progress_bar;
+use crate::reporter::{self, ReportResult};
+
+/// The manifest's parent directory, e.g. `./mingling` — the report location
+/// for a crate-based item.
+pub(crate) fn location(path: &Path) -> String {
+ path.parent()
+ .map_or_else(|| ".".to_string(), |d| d.to_string_lossy().into_owned())
+}
+
+/// Outcome of a `cargo` subcommand.
+struct CargoResult {
+ ok: bool,
+ exit_code: Option<i32>,
+ output: String,
+}
+
+/// Runs the given cargo task list in parallel.
+///
+/// Each task is an `(item, location, argv)` triple; progress and failures go
+/// to stderr: a failing task prints its output immediately and writes its
+/// report entry at the same time. Returns the number of failing tasks.
+pub(crate) async fn run_parallel_checks(
+ task: &str,
+ phase: &str,
+ tasks: Vec<(String, String, Vec<OsString>)>,
+) -> usize {
+ reporter::set_task(task);
+
+ let n = tasks.len();
+ let pb = task_progress_bar(n, phase);
+ pb.set_message("tasks");
+
+ // Run each task in parallel.
+ let mut set = tokio::task::JoinSet::new();
+ for (item, location, args) in tasks {
+ set.spawn(async move { (item, location, run_cargo(args).await) });
+ }
+
+ let mut fail_count = 0;
+ while let Some(joined) = set.join_next().await {
+ let Ok((item, location, result)) = joined else {
+ continue;
+ };
+ pb.inc(1);
+ pb.set_message(item.clone());
+
+ if result.ok {
+ reporter::export(&item, &location, ReportResult::Ok);
+ } else {
+ fail_count += 1;
+ // Failures print to stderr immediately (bar suspended to avoid
+ // interleaving) and write their report entry at the same time.
+ pb.suspend(|| {
+ eprintln!(
+ "{}: {} failed{}",
+ phase.bold().bright_cyan(),
+ item,
+ result
+ .exit_code
+ .map_or_else(String::new, |c| format!(" (exit code {c})"))
+ );
+ for line in result.output.lines() {
+ eprintln!(" {line}");
+ }
+ });
+ reporter::export(&item, &location, ReportResult::Error(result.output));
+ }
+ }
+
+ pb.finish_and_clear();
+ reporter::flush();
+ fail_count
+}
+
+/// Runs a `cargo` subcommand, capturing its output.
+/// Runs a cargo subcommand (`argv[0]` is the program), capturing its output.
+async fn run_cargo(argv: Vec<OsString>) -> CargoResult {
+ let mut argv = argv.into_iter();
+ let Some(program) = argv.next() else {
+ return CargoResult {
+ ok: false,
+ exit_code: None,
+ output: "empty command".to_string(),
+ };
+ };
+
+ let output = tokio::process::Command::new(program)
+ .args(argv)
+ .output()
+ .await;
+
+ match output {
+ Ok(output) => {
+ let mut log = String::from_utf8_lossy(&output.stdout).into_owned();
+ log.push_str(&String::from_utf8_lossy(&output.stderr));
+ CargoResult {
+ ok: output.status.success(),
+ exit_code: output.status.code(),
+ output: log,
+ }
+ }
+ Err(e) => CargoResult {
+ ok: false,
+ exit_code: None,
+ output: format!("failed to run cargo: {e}"),
+ },
+ }
+}
diff --git a/mingling_ci/src/tools.rs b/mingling_ci/src/tools.rs
new file mode 100644
index 0000000..13c2ec4
--- /dev/null
+++ b/mingling_ci/src/tools.rs
@@ -0,0 +1,3 @@
+pub(crate) mod docsify_refresh;
+pub(crate) mod example_refresh;
+pub(crate) mod features_refresh;
diff --git a/mingling_ci/src/tools/docsify_refresh.rs b/mingling_ci/src/tools/docsify_refresh.rs
new file mode 100644
index 0000000..dfb9b11
--- /dev/null
+++ b/mingling_ci/src/tools/docsify_refresh.rs
@@ -0,0 +1,373 @@
+//! Docsify maintenance: fix code-box blank lines and regenerate `_sidebar.md`
+//! files under `docs/`.
+
+use std::collections::BTreeMap;
+use std::fmt::Write as _;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{buffer, command, r_println, renderer},
+};
+
+use crate::Next;
+use crate::res::{CargoError, MessagePrinter};
+
+const DOCS_DIR: &str = "./docs";
+const SIDEBAR_HEAD: &str = "- [Welcome!](README)\n";
+
+#[command(node = "docsify-refresh")]
+pub fn docsify_refresh() -> Next {
+ match refresh_all() {
+ Ok(written) => ResultDocsifyRefresh { written }.to_chain(),
+ Err(e) => ErrorDocsifyRefresh(e).to_chain(),
+ }
+}
+
+fn refresh_all() -> Result<Vec<String>, String> {
+ let mut written = Vec::new();
+ written.extend(fix_code_boxes());
+ written.extend(gen_sidebars()?);
+ Ok(written)
+}
+
+/// Part 1: docsify renders code blocks poorly when the blank lines around
+/// them are completely empty — replace them with a single space.
+fn fix_code_boxes() -> Vec<String> {
+ let mut file_count = 0;
+ let mut fixed_count = 0;
+ let mut written = Vec::new();
+
+ collect_md_files(Path::new(DOCS_DIR), &mut |path| {
+ if path
+ .file_name()
+ .is_some_and(|n| n.to_string_lossy().to_lowercase() == "_sidebar.md")
+ {
+ return;
+ }
+ let content = fs::read_to_string(path).unwrap_or_default();
+ if content.is_empty() {
+ return;
+ }
+ let new_content = fix_code_box_empty_lines(&content);
+ if new_content != content {
+ fs::write(path, &new_content).unwrap();
+ written.push(format!("fixed: {}", path.display()));
+ fixed_count += 1;
+ }
+ file_count += 1;
+ });
+
+ written.push(format!("scanned {file_count} files, fixed {fixed_count}"));
+ written
+}
+
+/// Replaces completely empty lines adjacent to fenced code blocks with lines
+/// containing a single space.
+fn fix_code_box_empty_lines(content: &str) -> String {
+ let mut result = String::new();
+ let lines: Vec<&str> = content.lines().collect();
+ let len = lines.len();
+
+ let mut i = 0;
+ while i < len {
+ let line = lines[i];
+ result.push_str(line);
+ result.push('\n');
+ i += 1;
+
+ if !line.trim_start().starts_with("```") {
+ continue;
+ }
+
+ // In a code block: find the closing fence.
+ let code_start = i;
+ let mut code_end = len;
+ let mut found_end = false;
+ while i < len {
+ let cline = lines[i];
+ if cline.trim_start().starts_with("```") && !cline.trim().is_empty() {
+ code_end = i;
+ found_end = true;
+ break;
+ }
+ i += 1;
+ }
+
+ ensure_space_before_code_block(&mut result);
+
+ for code_line in lines.iter().take(code_end).skip(code_start) {
+ if code_line.is_empty() {
+ result.push(' ');
+ } else {
+ result.push_str(code_line);
+ }
+ result.push('\n');
+ }
+
+ if found_end {
+ result.push_str(lines[code_end]);
+ result.push('\n');
+ i += 1;
+
+ if i < len && lines[i].trim().is_empty() && lines[i].is_empty() {
+ result.push(' ');
+ result.push('\n');
+ i += 1;
+ }
+ }
+ }
+
+ while result.ends_with('\n') {
+ result.pop();
+ }
+ result.push('\n');
+ result
+}
+
+/// Turns a trailing `\n\n` before a code block into `\n \n`.
+fn ensure_space_before_code_block(result: &mut String) {
+ let len = result.len();
+ if len >= 2 && &result[len - 2..] == "\n\n" {
+ result.insert(len - 1, ' ');
+ }
+}
+
+/// Part 2: find every README.md under `docs/` (each is a site root) and
+/// regenerate its `_sidebar.md`.
+fn gen_sidebars() -> Result<Vec<String>, String> {
+ let mut written = Vec::new();
+ for readme_path in find_all_readmes(Path::new(DOCS_DIR)) {
+ let site_root = readme_path
+ .parent()
+ .ok_or_else(|| format!("{} has no parent", readme_path.display()))?;
+ if let Some(content_dir) = find_content_dir(site_root) {
+ let lines = build_sidebar_content(site_root, &content_dir, SIDEBAR_HEAD);
+ let sidebar_path = site_root.join("_sidebar.md");
+ fs::write(&sidebar_path, lines)
+ .map_err(|e| format!("failed to write {}: {e}", sidebar_path.display()))?;
+ written.push(format!("generated: {}", sidebar_path.display()));
+ }
+ }
+ Ok(written)
+}
+
+/// Recursively finds all README.md files under a directory.
+fn find_all_readmes(dir: &Path) -> Vec<PathBuf> {
+ let mut results = Vec::new();
+ if let Ok(read_dir) = fs::read_dir(dir) {
+ let mut entries: Vec<_> = read_dir.flatten().collect();
+ entries.sort_by_key(std::fs::DirEntry::path);
+ for entry in entries {
+ let path = entry.path();
+ if path.is_dir() {
+ results.extend(find_all_readmes(&path));
+ } else if path.file_name().is_some_and(|n| n == "README.md") {
+ results.push(path);
+ }
+ }
+ }
+ results
+}
+
+/// The content directory of a site: `pages/` if present, else the first
+/// subdirectory containing markdown files.
+fn find_content_dir(site_root: &Path) -> Option<PathBuf> {
+ let pages_dir = site_root.join("pages");
+ if pages_dir.is_dir() {
+ return Some(pages_dir);
+ }
+ if let Ok(read_dir) = fs::read_dir(site_root) {
+ let mut entries: Vec<_> = read_dir.flatten().collect();
+ entries.sort_by_key(std::fs::DirEntry::path);
+ for entry in entries {
+ let path = entry.path();
+ if path.is_dir() && has_markdown_files(&path) {
+ return Some(path);
+ }
+ }
+ }
+ None
+}
+
+fn has_markdown_files(dir: &Path) -> bool {
+ if let Ok(read_dir) = fs::read_dir(dir) {
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ if has_markdown_files(&path) {
+ return true;
+ }
+ } else if path.extension().is_some_and(|ext| ext == "md") {
+ return true;
+ }
+ }
+ }
+ false
+}
+
+#[derive(Clone)]
+struct SidebarEntry {
+ title: String,
+ link: String,
+}
+
+/// Builds the sidebar content from the markdown files under `pages_dir`.
+fn build_sidebar_content(base_dir: &Path, pages_dir: &Path, sidebar_head: &str) -> String {
+ let mut lines = String::from(sidebar_head);
+
+ let mut root_files: Vec<SidebarEntry> = Vec::new();
+ let mut sub_dirs: BTreeMap<String, Vec<SidebarEntry>> = BTreeMap::new();
+
+ if let Ok(read_dir) = fs::read_dir(pages_dir) {
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ let dir_name = entry.file_name().to_string_lossy().into_owned();
+ let entries = collect_markdown_files(&path, base_dir);
+ if !entries.is_empty() {
+ let display_name = get_directory_display_name(&path, &dir_name);
+ sub_dirs.insert(display_name, entries);
+ }
+ } else if path.extension().is_some_and(|ext| ext == "md") {
+ root_files.push(SidebarEntry {
+ title: extract_title(&path),
+ link: relative_link(&path, base_dir),
+ });
+ }
+ }
+ }
+
+ root_files.sort_by(|a, b| natural_cmp(&a.link, &b.link));
+ for f in &root_files {
+ let _ = writeln!(lines, "* [{}]({})", f.title, f.link);
+ }
+
+ for (dir_name, entries) in &sub_dirs {
+ let mut sorted_entries = entries.clone();
+ sorted_entries.sort_by(|a, b| natural_cmp(&a.link, &b.link));
+ let _ = writeln!(lines, "* {dir_name}");
+ for f in &sorted_entries {
+ let _ = writeln!(lines, " * [{}]({})", f.title, f.link);
+ }
+ }
+
+ lines
+}
+
+/// All `.md` files directly under `dir`, as sidebar entries.
+fn collect_markdown_files(dir: &Path, base_dir: &Path) -> Vec<SidebarEntry> {
+ let mut entries = Vec::new();
+ if let Ok(read_dir) = fs::read_dir(dir) {
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.extension().is_some_and(|ext| ext == "md") {
+ entries.push(SidebarEntry {
+ title: extract_title(&path),
+ link: relative_link(&path, base_dir),
+ });
+ }
+ }
+ }
+ entries
+}
+
+/// The link of a file relative to `base_dir`, without the `.md` suffix.
+fn relative_link(path: &Path, base_dir: &Path) -> String {
+ path.strip_prefix(base_dir)
+ .unwrap_or(path)
+ .to_string_lossy()
+ .replace('\\', "/")
+ .strip_suffix(".md")
+ .unwrap_or_default()
+ .to_string()
+}
+
+/// Extracts the title from the first line `<h1 align="center">TITLE</h1>`,
+/// falling back to the file stem.
+fn extract_title(path: &Path) -> String {
+ let content = fs::read_to_string(path).unwrap_or_default();
+ if let Some(first_line) = content.lines().next() {
+ let trimmed = first_line.trim();
+ if let Some(start) = trimmed.find('>') {
+ let after_start = &trimmed[start + 1..];
+ if let Some(end) = after_start.find('<') {
+ return after_start[..end].to_string();
+ }
+ }
+ }
+ path.file_stem().map_or_else(
+ || "Untitled".to_string(),
+ |s| s.to_string_lossy().into_owned(),
+ )
+}
+
+/// Reads a directory's `.name` file to override its sidebar display name.
+fn get_directory_display_name(dir_path: &Path, fallback: &str) -> String {
+ let name_file = dir_path.join(".name");
+ if name_file.is_file() {
+ fs::read_to_string(&name_file)
+ .ok()
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ .unwrap_or_else(|| fallback.to_string())
+ } else {
+ fallback.to_string()
+ }
+}
+
+/// Numeric-aware comparison: `1-x` sorts before `10-x`, unnumbered last.
+fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
+ extract_leading_number(a)
+ .cmp(&extract_leading_number(b))
+ .then_with(|| a.cmp(b))
+}
+
+/// The leading numeric prefix of a link's file stem, `usize::MAX` if absent.
+fn extract_leading_number(link: &str) -> usize {
+ if let Some(file_stem) = link.rsplit('/').next()
+ && let Some(num_end) = file_stem.find('-')
+ && let Ok(num) = file_stem[..num_end].parse::<usize>()
+ {
+ return num;
+ }
+ usize::MAX
+}
+
+/// Recursively collects all `.md` files under a directory.
+fn collect_md_files(dir: &Path, callback: &mut dyn FnMut(&Path)) {
+ if let Ok(entries) = fs::read_dir(dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_md_files(&path, callback);
+ } else if path.extension().is_some_and(|ext| ext == "md") {
+ callback(&path);
+ }
+ }
+ }
+}
+
+/// Files written by `docsify-refresh`.
+#[derive(Grouped)]
+pub struct ResultDocsifyRefresh {
+ pub written: Vec<String>,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorDocsifyRefresh(pub String);
+
+#[renderer(buffer)]
+pub fn render_docsify_refresh(r: ResultDocsifyRefresh) {
+ for item in r.written {
+ r_println!("{item}");
+ }
+}
+
+#[renderer]
+pub fn render_error_docsify_refresh(e: ErrorDocsifyRefresh, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![e.0]);
+ render_result
+}
diff --git a/mingling_ci/src/tools/example_refresh.rs b/mingling_ci/src/tools/example_refresh.rs
new file mode 100644
index 0000000..ca8443c
--- /dev/null
+++ b/mingling_ci/src/tools/example_refresh.rs
@@ -0,0 +1,279 @@
+//! Regenerates the example documentation module and the examples index.
+
+use std::collections::HashMap;
+use std::fs;
+use std::path::Path;
+
+use just_fmt::snake_case;
+use just_template::Template;
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{buffer, command, r_println, renderer},
+};
+use serde::Serialize;
+
+use crate::Next;
+use crate::res::{CargoError, MessagePrinter};
+
+const EXAMPLE_ROOT: &str = "./examples";
+const EXAMPLE_DOCS_OUTPUT: &str = "./mingling/src/example_docs.rs";
+const EXAMPLE_DOCS_TEMPLATE: &str = include_str!("../../../mingling/src/example_docs.rs.tmpl");
+const EXAMPLES_JSON_OUTPUT: &str = "./docs/example-pages/examples.json";
+
+#[command(node = "example-refresh")]
+pub fn example_refresh() -> Next {
+ match refresh_all() {
+ Ok(written) => ResultExampleRefresh { written }.to_chain(),
+ Err(e) => ErrorExampleRefresh(e).to_chain(),
+ }
+}
+
+fn refresh_all() -> Result<Vec<String>, String> {
+ let mut written = Vec::new();
+ written.extend(refresh_example_docs()?);
+ written.extend(sync_examples()?);
+ Ok(written)
+}
+
+/// Part 1: regenerate `mingling/src/example_docs.rs` from the examples'
+/// `src/main.rs` (header `//!` + code) and `Cargo.toml`.
+fn refresh_example_docs() -> Result<Vec<String>, String> {
+ let mut template = Template::from(EXAMPLE_DOCS_TEMPLATE);
+
+ let mut examples = Vec::new();
+ let entries =
+ fs::read_dir(EXAMPLE_ROOT).map_err(|e| format!("failed to read {EXAMPLE_ROOT}: {e}"))?;
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_dir() {
+ continue;
+ }
+ let name = entry.file_name().to_string_lossy().into_owned();
+ if !name.starts_with("example-") {
+ continue;
+ }
+ examples.push(ExampleContent::read(&name));
+ }
+ examples.sort_by(|a, b| a.name.cmp(&b.name));
+
+ let mut written = Vec::new();
+ for example in examples {
+ template
+ .add_impl("examples".to_string())
+ .push(HashMap::from([
+ ("example_header".to_string(), example.header),
+ ("example_import".to_string(), example.cargo_toml),
+ ("example_code".to_string(), example.code),
+ ("example_name".to_string(), snake_case!(&example.name)),
+ ]));
+ written.push(format!("example_docs: {}", example.name));
+ }
+
+ let template_str = template.to_string();
+ let template_str = template_str
+ .lines()
+ .map(str::trim_end)
+ .collect::<Vec<_>>()
+ .join("\n")
+ + "\n";
+ fs::write(EXAMPLE_DOCS_OUTPUT, template_str)
+ .map_err(|e| format!("failed to write {EXAMPLE_DOCS_OUTPUT}: {e}"))?;
+ written.push(format!("written: {EXAMPLE_DOCS_OUTPUT}"));
+ Ok(written)
+}
+
+struct ExampleContent {
+ name: String,
+ header: String,
+ code: String,
+ cargo_toml: String,
+}
+
+impl ExampleContent {
+ fn read(name: &str) -> Self {
+ let prefix = |s: &str| {
+ s.lines()
+ .map(|line| format!("/// {line}"))
+ .collect::<Vec<_>>()
+ .join("\n")
+ };
+
+ let (header, code) = read_header_and_code(name);
+ Self {
+ name: name.to_string(),
+ header: prefix(&header),
+ code: prefix(&code),
+ cargo_toml: prefix(&read_cargo_toml(name)),
+ }
+ }
+}
+
+/// Reads an example's `src/main.rs`, splitting `//!` doc header from code.
+fn read_header_and_code(name: &str) -> (String, String) {
+ let content = fs::read_to_string(Path::new(EXAMPLE_ROOT).join(name).join("src/main.rs"))
+ .unwrap_or_default();
+ let mut lines = content.lines();
+ let mut header = String::new();
+ let mut code = String::new();
+
+ for line in lines.by_ref() {
+ if line.trim_start().starts_with("//!") {
+ header.push_str(line.trim_start_matches("//!"));
+ header.push('\n');
+ } else {
+ code.push_str(line);
+ code.push('\n');
+ break;
+ }
+ }
+ for line in lines {
+ code.push_str(line);
+ code.push('\n');
+ }
+
+ (header.trim().to_string(), code.trim().to_string())
+}
+
+fn read_cargo_toml(name: &str) -> String {
+ fs::read_to_string(Path::new(EXAMPLE_ROOT).join(name).join("Cargo.toml")).unwrap_or_default()
+}
+
+/// Part 2: regenerate `docs/example-pages/examples.json` from each example's
+/// `page.toml`.
+fn sync_examples() -> Result<Vec<String>, String> {
+ fs::create_dir_all("docs/example-pages")
+ .map_err(|e| format!("failed to create docs/example-pages: {e}"))?;
+
+ let mut examples = Vec::new();
+ let entries =
+ fs::read_dir(EXAMPLE_ROOT).map_err(|e| format!("failed to read {EXAMPLE_ROOT}: {e}"))?;
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_dir() {
+ continue;
+ }
+ let dir_name = entry.file_name().to_string_lossy().into_owned();
+ let page_toml = path.join("page.toml");
+ if !page_toml.is_file() {
+ continue;
+ }
+ let Ok(content) = fs::read_to_string(&page_toml) else {
+ continue;
+ };
+ let Ok(table) = content.parse::<toml::Value>() else {
+ eprintln!("Warning: failed to parse {}", page_toml.display());
+ continue;
+ };
+ let Some(example) = table.get("example") else {
+ continue;
+ };
+
+ let get = |key: &str| {
+ example
+ .get(key)
+ .and_then(|v| v.as_str())
+ .unwrap_or_default()
+ };
+ let str_vec = |key: &str| {
+ example
+ .get(key)
+ .and_then(|v| v.as_array())
+ .map(|a| {
+ a.iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default()
+ };
+
+ let id = get("id");
+ examples.push(ExampleMeta {
+ id: if id.is_empty() {
+ dir_name.clone()
+ } else {
+ id.to_string()
+ },
+ name: {
+ let name = get("name");
+ if name.is_empty() {
+ dir_name.clone()
+ } else {
+ name.to_string()
+ }
+ },
+ icon: {
+ let icon = get("icon");
+ if icon.is_empty() {
+ "📦".to_string()
+ } else {
+ icon.to_string()
+ }
+ },
+ category: get("category").to_string(),
+ desc: get("desc").to_string(),
+ tags: str_vec("tags"),
+ files: {
+ let files = str_vec("files");
+ if files.is_empty() {
+ vec!["Cargo.toml".to_string(), "src/main.rs".to_string()]
+ } else {
+ files
+ }
+ },
+ });
+ }
+
+ // Basic first, then alphabetical.
+ examples.sort_by(
+ |a, b| match (a.id == "example-basic", b.id == "example-basic") {
+ (true, false) => std::cmp::Ordering::Less,
+ (false, true) => std::cmp::Ordering::Greater,
+ _ => a.id.cmp(&b.id),
+ },
+ );
+
+ let json = serde_json::to_string_pretty(&examples)
+ .map_err(|e| format!("failed to serialize examples: {e}"))?;
+ fs::write(EXAMPLES_JSON_OUTPUT, json)
+ .map_err(|e| format!("failed to write {EXAMPLES_JSON_OUTPUT}: {e}"))?;
+
+ Ok(vec![format!(
+ "synced: {} examples -> {EXAMPLES_JSON_OUTPUT}",
+ examples.len()
+ )])
+}
+
+/// One entry of `docs/example-pages/examples.json`.
+#[derive(Serialize)]
+struct ExampleMeta {
+ id: String,
+ name: String,
+ icon: String,
+ category: String,
+ desc: String,
+ tags: Vec<String>,
+ files: Vec<String>,
+}
+
+/// Files written by `example-refresh`.
+#[derive(Grouped)]
+pub struct ResultExampleRefresh {
+ pub written: Vec<String>,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorExampleRefresh(pub String);
+
+#[renderer(buffer)]
+pub fn render_example_refresh(r: ResultExampleRefresh) {
+ for item in r.written {
+ r_println!("{item}");
+ }
+}
+
+#[renderer]
+pub fn render_error_example_refresh(e: ErrorExampleRefresh, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![e.0]);
+ render_result
+}
diff --git a/mingling_ci/src/tools/features_refresh.rs b/mingling_ci/src/tools/features_refresh.rs
new file mode 100644
index 0000000..87aeead
--- /dev/null
+++ b/mingling_ci/src/tools/features_refresh.rs
@@ -0,0 +1,96 @@
+//! Regenerates `mingling/src/features.rs` from the `[features]` section of
+//! `mingling/Cargo.toml`.
+
+use std::collections::HashMap;
+use std::fs;
+
+use just_fmt::snake_case;
+use just_template::Template;
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{buffer, command, r_println, renderer},
+};
+
+use crate::Next;
+use crate::res::{CargoError, MessagePrinter};
+
+const CARGO_TOML_PATH: &str = "./mingling/Cargo.toml";
+const OUTPUT_PATH: &str = "./mingling/src/features.rs";
+const TEMPLATE_CONTENT: &str = include_str!("../../../mingling/src/features.rs.tmpl");
+
+#[command(node = "features-refresh")]
+pub fn features_refresh() -> Next {
+ match gen_feature_module() {
+ Ok(written) => ResultFeaturesRefresh { written }.to_chain(),
+ Err(e) => ErrorFeaturesRefresh(e).to_chain(),
+ }
+}
+
+fn gen_feature_module() -> Result<Vec<String>, String> {
+ let features = parse_features()?;
+
+ let mut template = Template::from(TEMPLATE_CONTENT);
+ let mut written = Vec::new();
+ for feat_name in &features {
+ let feat_const_name = snake_case!(feat_name).to_uppercase();
+ template
+ .add_impl("features".to_string())
+ .push(HashMap::from([
+ ("feat_name".to_string(), feat_name.clone()),
+ ("feat_const_name".to_string(), feat_const_name),
+ ]));
+ written.push(format!("feature: {feat_name}"));
+ }
+
+ let template_str = template.to_string();
+ let template_str = template_str
+ .lines()
+ .map(str::trim_end)
+ .collect::<Vec<_>>()
+ .join("\n")
+ + "\n";
+ fs::write(OUTPUT_PATH, template_str)
+ .map_err(|e| format!("failed to write {OUTPUT_PATH}: {e}"))?;
+ written.push(format!("written: {OUTPUT_PATH}"));
+ Ok(written)
+}
+
+/// All feature names from the `[features]` section, sorted.
+fn parse_features() -> Result<Vec<String>, String> {
+ let content = fs::read_to_string(CARGO_TOML_PATH)
+ .map_err(|e| format!("failed to read {CARGO_TOML_PATH}: {e}"))?;
+ let table: toml::Value = content
+ .parse()
+ .map_err(|e| format!("failed to parse {CARGO_TOML_PATH}: {e}"))?;
+ let features = table
+ .get("features")
+ .and_then(|v| v.as_table())
+ .ok_or_else(|| format!("no [features] section in {CARGO_TOML_PATH}"))?;
+
+ let mut names: Vec<String> = features.keys().cloned().collect();
+ names.sort();
+ Ok(names)
+}
+
+/// Feature names written by `features-refresh`.
+#[derive(Grouped)]
+pub struct ResultFeaturesRefresh {
+ pub written: Vec<String>,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorFeaturesRefresh(pub String);
+
+#[renderer(buffer)]
+pub fn render_features_refresh(r: ResultFeaturesRefresh) {
+ for item in r.written {
+ r_println!("{item}");
+ }
+}
+
+#[renderer]
+pub fn render_error_features_refresh(e: ErrorFeaturesRefresh, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![e.0]);
+ render_result
+}
diff --git a/mingling_ci/tmpls/report.md b/mingling_ci/tmpls/report.md
new file mode 100644
index 0000000..020fda2
--- /dev/null
+++ b/mingling_ci/tmpls/report.md
@@ -0,0 +1,9 @@
+<h1 align="center">Mingling CI Results</h1>
+
+<p align="center"> <<<date>>> - <<<commit_hash>>> </p>
+
+>>>>>>>>>> task_sections
+@@@ >>> task_sections
+<<<section>>>
+
+@@@ <<<
diff --git a/mingling_ci/tmpls/task_section.md b/mingling_ci/tmpls/task_section.md
new file mode 100644
index 0000000..78c9801
--- /dev/null
+++ b/mingling_ci/tmpls/task_section.md
@@ -0,0 +1,18 @@
+## Task: <<<task_name>>>
+
+| Item-Name | Location | PASS (Windows) | PASS (Linux) | PASS (Mac OS) |
+| ----------- | -------- | -------------- | ------------ | ------------- |
+>>>>>>>>>> rows
+@@@ >>> rows
+| <<<item_name>>> | <<<location>>> | <<<pass_win>>> | <<<pass_linux>>> | <<<pass_mac>>> |
+@@@ <<<
+
+>>>>>>>>>> fails
+@@@ >>> fails
+### Fail: <<<item_name>>>
+
+```stdout
+<<<stdout>>>
+```
+
+@@@ <<<
diff --git a/mingling_core/mingling-ci.toml b/mingling_core/mingling-ci.toml
new file mode 100644
index 0000000..b243cbe
--- /dev/null
+++ b/mingling_core/mingling-ci.toml
@@ -0,0 +1,2 @@
+[test]
+command = ["cargo", "test", "-p", "mingling_core"]
diff --git a/mingling_macros/mingling-ci.toml b/mingling_macros/mingling-ci.toml
new file mode 100644
index 0000000..3aefc4a
--- /dev/null
+++ b/mingling_macros/mingling-ci.toml
@@ -0,0 +1,2 @@
+[test]
+command = ["cargo", "test", "-p", "mingling_macros"]
diff --git a/mingling_pathf/mingling-ci.toml b/mingling_pathf/mingling-ci.toml
new file mode 100644
index 0000000..59e4e17
--- /dev/null
+++ b/mingling_pathf/mingling-ci.toml
@@ -0,0 +1,2 @@
+[test]
+command = ["cargo", "test", "-p", "mingling_pathf"]