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/install-mling.ps12
-rwxr-xr-x.run/src/bin/install-mling.sh2
-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--.run/src/verify.rs23
-rw-r--r--.vscode/settings.json1
-rw-r--r--.zed/settings.json1
-rw-r--r--CHANGELOG.md131
-rw-r--r--CONTRIBUTING.md88
-rw-r--r--Cargo.lock95
-rw-r--r--Cargo.toml1
-rw-r--r--GETTING-STARTED.md19
-rw-r--r--README.md173
-rw-r--r--ROADMAP.md33
-rw-r--r--arg_picker/mingling-ci.toml2
-rw-r--r--arg_picker_macros/mingling-ci.toml2
-rw-r--r--docs/_zh_CN/pages/12-exit-code.md4
-rw-r--r--docs/_zh_CN/pages/advanced/1-completion.md21
-rw-r--r--docs/_zh_CN/pages/other/features.md64
-rw-r--r--docs/dev/pages/abouts/ci.md148
-rw-r--r--docs/dev/pages/abouts/code-verify-system.md54
-rw-r--r--docs/example-pages/examples.json11
-rw-r--r--docs/pages/12-exit-code.md4
-rw-r--r--docs/pages/advanced/1-completion.md21
-rw-r--r--docs/pages/other/features.md64
-rw-r--r--examples/example-combine-pathf-dispatch-tree/Cargo.lock92
-rw-r--r--examples/example-combine-pathf-dispatch-tree/Cargo.toml13
-rw-r--r--examples/example-combine-pathf-dispatch-tree/build.rs3
-rw-r--r--examples/example-combine-pathf-dispatch-tree/page.toml4
-rw-r--r--examples/example-combine-pathf-dispatch-tree/src/main.rs8
-rw-r--r--examples/example-combine-pathf-metadata/Cargo.lock92
-rw-r--r--examples/example-combine-pathf-metadata/Cargo.toml9
-rw-r--r--examples/example-combine-pathf-metadata/build.rs3
-rw-r--r--examples/example-combine-pathf-metadata/page.toml4
-rw-r--r--examples/example-completion/Cargo.lock102
-rw-r--r--examples/example-completion/Cargo.toml12
-rw-r--r--examples/example-completion/build.rs14
-rw-r--r--examples/example-completion/page.toml2
-rw-r--r--examples/example-completion/src/main.rs39
-rw-r--r--examples/example-enum-tag/Cargo.lock102
-rw-r--r--examples/example-enum-tag/src/main.rs2
-rw-r--r--examples/example-exitcode/src/main.rs2
-rw-r--r--examples/example-pathfinder/Cargo.lock92
-rw-r--r--examples/example-pathfinder/Cargo.toml11
-rw-r--r--examples/example-pathfinder/build.rs10
-rw-r--r--examples/example-pathfinder/page.toml4
-rw-r--r--examples/full-todolist/src/main.rs2
-rw-r--r--index.html2
-rw-r--r--mingling/Cargo.toml12
-rw-r--r--mingling/mingling-ci.toml2
-rw-r--r--mingling/src/example_docs.rs98
-rw-r--r--mingling/src/features.rs55
-rw-r--r--mingling/src/lib.rs4
-rw-r--r--mingling/src/setups/dirs.rs70
-rw-r--r--mingling/src/setups/exit_code.rs56
-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_cli/Cargo.lock31
-rw-r--r--mingling_cli/Cargo.toml9
-rw-r--r--mingling_cli/build.rs8
-rw-r--r--mingling_cli/src/bin/cli.rs4
-rw-r--r--mingling_cli/src/config/cmd_cfg.rs2
-rw-r--r--mingling_cli/src/lib.rs6
-rw-r--r--mingling_cli/src/linter/cmd_explain.rs2
-rw-r--r--mingling_cli/src/linter/cmd_lint.rs2
-rw-r--r--mingling_cli/src/pkg_mgr/cmd_install.rs23
-rw-r--r--mingling_cli/src/pkg_mgr/cmd_pkg_disable.rs2
-rw-r--r--mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs2
-rw-r--r--mingling_cli/src/pkg_mgr/cmd_uninstall.rs2
-rw-r--r--mingling_cli/src/proj_mgr/cmd_class_add.rs2
-rw-r--r--mingling_core/Cargo.toml9
-rw-r--r--mingling_core/mingling-ci.toml2
-rw-r--r--mingling_core/src/build.rs28
-rw-r--r--mingling_core/src/build/pathf.rs98
-rw-r--r--mingling_core/src/docs/build.md57
-rw-r--r--mingling_core/src/lib.rs7
-rw-r--r--mingling_core/tests/test-all/Cargo.lock99
-rw-r--r--mingling_core/tests/test-all/Cargo.toml1
-rw-r--r--mingling_core/tests/test-comp/Cargo.lock102
-rw-r--r--mingling_core/tests/test-comp/Cargo.toml2
-rw-r--r--mingling_macros/Cargo.toml10
-rw-r--r--mingling_macros/mingling-ci.toml2
-rw-r--r--mingling_macros/src/attr/completion.rs232
-rw-r--r--mingling_macros/src/build.rs56
-rw-r--r--mingling_macros/src/build/comp.rs (renamed from mingling_core/src/build/comp.rs)113
-rw-r--r--mingling_macros/src/build/pathf.rs19
-rw-r--r--mingling_macros/src/func/gen_program.rs65
-rw-r--r--mingling_macros/src/func/program_final_gen.rs22
-rw-r--r--mingling_macros/src/lib.rs87
-rw-r--r--mingling_macros/tmpls/comps/bash.sh (renamed from mingling_core/tmpls/comps/bash.sh)0
-rw-r--r--mingling_macros/tmpls/comps/fish.fish (renamed from mingling_core/tmpls/comps/fish.fish)0
-rw-r--r--mingling_macros/tmpls/comps/pwsh.ps1 (renamed from mingling_core/tmpls/comps/pwsh.ps1)0
-rw-r--r--mingling_macros/tmpls/comps/zsh.zsh (renamed from mingling_core/tmpls/comps/zsh.zsh)0
-rw-r--r--mingling_pathf/Cargo.toml1
-rw-r--r--mingling_pathf/mingling-ci.toml2
-rw-r--r--mingling_pathf/src/error.rs6
-rw-r--r--mingling_pathf/src/lib.rs2
-rw-r--r--mingling_pathf/src/type_mapping_builder.rs76
-rw-r--r--mingling_pathf/test/Cargo.lock138
161 files changed, 7032 insertions, 3408 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 58d7af4..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,builds,structural_renderer,repl,comp,picker,clap,extra_macros `
- --open `
- -- `
- --cfg docsrs
diff --git a/.run/src/bin/doc-nightly.sh b/.run/src/bin/doc-nightly.sh
deleted file mode 100755
index d16b6fc..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,builds,structural_renderer,repl,comp,picker,clap,extra_macros \
- --open \
- -- \
- --cfg docsrs
diff --git a/.run/src/bin/doc.ps1 b/.run/src/bin/doc.ps1
deleted file mode 100644
index d400f76..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,builds,structural_renderer,repl,comp,picker,clap,extra_macros,pathf `
- --open
diff --git a/.run/src/bin/doc.sh b/.run/src/bin/doc.sh
deleted file mode 100755
index 4229853..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,builds,structural_renderer,repl,comp,picker,clap,extra_macros,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/install-mling.ps1 b/.run/src/bin/install-mling.ps1
index 2bc28ee..2b55a09 100644
--- a/.run/src/bin/install-mling.ps1
+++ b/.run/src/bin/install-mling.ps1
@@ -6,5 +6,5 @@ New-Item -ItemType Directory -Force -Path .temp/mling/bin, .temp/mling/scripts |
Copy-Item .temp/target/release/mling.exe .temp/mling/bin/
Copy-Item .temp/target/release/mingling-cli.exe .temp/mling/bin/
-Copy-Item .temp/target/release/mling_comp.ps1 .temp/mling/scripts/mling_comp.ps1
+Copy-Item .temp/target/mingling/mling_comp.ps1 .temp/mling/scripts/mling_comp.ps1
Copy-Item mingling_cli/scripts/load_mling.ps1 .temp/mling/
diff --git a/.run/src/bin/install-mling.sh b/.run/src/bin/install-mling.sh
index 139e221..e8cfa18 100755
--- a/.run/src/bin/install-mling.sh
+++ b/.run/src/bin/install-mling.sh
@@ -10,7 +10,7 @@ cp .temp/target/release/mling .temp/mling/bin/
cp .temp/target/release/mingling-cli .temp/mling/bin/
for comp in zsh sh fish; do
- cp ".temp/target/release/mling_comp.$comp" ".temp/mling/scripts/mling_comp.$comp"
+ cp ".temp/target/mingling/mling_comp.$comp" ".temp/mling/scripts/mling_comp.$comp"
done
cp mingling_cli/scripts/load_mling.zsh .temp/mling/
cp mingling_cli/scripts/load_mling.sh .temp/mling/
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/.run/src/verify.rs b/.run/src/verify.rs
index 0a4b354..b79bb73 100644
--- a/.run/src/verify.rs
+++ b/.run/src/verify.rs
@@ -215,16 +215,15 @@ pub fn generate_cargo_toml(block: &CodeBlock, package_name: &str, manifest_path:
)
};
- // Build-time blocks: add `builds` by default, merge with explicit features
+ // Build-time blocks: mirror the declared features into [build-dependencies]
+ // so that build.rs can use the same feature set as the crate itself.
let build_deps_section = if block.is_build_time {
- let mut all_feats = vec!["builds".to_string()];
- for f in &block.features {
- if f != "builds" {
- all_feats.push(f.clone());
- }
- }
- let feats_str: Vec<String> = all_feats.iter().map(|f| format!("\"{f}\"")).collect();
- let build_feats = format!("features = [{}]", feats_str.join(", "));
+ let feats_str: Vec<String> = block.features.iter().map(|f| format!("\"{f}\"")).collect();
+ let build_feats = if feats_str.is_empty() {
+ String::new()
+ } else {
+ format!("features = [{}]", feats_str.join(", "))
+ };
format!(
"\n[build-dependencies]\nmingling = {{ path = \"{mingling_path}\", {build_feats} }}\n"
)
@@ -292,14 +291,10 @@ pub fn generate_main_rs(block: &CodeBlock) -> String {
/// Generate build.rs for a build-time block
///
-/// Default: `use mingling::builds::*;`, code wrapped in `fn main() { }`.
+/// Default: code wrapped in `fn main() { }`.
pub fn generate_build_rs(block: &CodeBlock) -> String {
let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n");
- if !block.code.contains("use mingling::build::*;") {
- output.push_str("#[allow(unused_imports)]\nuse mingling::build::*;\n\n");
- }
-
if block.has_main {
output.push_str(&block.code);
} else {
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/CHANGELOG.md b/CHANGELOG.md
index 7bbce12..ed8d388 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -58,7 +58,7 @@ None
#### Fixes:
-None
+1. **[`macros:gen_program`]** Fixed the empty `do_chain` fallback generated by `program_final_gen` to respect the `async` feature. When a program has no chains registered, the synthesized `do_chain` previously always emitted the synchronous signature `fn do_chain(...) -> ChainProcess<Self::Enum>`, which fails to compile under the `async` feature with E0053 (method signature does not match the `ProgramCollect` trait, which requires a `Pin<Box<dyn Future<Output = ChainProcess> + Send>>` return in async mode). The generator now checks the compile-time `ASYNC_ENABLED` flag for the empty-chain case, mirroring the non-empty branch: when async is enabled it emits the boxed-future signature with a `Box::pin(async { panic!(...) })` body, and otherwise emits the synchronous signature. This fixes programs that declare zero chains (relying solely on entry/fallback behavior) when built with the `async` feature.
#### Optimizations:
@@ -367,6 +367,135 @@ None
_Behavioral note:_ the runtime semantics of pipeline types are unchanged — `#[derive(Grouped, Wrap)]` produces types with the same `Grouped` identity, `Into<AnyOutput>`/`Into<ChainProcess>` routing, `Deref`/`DerefMut`, and `From`/`Into` conversions that `pack!` provided. The removal is purely an API move from magic macros to standard Rust derives, reducing macro surface area and making pipeline types inspectable and composable like any other struct.
+6. **[`macros:completion`]** **[BREAKING]** Changed the `#[completion]` attribute macro's context-parameter semantics: completion functions now take the **owned** `ShellContext` (or any `From<&ShellContext>` type) by value, and `&ShellContext` is no longer accepted.
+
+ ### What changed
+
+ Previously, the completion function's context parameter could be `&ShellContext` (the classic form) or an owned `ShellContext` / any `From<&ShellContext>` type. Now the reference form is rejected: reference parameters (`&T` / `&mut T`) are reserved exclusively for **resource injection**, matching `#[chain]` semantics, so the parser in `mingling_macros/src/attr/completion.rs` was reworked to classify each parameter as either:
+
+ - **Owned (non-reference) parameter** — a _shell source_: derived from `&ShellContext` via `<#ty as From<&ShellContext>>::from(ctx)`. This covers `ShellContext` itself (via its new `Clone`-based `From` impl), framework state types, and any user-defined type derived from the shell context. Multiple owned parameters are allowed; each gets its own derived binding (`__ctx_derived_{idx}`).
+ - **`&T` / `&mut T` reference parameter** — a _resource injection_, identical to the parameter position used by `#[chain]`. Requires a simple-identifier binding. `&ShellContext` specifically is rejected with a compile error: "`&ShellContext` is not supported; use the owned `ShellContext` (or any other type implementing `From<&ShellContext>`) as a value parameter".
+
+ A helper `is_shell_context_path(ty)` detects a path whose last segment is `ShellContext` (covering `ShellContext` and `mingling::ShellContext` alike).
+
+ Previously, resource injection only started _after_ the first (context) parameter, and a completion function with no context parameter could not inject resources (compile error). Now, ownership of the parameter — not its position — determines its role: owned parameters are shell sources, references are resources, and they may be freely interleaved. The "no context → no resources" restriction is gone entirely.
+
+ The generated `Completion::comp` body now emits:
+
+ 1. A derived-binding statement for each owned parameter.
+ 2. The immut-resource binding statements (for `&T` injections).
+ 3. The mut-resource wrapper / call (for `&mut T` injections).
+ 4. The return statement applying the `Into<Suggest>` conversion (`()` → empty `Suggest`).
+
+ **Migration guide:**
+
+ - Change every `ctx: &ShellContext` parameter to `ctx: ShellContext`. The owned type behaves identically for reads; only the declared parameter type changes.
+ - Code that previously relied on `&ShellContext` in the _middle_ of the signature no longer needs special treatment: owned parameters anywhere are treated as shell sources.
+ - `_ctx: &ShellContext` (unused parameter) becomes `_ctx: ShellContext`.
+
+ _All internal call sites, examples, docs, and tests updated_ (e.g., `mingling_cli` completion handlers, `example-completion`, `example-enum-tag`, `GETTING-STARTED.md`, `docs/pages/advanced/1-completion.md`, `docs/_zh_CN/pages/advanced/1-completion.md`, and `mingling/src/example_docs.rs`).
+
+7. **[`build`]** **[BREAKING]** Replaced the `build` / `builds` build-time feature system with compile-time macro-driven build steps. The `build` feature, `builds` feature, `build_advanced` preset, `build_full` preset, `mingling::build` module, and the entire `build.rs`-based workflow have been removed. Build steps (completion script generation and pathf type-mapping analysis) now run automatically as a side effect of `gen_program!()` expansion via new `build_comp!()` / `build_pathf!()` macros.
+
+ ### What changed
+
+ Previously, build-time functionality was enabled through the `build` feature (and preset groups `build_advanced` / `build_full`, plus the deprecated `builds` alias). Users needed a `[build-dependencies.mingling]` entry in `Cargo.toml` and a hand-written `build.rs` that called `mingling::build::build_comp_scripts(...)` (for completion scripts) and `mingling::build::analyze_and_build_type_mapping()` (for pathf). These functions were gated behind the `build` + `comp` / `build` + `pathf` feature combinations and read `OUT_DIR` to locate the output directory.
+
+ Now, the build steps are integrated directly into macro expansion:
+
+ - **`gen_program!()` automatically invokes `build_comp!()`** (when the `comp` feature is enabled) and **`build_pathf!()`** (when the `pathf` feature is enabled) at the very start of its expansion. These macros run the build logic as a compile-time side effect and expand to nothing.
+ - **`build_comp!()`** is a proc macro (re-exported as `mingling::macros::build_comp`) that generates completion scripts into `{target_directory}/mingling/`. It accepts an optional string literal for the binary name; without an argument it defaults to `CARGO_PKG_NAME`. On failure it emits a `compile_error!`.
+ - **`build_pathf!()`** is a proc macro (re-exported as `mingling::macros::build_pathf`) that runs the pathf type-mapping analysis, writing mapping files into `{target_directory}/mingling/{CARGO_PKG_NAME}/`. On failure it emits a `compile_error!`.
+ - **No `build.rs` is required anymore.** Build logic runs from proc-macro expansion, so no `[build-dependencies.mingling]` entry, no `build` feature, and no `build.rs` file are needed.
+
+ **Removed API:**
+
+ - **`build` feature** — Removed from `mingling/Cargo.toml` and `mingling_core/Cargo.toml`. The `build = ["mingling_core/build"]` feature mapping and the `mingling_core/build` feature have been deleted.
+ - **`builds` feature** — Removed (deprecated alias, mapped to `mingling_core/build`).
+ - **`build_advanced` / `build_full` preset features** — Removed from `mingling/Cargo.toml` feature groups.
+ - **`mingling::build` module** — Removed entirely from `mingling_core`:
+ - `mingling_core/src/build.rs` and the `mingling_core/src/build/` directory deleted.
+ - `mingling_core/src/docs/build.md` deleted.
+ - `mingling_core/src/lib.rs` no longer gates `pub mod build` behind the `build` feature.
+ - **Build functions** — Removed: `build_comp_scripts`, `build_comp_script`, `build_comp_script_to`, `build_comp_script_to_file`, `analyze_and_build_type_mapping`, `analyze_and_build_type_mapping_for`, `analyze`.
+ - **`MINGLING_BUILD` / `MINGLING_BUILDS` / `MINGLING_BUILD_ADVANCED` / `MINGLING_BUILD_FULL` feature constants** — Removed from `mingling/src/features.rs`.
+ - **`mingling_core` dependencies** — Removed `just_template` (comp) and `mingling_pathf` (pathf) from `mingling_core/Cargo.toml`; these moved to `mingling_macros` as optional dependencies gated behind the `comp` / `pathf` features.
+ - **`mingling_macros` feature wiring** — Changed `comp = []` to `comp = ["dep:just_template", "dep:mingling_pathf"]` and `pathf = []` to `pathf = ["dep:mingling_pathf"]`; `mingling` crate's `pathf` feature no longer forwards to `mingling_core/pathf`.
+ - **`mingling::build::pathf` error re-exports** — `mingling_core::error` no longer re-exports `mingling_pathf::error::*`.
+
+ **New internal infrastructure:**
+
+ - **`mingling_macros/src/build.rs`** — New module hosting `comp_build_impl` (behind `comp`) and `pathf_build_impl` (behind `pathf`), which parse the macro input and delegate to the build logic, converting errors into `compile_error!` token streams.
+ - **`mingling_macros/src/build/comp.rs`** — Moved from `mingling_core/src/build/comp.rs` (with the shell templates, which moved from `mingling_core/tmpls/comps/` to `mingling_macros/tmpls/comps/`). Contains a private copy of `ShellFlag` (since the macros crate cannot depend on `mingling_core`); the template files are identical. Scripts are written to `{target_directory}/mingling/` resolved via the new `mingling_pathf::build_output_dir()`.
+ - **`mingling_macros/src/build/pathf.rs`** — New module providing `output_dir()` (`{target_directory}/mingling/{CARGO_PKG_NAME}`) and `analyze_and_build_type_mapping()` delegating to `mingling_pathf`.
+ - **`mingling_pathf::build_output_dir()`** — New public function resolving `{target_directory}/mingling/` via `cargo metadata` (from `CARGO_MANIFEST_DIR`).
+ - **`mingling_pathf::target_directory()`** — New public function running `cargo metadata` (`no_deps`) from a crate directory and returning the target directory.
+ - **`MinglingPathfinderError::CargoMetadata(String)`** — New error variant added to `mingling_pathf`'s error enum.
+ - **`cargo_metadata` dependency** — Added to `mingling_pathf` (workspace, version `0.23.1`) and to the root workspace `Cargo.toml`.
+
+ **`gen_program!()` changes** (`mingling_macros/src/func/gen_program.rs`):
+ - Emits `::mingling::macros::build_comp!();` at the start of the expansion when `comp` is enabled (and `::mingling::macros::build_pathf!();` when `pathf` is enabled).
+ - The pathf `use`-statement loading now runs the analysis inline via `crate::build::pathf::analyze_and_build_type_mapping()` (so the mapping exists when the `use` statements are read) and loads `type_using.rs` from `crate::build::pathf::output_dir()`.
+ - The `load_pathf_uses` function now reads from `{target_directory}/mingling/{CARGO_PKG_NAME}/type_using.rs` instead of `{OUT_DIR}/{CARGO_PKG_NAME}/type_using.rs`.
+ - The empty-uses `compile_error!` hint was reworded: it no longer mentions `build.rs` or the `build` feature; it now says the analyzer found no types and suggests ensuring the `pathf` feature is enabled and `gen_program!()` is called in a crate with a `src/` directory.
+ - `mingling_pathf::analyze_and_build_type_mapping` no longer emits `cargo:rerun-if-changed=src/` / `cargo:rerun-if-env-changed=...` directives (there is no build script for Cargo to track).
+
+ **Migration guide:**
+
+ - **Delete `build.rs`** (and the `[build-dependencies]` block in `Cargo.toml`). Completion scripts are generated automatically when the `comp` feature is enabled; pathf analysis and any `[build-dependencies.mingling]`). If a feature list references only these, delete the whole section.
+ - **If your binary name differs from the crate name**, call `build_comp!()` manually with the binary name:
+ ```rust
+ // Features: ["comp"]
+ mingling::macros::build_comp!("mybin");
+ ```
+ This can be placed at module scope (e.g., in `src/lib.rs` or `src/main.rs`) alongside `gen_program!()`.
+ - **Remove any `mingling::build::...` imports.**
+ - **Example/build artifacts**: The completion scripts are now written to `{target_directory}/mingling/` rather than `{target_directory}/release/` or the `OUT_DIR`-derived path. Any scripts that copied them from the release directory must be updated (e.g., `.run/src/bin/install-mling.sh` now copies from `.temp/target/mingling/mling_comp.$comp`, and `.run/src/bin/install-mling.ps1` from `.temp/target/mingling/mling_comp.ps1`).
+ - **`mingling_cli`**: `build.rs` no longer calls `analyze_and_build_type_mapping` / `build_comp_scripts`; `mingling_cli/src/lib.rs` now invokes `mingling::macros::build_comp!("mling")` to generate scripts for the `mling` binary. `StateInstallBuild` / `StateInstallCopy` gained a `mingling_dir` field (`{target}/mingling/`) and the install copy step reads completion scripts from `{target}/mingling/` instead of `{target}/release/`.
+ - **Tests/examples**: Removed the `builds` feature from `mingling_core/tests/test-all`, `mingling_core/tests/test-comp`, and all pathf/completion examples, and deleted the corresponding `build.rs` files and `[build-dependencies]` blocks.
+
+ _Behavioral note:_ the runtime behavior of programs is unchanged — completion scripts and pathf type mappings are still produced, just from compile-time macro expansion instead of a separate `build.rs` step. The output directory changed from an `OUT_DIR`-derived path (effectively `{target}/<profile>` style) to a dedicated `{target_directory}/mingling/` directory resolved via `cargo metadata`, which is deterministic regardless of build profile.
+
+8. **[`Cargo.toml`]** Removed the legacy `extra_macros` feature alias from `mingling/Cargo.toml`. The `extras` feature (introduced in 0.4.0, BREAKING CHANGE #1) is now the sole name for this feature; the deprecated alias is gone.
+
+9. **[`setups:dirs`]** **[BREAKING]** Simplified the `DirectoryEnvironmentSetup` type — it is no longer generic over the program collect type `C` and no longer requires `DirectoryEnvironmentSetup::<C>::default()` to construct.
+
+ ### What changed
+
+ Previously, `DirectoryEnvironmentSetup` was a generic struct `DirectoryEnvironmentSetup<C>` (carrying `PhantomData<C>`) that had to be constructed via `DirectoryEnvironmentSetup::<C>::default()` before calling `Program::with_setup`. Now the struct is unit-like (`pub struct DirectoryEnvironmentSetup;`), so it can be constructed directly as a value with no `::default()` call and no generic parameter.
+
+ Additionally, the `setup` method's `program` parameter was retyped from `crate::Program<C>` to `mingling_core::Program<C>` for cleanliness.
+
+ ### Removed / changed API
+ - **`DirectoryEnvironmentSetup<C>`** → **`DirectoryEnvironmentSetup`** — The struct no longer has a generic parameter (previously `DirectoryEnvironmentSetup<C>` with `PhantomData<C>`).
+ - **`impl<C> Default for DirectoryEnvironmentSetup<C>`** — Removed. The unit struct uses the derived/implicit `Default`, and more importantly construction is now just the plain value `DirectoryEnvironmentSetup`, not `DirectoryEnvironmentSetup::<C>::default()`.
+ - **`impl<C> ProgramSetup<C> for DirectoryEnvironmentSetup<C>`** → **`impl<C> ProgramSetup<C> for DirectoryEnvironmentSetup`** — The `ProgramSetup` impl is now on the unit type.
+
+ ### Migration guide
+ - Replace `program.with_setup(DirectoryEnvironmentSetup::<ThisProgram>::default())` with `program.with_setup(DirectoryEnvironmentSetup)`.
+ - Any type annotations referencing `DirectoryEnvironmentSetup<C>` must drop the generic argument.
+
+ _No behavioral changes — the setup still registers the same four directory resources (`ResCurrentDir`, `ResCurrentExe`, `ResHomeDir`, `ResTempDir`) in the program's resource store. The type simplification is purely ergonomic.
+
+10. **[`setups:exit_code`]** **[BREAKING]** Simplified the `ExitCodeSetup` type — it is no longer generic over the program collect type `C` and no longer requires `ExitCodeSetup::<C>::default()` to construct.
+
+ ### What changed
+
+ Previously, `ExitCodeSetup` was a generic struct `ExitCodeSetup<C>` (carrying `PhantomData<C>`) that had to be constructed via `ExitCodeSetup::<C>::default()` before calling `Program::with_setup`. Now the struct is unit-like (`pub struct ExitCodeSetup;`), so it can be constructed directly as a value with no `::default()` call and no generic parameter.
+
+ Additionally, the `setup` method's `program` parameter was retyped from `crate::Program<C>` to `mingling_core::Program<C>` for cleanliness.
+
+ ### Removed / changed API
+ - **`ExitCodeSetup<C>`** → **`ExitCodeSetup`** — The struct no longer has a generic parameter (previously `ExitCodeSetup<C>` with `PhantomData<C>`).
+ - **`impl<C> Default for ExitCodeSetup<C>`** — Removed. The unit struct uses the derived/implicit `Default`, and more importantly construction is now just the plain value `ExitCodeSetup`, not `ExitCodeSetup::<C>::default()`.
+ - **`impl<C> ProgramSetup<C> for ExitCodeSetup<C>`** → **`impl<C> ProgramSetup<C> for ExitCodeSetup`** — The `ProgramSetup` impl is now on the unit type.
+
+ ### Migration guide
+ - Replace `program.with_setup(ExitCodeSetup::<ThisProgram>::default())` (or `ExitCodeSetup::default()`) with `program.with_setup(ExitCodeSetup)`.
+ - Any type annotations referencing `ExitCodeSetup<C>` must drop the generic argument.
+
+ _No behavioral changes — the setup still registers the same `ResExitCode` resource (initialised to `0`) and installs the same program-finish hook that overrides the program's exit code when the resource holds a non-zero value. The type simplification is purely ergonomic._
+
---
## Contents
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 779bc3e..6e34e93 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,22 +6,24 @@ Before contributing, we recommend reading [README](README.md) to get an overview
## 1. Project Structure 📦
-| Category | Path/Name | Description |
-| --------------------------- | -------------------- | -------------------------------------------------------------------- |
-| **Entry crate** | `mingling/` | Project entry point |
-| **Core library** | `mingling_core/` | Imported as an external dependency |
-| **Macro library** | `mingling_macros/` | Imported as an external dependency |
-| **Mingling Pathfinder** | `mingling_pathf/` | Build-time module path resolution for types |
-| **Mingling Picker2** | `arg_picker/` | Mingling Arguments Parser |
-| **Mingling Picker2 Macros** | `arg_picker_macros/` | Mingling Arguments Parser Macros |
-| **Scaffolding tool** | `mling/` | Scaffolding tool `mingling-cli` |
-| **Examples** | `examples/` | To add expected output tests, add a `test.toml` in the example's dir |
-| **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` |
-| **Temporary files** | `.temp/` | Ignored by `.gitignore` |
+| Category | Path/Name | Description |
+| --------------------------- | -------------------- | ----------------------------------------------------------------------- |
+| **Entry crate** | `mingling/` | Project entry point |
+| **Core library** | `mingling_core/` | Imported as an external dependency |
+| **Macro library** | `mingling_macros/` | Imported as an external dependency |
+| **Mingling Pathfinder** | `mingling_pathf/` | Build-time module path resolution for types |
+| **Mingling Picker2** | `arg_picker/` | Mingling Arguments Parser |
+| **Mingling Picker2 Macros** | `arg_picker_macros/` | Mingling Arguments Parser Macros |
+| **Scaffolding tool** | `mling/` | Scaffolding tool `mingling-cli` |
+| **Examples** | `examples/` | To add expected output tests, add a `test.toml` in the example's dir |
+| **Documents** | `docs/` | All documents |
+| **Dev Documents** | `docs/dev/` | Internal documents |
+| **Resources** | `docs/res/` | All resources |
+| **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.
@@ -186,3 +190,7 @@ Mingling uses the **MIT + Apache 2.0** dual license. For details, please see:
- [LICENSE-MIT](LICENSE-MIT)
- [LICENSE-APACHE](LICENSE-APACHE)
+
+# Contributors
+
+<img src="https://contrib.rocks/image?repo=mingling-rs/mingling" />
diff --git a/Cargo.lock b/Cargo.lock
index b5116a3..fbcfb31 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -75,7 +75,7 @@ version = "0.2.0"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -94,6 +94,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -193,7 +226,7 @@ checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -226,7 +259,7 @@ checksum = "1471eb68722ecefeb71debdde2859e8725341f171d3f42b3a98a0862ad19416e"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -264,7 +297,7 @@ checksum = "eca871cea620b07bd2e6da0c883891a25bead698c43a9ab64b0fd663a7a78d5f"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
"toml 0.8.23",
]
@@ -290,10 +323,8 @@ version = "0.5.0"
dependencies = [
"env_logger",
"just_fmt 0.2.0",
- "just_template",
"log",
"might_be_async",
- "mingling_pathf",
"ron",
"serde",
"serde_json",
@@ -307,18 +338,21 @@ name = "mingling_macros"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
+ "just_template",
+ "mingling_pathf",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
name = "mingling_pathf"
version = "0.5.0"
dependencies = [
+ "cargo_metadata",
"just_fmt 0.2.0",
"proc-macro2",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -471,6 +505,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -497,7 +541,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -582,6 +626,17 @@ dependencies = [
]
[[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 = "temp-env"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -591,6 +646,26 @@ dependencies = [
]
[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -615,7 +690,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 6b0e6b3..595053b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,6 +39,7 @@ arg-picker-macros = { path = "arg_picker_macros", default-features = false }
just_fmt = "0.2.0"
just_template = "0.2.0"
+cargo_metadata = "0.23.1"
might_be_async = "0.1.0"
diff --git a/GETTING-STARTED.md b/GETTING-STARTED.md
index 0730136..e4a706a 100644
--- a/GETTING-STARTED.md
+++ b/GETTING-STARTED.md
@@ -201,7 +201,7 @@ dispatcher!("greet", EntryGreet);
pub struct ResultName((u8, String));
#[completion(EntryGreet)]
-fn complete_greet(ctx: &ShellContext) -> Suggest {
+fn complete_greet(ctx: ShellContext) -> Suggest {
// Suggest positional arguments
if ctx.previous_word == "greet" {
return suggest! {
@@ -212,12 +212,11 @@ fn complete_greet(ctx: &ShellContext) -> Suggest {
}
// Suggest flag arguments
- if ctx.typing_argument() {
+ if ctx.current_word.starts_with('-') {
return suggest! {
"-r": "Number of repetitions",
"--repeat": "Number of repetitions",
- }
- .strip_typed_argument(ctx);
+ };
}
suggest!() // no suggestions
@@ -235,13 +234,9 @@ fn main() {
}
```
-In your `build.rs`, generate the shell scripts:
-
-```rust
-// BUILD TIME
-// Features: ["comp", "build"]
-mingling::build::build_comp_scripts(env!("CARGO_PKG_NAME")).unwrap();
-```
+The completion scripts are generated automatically: with the `comp` feature enabled,
+`gen_program!()` invokes `build_comp!()` at compile time and writes the shell scripts
+(named after `CARGO_PKG_NAME`) into `target/mingling/`.
For enum-based completions, use `suggest_enum!`:
@@ -262,7 +257,7 @@ pub enum ProgrammingLanguages {
}
#[completion(EntryLang)]
-fn complete_lang(_: &ShellContext) -> Suggest {
+fn complete_lang(_: ShellContext) -> Suggest {
suggest_enum!(ProgrammingLanguages)
}
```
diff --git a/README.md b/README.md
index a7781d0..2059384 100644
--- a/README.md
+++ b/README.md
@@ -12,22 +12,22 @@
<p align="center">
Macro magician in your CLI.
</p>
+
<p align="center">
- <img src="https://img.shields.io/github/license/mingling-rs/mingling">
- <img src="https://img.shields.io/github/stars/mingling-rs/mingling?style=flat">
- <img src="https://img.shields.io/crates/size/mingling">
- <img src="https://img.shields.io/crates/v/mingling?style=flat">
- <img src="https://img.shields.io/docsrs/mingling?style=flat">
- <img src="https://img.shields.io/github/actions/workflow/status/mingling-rs/mingling/ci.yml">
+ <img alt="License" src="https://img.shields.io/github/license/mingling-rs/mingling?style=for-the-badge&color=yellow">
+ <img alt="GitHub stars" src="https://img.shields.io/github/stars/mingling-rs/mingling?style=for-the-badge&color=yellow">
+ <img alt="Crate size" src="https://img.shields.io/crates/size/mingling?style=for-the-badge">
+ <img alt="Crates.io version" src="https://img.shields.io/crates/v/mingling?style=for-the-badge">
+ <img alt="CI" src="https://img.shields.io/github/actions/workflow/status/mingling-rs/mingling/ci.yml?style=for-the-badge">
</p>
## What is Mingling?
[`Mingling`](https://github.com/mingling-rs/mingling) is a **state-driven and data-driven** CLI workflow orchestration framework built in Rust.
-💡 Its name comes from the Chinese pinyin **"Mìng Lìng"**, which means **"command"**.
+Its name comes from the Chinese pinyin **"Mìng Lìng"**, which means **"command"**.
-## ⚠WARNING
+## WARNING
Mingling is currently usable at a basic level, but it is still under active development, so many APIs are not yet mature. Any changes to the public API will be documented in detail in the [Changelog](https://github.com/mingling-rs/mingling/blob/main/CHANGELOG.md).
@@ -35,69 +35,104 @@ Additionally, the project is currently developed by me alone ([Weicao-CatilGrass
## About Mingling's Design
-Mingling abstracts the behavior of a program's lifecycle into three phases: **Dispatch**, **Execution**, and **Rendering**. Each phase is connected by types — the output of the current phase becomes the input of the next phase. For example:
+Mingling aims to organize and manage the architectural concerns of command-line programs through reasonable abstractions: it breaks a program down into the following concepts:
+
+| Concept | Description |
+| -------------: | :--------------------------------------------------------------- |
+| **Command** | A combination of **Dispatcher** and **Chain** |
+| **Dispatcher** | Maps user input to entry types |
+| **Chain** | Provides behavioral logic for any type and returns the next type |
+| **Renderer** | Renders any type into output-ready text |
+| **Resource** | Provides global **data** for the program |
+| **Hook** | Provides global **behavior** for the program |
+
+## Example
+
+Below is a typical Mingling program that demonstrates how to implement a simple adder:
```rust
-dispatcher!("current", EntryCurrent);
+// Features: ["mini"]
#[derive(Grouped)]
-pub struct StateNext;
-
-#[chain]
-fn handle_current(_: EntryCurrent) -> StateNext {
- // 1. The first phase outputs the StateNext value
- StateNext // ^^^^^^^^^
-} // |
- // |
- // 2. The second phase takes StateNext as input
-#[chain] // |
-fn handle_state_next(_: StateNext) {
- todo!()
+struct ResultNumber(f32);
+
+#[command]
+fn sum(args: Entry) -> ResultNumber {
+ let (a, b) = args
+ .pick(&arg![f32])
+ .pick(&arg![f32])
+ .unwrap();
+ ResultNumber(a + b)
+}
+
+#[renderer]
+fn render_number(n: ResultNumber) -> String {
+ format!("Result is {}", n.0)
}
```
-See? `handle_current` and `handle_state_next` have no direct connection!
+Output:
-They are bridged by `StateNext` and automatically linked by the framework.
+```bash
+~# my-cli sum 5 10
+Result is 15
+```
-You can use this approach to separate computation from result rendering, like this:
+If we add full error handling, it would look like this:
```rust
-// Features: ["picker"]
-use mingling::macros::buffer;
-use mingling::prelude::*;
+// Features: ["mini"]
-dispatcher!("calc", EntryCalculate);
+use mingling::macros::routeify;
+use mingling::setup::ExitCodeSetup;
+use mingling::res::ResExitCode;
-#[derive(Grouped, Wrap)]
-pub struct StateSumNumbers(Vec<i32>);
+fn main() {
+ let mut program = ThisProgram::new();
+ program.with_setup(ExitCodeSetup);
+ program.exec_and_exit();
+}
-#[derive(Grouped, Wrap)]
-pub struct ResultNumber(i32);
+#[derive(Grouped)]
+struct ResultNumber(f32);
-// Entry: parse arguments and pass state to the calculation step
-#[chain]
-fn handle_calc(args: EntryCalculate) -> StateSumNumbers {
- let numbers = args.pick(&arg![Vec<i32>]).unwrap();
- StateSumNumbers(numbers)
+#[derive(Grouped)]
+struct ErrorNoNumber;
+
+#[command(routeify)]
+fn sum(args: Entry) -> Next {
+ let (a, b) = args
+ .pick_or_route(&arg![f32], || ErrorNoNumber.into())
+ .pick_or_route(&arg![f32], || ErrorNoNumber.into())
+ .to_result()?;
+ ResultNumber(a + b).into()
}
-// Calculate: pass the result to the rendering step
-#[chain]
-fn handle_state_sum_numbers(sum: StateSumNumbers) -> ResultNumber {
- let numbers = sum.0;
- let total: i32 = numbers.iter().sum();
- ResultNumber(total)
+#[renderer]
+fn render_error_no_num(n: ErrorNoNumber, ec: &mut ResExitCode) -> String {
+ ec.exit_code = 1;
+ format!("Error: No number provided.")
}
-// Renderer: return the render result and let the framework handle output
-#[renderer(buffer)]
-fn render_number(number: ResultNumber) {
- r_println!("Number: {}", *number);
+#[renderer]
+fn render_number(n: ResultNumber) -> String {
+ format!("Result is {}", n.0)
}
```
-Although this may make your program slightly more verbose, each step is a **pure function**, making it extremely easy to test!
+Output:
+
+```bash
+~# my-cli sum 5 10
+Result is 15
+
+~# my-cli sum
+Error: No number provided. << 1
+```
+
+See! By assembling `ExitCodeSetup` and modifying `ResExitCode`, we **explicitly** mark the side effect of changing the exit code on the `render_error_no_num` function. This is exactly the problem Mingling aims to solve: **separating concerns by separating side effects through architecture**.
+
+Of course, by combining the concepts above, you can elegantly separate the side effects of your program into independent resources, keeping your execution functions pure.
## Getting Started
@@ -123,31 +158,6 @@ To learn more, check out [Writing with Mingling](https://github.com/mingling-rs/
> [!Note]
> You can also use the `mling` scaffolding tool to build, check, and manage your project [Download](https://mingling-rs.github.io/mingling/dist) | [About](https://github.com/mingling-rs/mingling/tree/main/mingling_cli)
-## Roadmap
-
-- [x] Milestone.1 "MVP" 🎉
- - [x] [[0.1.4](https://docs.rs/mingling/0.1.4/mingling/)] [`core`] [`structural_renderer`] **Mingling** can render data into serializable formats via `--json` and `--yaml` flags
- - [x] [[0.1.5](https://docs.rs/mingling/0.1.5/mingling/)] [`core`] [`comp`] **Mingling** can dynamically invoke itself to provide completions for shells like `bash`, `zsh`, `fish`, and `pwsh`
- - [x] [[0.1.6](https://docs.rs/mingling/0.1.6/mingling/)] [`core`] [`comp`] **Mingling** can gather more context for smarter completions
- - [x] [[0.1.7](https://docs.rs/mingling/0.1.7/mingling/)] [`clap`] Provides a **Clap** compatibility layer, allowing **Mingling** to reuse its powerful parsing capabilities
- - [x] [[0.1.7](https://docs.rs/mingling/0.1.7/mingling/)] [`core`] **Mingling** can intercept `-h` or `--help` flags to display custom help text for each subcommand
- - [x] [[0.1.7](https://docs.rs/mingling/0.1.7/mingling/)] [`mling`] Provides a basic scaffolding tool (`mling`) for rapid development and debugging
- - [x] [[0.1.8](https://docs.rs/mingling/0.1.8/mingling/)] [`core`] [`dispatch_tree`] Converts the subcommand list into a prefix tree to improve command matching speed
- - [x] [[0.1.9](https://docs.rs/mingling/0.1.9/mingling/)] [`core`] [`dev_toolkits`] Provides debugging interfaces for developers to capture invocation information when issues arise (`InvokeStackDisplay`) (indirectly implemented via `ProgramHook`)
- - [x] [[0.1.9](https://docs.rs/mingling/0.1.9/mingling/)] [`core`] [`repl`] Provides REPL capability (`program.exec_repl();`)
- - [x] [[0.2.0](https://docs.rs/mingling/0.2.0/mingling/)] Complete documentation, tests, and examples
-- [ ] Milestone.2 "More Comfortable Dev and User Experience"
- - [ ] [`mling` / `mingling-cli`]
- - [x] **Mingling** Linter
- - [x] **Mingling** Project Generator
- - [x] **Mingling** Program Installer & Manager (For development)
- - [ ] Helpdoc Editor
- - [x] [`picker`] A more efficient and intelligent argument parser
- - [x] [`macros`] ~~Remove r_print! / r_println! macros~~ (see below)
- - [x] [`macros`] Make implicit modifications to functions explicit
-- [ ] Milestone.3 "Unplanned"
- - [ ] ...
-
## Unplanned Features
While Mingling has several common CLI features that are **NOT PLANNED** to be directly included in the framework.
@@ -158,12 +168,6 @@ This is because the Rust ecosystem already has excellent and mature crates to ha
- **Progress Bars**: To display progress indicators, the [`indicatif`](https://crates.io/crates/indicatif) crate is the standard choice.
- **TUI**: To build full-screen interactive terminal applications, it is recommended to use a framework like [`ratatui`](https://crates.io/crates/ratatui) (formerly `tui-rs`).
-## License
-
-This project is licensed under the MIT License.
-
-See [LICENSE-MIT](LICENSE-MIT) or [LICENSE-APACHE](LICENSE-APACHE) file for details.
-
## Learn More
**To learn more, check out the following links:**
@@ -176,3 +180,12 @@ See [LICENSE-MIT](LICENSE-MIT) or [LICENSE-APACHE](LICENSE-APACHE) file for deta
- 📖 API Doc - [docs.rs](https://docs.rs/mingling/latest/mingling/) | [latest](https://mingling-rs.github.io/mingling/docs/api-docs/mingling/)
- 📖 Coverage Test - [LLVM Coverage](https://mingling-rs.github.io/mingling/docs/cov-test/)
- 📖 Dev Doc - [Github](https://mingling-rs.github.io/mingling/docs/dev/)
+
+- 📖 Contribution - [CONTRIBUTING.md](./CONTRIBUTING.md)
+- 🗺 Roadmap - [ROADMAP.md](./ROADMAP.md)
+
+## License
+
+This project is licensed under the MIT License.
+
+See [LICENSE-MIT](LICENSE-MIT) or [LICENSE-APACHE](LICENSE-APACHE) file for details.
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..ed84ea5
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,33 @@
+# Roadmap
+
+## Milestone.1 "MVP" 🎉
+
+This milestone completes the minimum viable version of Mingling. It does not introduce [semver](https://semver.org/) semantics; instead, it reaches a usable state through rapid Breaking Patches.
+
+- [x] [[0.1.4](https://docs.rs/mingling/0.1.4/mingling/)] [`core`] [`structural_renderer`] **Mingling** can render data into serializable formats via `--json` and `--yaml` flags
+- [x] [[0.1.5](https://docs.rs/mingling/0.1.5/mingling/)] [`core`] [`comp`] **Mingling** can dynamically invoke itself to provide completions for shells like `bash`, `zsh`, `fish`, and `pwsh`
+- [x] [[0.1.6](https://docs.rs/mingling/0.1.6/mingling/)] [`core`] [`comp`] **Mingling** can gather more context for smarter completions
+- [x] [[0.1.7](https://docs.rs/mingling/0.1.7/mingling/)] [`clap`] Provides a **Clap** compatibility layer, allowing **Mingling** to reuse its powerful parsing capabilities
+- [x] [[0.1.7](https://docs.rs/mingling/0.1.7/mingling/)] [`core`] **Mingling** can intercept `-h` or `--help` flags to display custom help text for each subcommand
+- [x] [[0.1.7](https://docs.rs/mingling/0.1.7/mingling/)] [`mling`] Provides a basic scaffolding tool (`mling`) for rapid development and debugging
+- [x] [[0.1.8](https://docs.rs/mingling/0.1.8/mingling/)] [`core`] [`dispatch_tree`] Converts the subcommand list into a prefix tree to improve command matching speed
+- [x] [[0.1.9](https://docs.rs/mingling/0.1.9/mingling/)] [`core`] [`dev_toolkits`] Provides debugging interfaces for developers to capture invocation information when issues arise (`InvokeStackDisplay`) (indirectly implemented via `ProgramHook`)
+- [x] [[0.1.9](https://docs.rs/mingling/0.1.9/mingling/)] [`core`] [`repl`] Provides REPL capability (`program.exec_repl();`)
+- [x] [[0.2.0](https://docs.rs/mingling/0.2.0/mingling/)] Complete documentation, tests, and examples
+
+## Milestone.2 "More Comfortable Dev and User Experience"
+
+Starting from this milestone, Mingling will fully adhere to [semver](https://semver.org/) semantics, polishing the API and surrounding toolchain to ensure a comfortable and convenient command-line development experience.
+
+- [ ] [`mling` / `mingling-cli`]
+ - [x] **Mingling** Linter
+ - [x] **Mingling** Project Generator
+ - [x] **Mingling** Program Installer & Manager (For development)
+ - [ ] Helpdoc Editor
+- [x] [`picker`] A more efficient and intelligent argument parser
+- [x] [`macros`] ~~Remove r_print! / r_println! macros~~ (see below)
+- [x] [`macros`] Make implicit modifications to functions explicit
+
+## Milestone.3 "Unplanned"
+
+- [ ] ...
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/_zh_CN/pages/12-exit-code.md b/docs/_zh_CN/pages/12-exit-code.md
index 9270b84..1f9e05c 100644
--- a/docs/_zh_CN/pages/12-exit-code.md
+++ b/docs/_zh_CN/pages/12-exit-code.md
@@ -12,7 +12,7 @@
@@@use mingling::setup::ExitCodeSetup;
fn main() {
let mut program = ThisProgram::new();
- program.with_setup(ExitCodeSetup::default());
+ program.with_setup(ExitCodeSetup);
@@@ program.exec_and_exit();
}
```
@@ -55,7 +55,7 @@ fn handle_check(_args: EntryCheck, ec: &mut ResExitCode) {
@@@use mingling::setup::ExitCodeSetup;
fn main() {
let mut program = ThisProgram::new();
- program.with_setup(ExitCodeSetup::default());
+ program.with_setup(ExitCodeSetup);
// 获取退出码自行处理
let exit_code = program.exec();
diff --git a/docs/_zh_CN/pages/advanced/1-completion.md b/docs/_zh_CN/pages/advanced/1-completion.md
index 28587b3..290fab0 100644
--- a/docs/_zh_CN/pages/advanced/1-completion.md
+++ b/docs/_zh_CN/pages/advanced/1-completion.md
@@ -11,13 +11,6 @@ Mingling 的补全是**完全动态**的——没有静态的补全文件,而
# Cargo.toml
[dependencies.mingling]
features = ["comp"]
-
-[build-dependencies.mingling]
-features = [
- "comp",
- # 启用 `build` 特性以提供构建期支持
- "build"
-]
```
## 工作原理
@@ -43,7 +36,7 @@ features = [
@@@dispatcher!("greet", EntryGreet);
#[completion(EntryGreet)]
-fn complete_greet(ctx: &ShellContext) -> Suggest {
+fn complete_greet(ctx: ShellContext) -> Suggest {
if ctx.previous_word == "greet" {
let mut items = BTreeSet::new();
items.insert(SuggestItem::new_with_desc("Alice".into(), "Likes to receive messages".into()));
@@ -72,8 +65,18 @@ suggest! {
## 生成补全脚本
-在 `build.rs` 中调用 `build_comp_scripts` 生成补全脚本(需要 `builds` + `comp` 特性)。
+开启 `comp` 特性后,`gen_program!()` 会在编译期自动调用 `build_comp!()`,生成以 `CARGO_PKG_NAME` 命名的补全脚本到 `target/mingling/`。
+
+如果你的二进制名与 crate 名不同,可以手动调用 `build_comp!()` 并指定二进制名:
+```rust
+// Features: ["comp"]
+@@@use mingling::macros::build_comp;
+@@@fn example() {
+build_comp!("mybin");
+@@@}
+```
+
详见 [example-completion](https://mingling-rs.github.io/mingling/docs/example-viewer.html?name=example-completion)。
<p align="center" style="font-size: 0.85em; color: gray;">
diff --git a/docs/_zh_CN/pages/other/features.md b/docs/_zh_CN/pages/other/features.md
index 5fa7c86..f8753e5 100644
--- a/docs/_zh_CN/pages/other/features.md
+++ b/docs/_zh_CN/pages/other/features.md
@@ -25,42 +25,6 @@ Mingling 提供了一系列**预设特性组**,方便用户按需组合启用
**定位:** 完整模式,启用 Mingling 的全部核心功能。在 `advanced` 的基础上额外包含 clap 集成、完整的结构化渲染器(含所有序列化格式)以及实验性的路径分析器,适合大型、功能全面的命令行应用。
-## `build_advanced`
-
-**启用特性:** `build`、`comp`
-
-**定位:** 构建期增强配置,用于在项目构建时生成补全脚本等构建辅助材料(`comp` 特性提供补全脚本生成能力)。
-
-> [!NOTE]
->
-> 此特性组为**构建依赖**专用,需配合 `advanced` 特性使用。请在 `Cargo.toml` 的 `[build-dependencies]` 中启用:
-
-```toml
-[dependencies.mingling]
-features = ["advanced"]
-
-[build-dependencies.mingling]
-features = ["build_advanced"]
-```
-
-## `build_full`
-
-**启用特性:** `build`、`comp`、`pathf`、`dispatch_tree`
-
-**定位:** 完整的构建期配置,在 `build_advanced` 的基础上额外包含路径分析器(`pathf`)以自动解析类型模块路径,适合结构复杂、需要自动化构建期分析的项目。
-
-> [!NOTE]
->
-> 此特性组为**构建依赖**专用,需配合 `full` 特性使用。请在 `Cargo.toml` 的 `[build-dependencies]` 中启用:
-
-```toml
-[dependencies.mingling]
-features = ["full"]
-
-[build-dependencies.mingling]
-features = ["build_full"]
-```
-
# 特性详解
## 特性 `all_serde_fmt`
@@ -91,23 +55,6 @@ async fn handle_state_foo(foo: StateFoo) -> Next {
详见 [示例](https://mingling-rs.github.io/mingling/docs/example-viewer.html?name=example-async-support)
-## 特性 `builds`
-
-**介绍:**
-
-启用部分需要在 `build.rs` 使用的脚本,目前包含:
-
-1. `comp` 特性下的补全脚本生成:
-
-```rust
-// BUILD TIME
-// Features: ["builds", "comp"]
-use mingling::build::build_comp_scripts;
-
-// 为 `myprogram` 生成补全脚本
-build_comp_scripts("myprogram").unwrap();
-```
-
## 特性 `clap`
**介绍:**
@@ -318,17 +265,10 @@ pub struct ErrorNotDir(PathBuf);
# Cargo.toml
[dependencies.mingling]
features = ["pathf"]
-
-[build-dependencies.mingling]
-features = ["builds", "pathf"]
-```
-
-```rust
-// BUILD TIME
-// Features: ["pathf"]
-analyze_and_build_type_mapping().unwrap();
```
+开启 `pathf` 特性后,`gen_program!()` 会在编译期自动调用 `build_pathf!()` 执行类型映射分析。
+
详见 [示例](https://mingling-rs.github.io/mingling/docs/example-viewer.html?name=example-pathfinder)
## 特性 `picker`
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 929b337..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,12 +131,15 @@ 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
-// Features: ["builds", "pathf"]
-analyze_and_build_type_mapping().unwrap();
+// Dependencies:
+// serde = "1"
+fn main() {
+ // build-time work, e.g. writing generated sources into OUT_DIR
+}
```
### `// Features: [...]`
@@ -199,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/docs/example-pages/examples.json b/docs/example-pages/examples.json
index 38f73cd..dd5b65b 100644
--- a/docs/example-pages/examples.json
+++ b/docs/example-pages/examples.json
@@ -69,13 +69,11 @@
"tags": [
"pathf",
"dispatch_tree",
- "extras",
- "build.rs"
+ "extras"
],
"files": [
"src/main.rs",
"src/sub/mod.rs",
- "build.rs",
"Cargo.toml"
]
},
@@ -87,13 +85,11 @@
"desc": "Combines the `pathf` feature with entry metadata. The metadata `DataType` and the entry `BindType` are defined inside a submodule, and `pathf` resolves them for `gen_program!()` at build time.\n",
"tags": [
"pathf",
- "metadata",
- "build.rs"
+ "metadata"
],
"files": [
"src/main.rs",
"src/sub/mod.rs",
- "build.rs",
"Cargo.toml"
]
},
@@ -124,7 +120,6 @@
],
"files": [
"src/main.rs",
- "build.rs",
"Cargo.toml"
]
},
@@ -294,12 +289,10 @@
"desc": "Demonstrates the `pathf` feature, which automatically resolves type module paths at build time. Types can be defined in submodules without explicit `use` in the main module.\n",
"tags": [
"pathf",
- "build.rs",
"architecture"
],
"files": [
"Cargo.toml",
- "build.rs",
"src/main.rs",
"src/sub/mod.rs"
]
diff --git a/docs/pages/12-exit-code.md b/docs/pages/12-exit-code.md
index 8fa320c..e9ad9de 100644
--- a/docs/pages/12-exit-code.md
+++ b/docs/pages/12-exit-code.md
@@ -12,7 +12,7 @@ Providing the shell with a correct exit code when a program terminates is a basi
@@@use mingling::setup::ExitCodeSetup;
fn main() {
let mut program = ThisProgram::new();
- program.with_setup(ExitCodeSetup::default());
+ program.with_setup(ExitCodeSetup);
@@@ program.exec_and_exit();
}
```
@@ -55,7 +55,7 @@ fn handle_check(_args: EntryCheck, ec: &mut ResExitCode) {
@@@use mingling::setup::ExitCodeSetup;
fn main() {
let mut program = ThisProgram::new();
- program.with_setup(ExitCodeSetup::default());
+ program.with_setup(ExitCodeSetup);
// Get exit code and handle it yourself
let exit_code = program.exec();
diff --git a/docs/pages/advanced/1-completion.md b/docs/pages/advanced/1-completion.md
index 55b8b1d..ca1f621 100644
--- a/docs/pages/advanced/1-completion.md
+++ b/docs/pages/advanced/1-completion.md
@@ -11,13 +11,6 @@ Mingling's completion is **fully dynamic** — no static completion files, sugge
# Cargo.toml
[dependencies.mingling]
features = ["comp"]
-
-[build-dependencies.mingling]
-features = [
- "comp",
- # Enable `build` for build-time support
- "build"
-]
```
## How it works
@@ -43,7 +36,7 @@ Use `#[completion(EntryType)]` to define completion logic for an Entry:
@@@dispatcher!("greet", EntryGreet);
#[completion(EntryGreet)]
-fn complete_greet(ctx: &ShellContext) -> Suggest {
+fn complete_greet(ctx: ShellContext) -> Suggest {
if ctx.previous_word == "greet" {
let mut items = BTreeSet::new();
items.insert(SuggestItem::new_with_desc("Alice".into(), "Likes to receive messages".into()));
@@ -72,8 +65,18 @@ suggest! {
## Generate completion scripts
-Call `build_comp_scripts` in `build.rs` to generate completion scripts (requires `builds` + `comp` features).
+When the `comp` feature is enabled, `gen_program!()` automatically invokes `build_comp!()` at compile time, which generates the completion scripts (named after `CARGO_PKG_NAME`) into `target/mingling/`.
+
+If your binary name differs from the crate name, call `build_comp!()` manually with the binary name:
+```rust
+// Features: ["comp"]
+@@@use mingling::macros::build_comp;
+@@@fn example() {
+build_comp!("mybin");
+@@@}
+```
+
See [example-completion](https://mingling-rs.github.io/mingling/docs/example-viewer.html?name=example-completion).
<p align="center" style="font-size: 0.85em; color: gray;">
diff --git a/docs/pages/other/features.md b/docs/pages/other/features.md
index b2c0ea5..858e556 100644
--- a/docs/pages/other/features.md
+++ b/docs/pages/other/features.md
@@ -25,42 +25,6 @@ Mingling provides a set of **preset feature groups** that make it easy to enable
**Positioning:** Full mode, enables all of Mingling's core functionality. In addition to `advanced`, it includes clap integration, the full structural renderer (with all serialization formats), and the experimental path analyzer. Suitable for large, feature-complete command-line applications.
-## `build_advanced`
-
-**Enables features:** `build`, `comp`
-
-**Positioning:** Build-time enhanced configuration, used to generate build helpers such as completion scripts at build time (the `comp` feature provides completion script generation).
-
-> [!NOTE]
->
-> This feature group is intended for **build dependencies** only and must be used alongside the `advanced` feature. Enable it in the `[build-dependencies]` section of `Cargo.toml`:
-
-```toml
-[dependencies.mingling]
-features = ["advanced"]
-
-[build-dependencies.mingling]
-features = ["build_advanced"]
-```
-
-## `build_full`
-
-**Enables features:** `build`, `comp`, `pathf`, `dispatch_tree`
-
-**Positioning:** Full build-time configuration, extends `build_advanced` with the path analyzer (`pathf`) to automatically resolve type module paths, suitable for projects with complex structures that require automated build-time analysis.
-
-> [!NOTE]
->
-> This feature group is intended for **build dependencies** only and must be used alongside the `full` feature. Enable it in the `[build-dependencies]` section of `Cargo.toml`:
-
-```toml
-[dependencies.mingling]
-features = ["full"]
-
-[build-dependencies.mingling]
-features = ["build_full"]
-```
-
# Feature Details
## Feature `all_serde_fmt`
@@ -91,23 +55,6 @@ async fn handle_state_foo(foo: StateFoo) -> Next {
See [example](https://mingling-rs.github.io/mingling/docs/example-viewer.html?name=example-async-support)
-## Feature `builds`
-
-**Description:**
-
-Enables scripts needed for use in `build.rs`, currently including:
-
-1. Completion script generation under the `comp` feature:
-
-```rust
-// BUILD TIME
-// Features: ["builds", "comp"]
-use mingling::build::build_comp_scripts;
-
-// Generate completion scripts for `myprogram`
-build_comp_scripts("myprogram").unwrap();
-```
-
## Feature `clap`
**Description:**
@@ -318,17 +265,10 @@ When enabled, types can be defined in any submodule, and `gen_program!()` can au
# Cargo.toml
[dependencies.mingling]
features = ["pathf"]
-
-[build-dependencies.mingling]
-features = ["builds", "pathf"]
-```
-
-```rust
-// BUILD TIME
-// Features: ["pathf"]
-analyze_and_build_type_mapping().unwrap();
```
+With the `pathf` feature enabled, `gen_program!()` automatically invokes `build_pathf!()` at compile time to run the type mapping analysis.
+
See [example](https://mingling-rs.github.io/mingling/docs/example-viewer.html?name=example-pathfinder)
## Feature `picker`
diff --git a/examples/example-combine-pathf-dispatch-tree/Cargo.lock b/examples/example-combine-pathf-dispatch-tree/Cargo.lock
index 9fdbf4f..3603db8 100644
--- a/examples/example-combine-pathf-dispatch-tree/Cargo.lock
+++ b/examples/example-combine-pathf-dispatch-tree/Cargo.lock
@@ -3,6 +3,39 @@
version = 4
[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -32,6 +65,12 @@ dependencies = [
]
[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
name = "just_fmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -69,7 +108,6 @@ version = "0.5.0"
dependencies = [
"just_fmt",
"might_be_async",
- "mingling_pathf",
]
[[package]]
@@ -77,6 +115,7 @@ name = "mingling_macros"
version = "0.5.0"
dependencies = [
"just_fmt",
+ "mingling_pathf",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -86,6 +125,7 @@ dependencies = [
name = "mingling_pathf"
version = "0.5.0"
dependencies = [
+ "cargo_metadata",
"just_fmt",
"proc-macro2",
"syn 2.0.118",
@@ -110,12 +150,23 @@ dependencies = [
]
[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[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]]
@@ -139,6 +190,19 @@ dependencies = [
]
[[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"
@@ -170,6 +234,26 @@ dependencies = [
]
[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+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"
@@ -224,3 +308,9 @@ 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/examples/example-combine-pathf-dispatch-tree/Cargo.toml b/examples/example-combine-pathf-dispatch-tree/Cargo.toml
index 2c866f7..6fcfc48 100644
--- a/examples/example-combine-pathf-dispatch-tree/Cargo.toml
+++ b/examples/example-combine-pathf-dispatch-tree/Cargo.toml
@@ -10,17 +10,4 @@ mingling = { path = "../../mingling", features = [
"pathf",
] }
-[build-dependencies]
-mingling = { path = "../../mingling", features = [
- "builds",
-
- # --------- IMPORTANT ---------
- # To use pathf under dispatch_tree
- # **must** enable the `dispatch_tree`
- # feature in build dependencies
- "dispatch_tree",
- "pathf",
- # --------- IMPORTANT ---------
-] }
-
[workspace]
diff --git a/examples/example-combine-pathf-dispatch-tree/build.rs b/examples/example-combine-pathf-dispatch-tree/build.rs
deleted file mode 100644
index d909431..0000000
--- a/examples/example-combine-pathf-dispatch-tree/build.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-fn main() {
- mingling::build::analyze_and_build_type_mapping().unwrap();
-}
diff --git a/examples/example-combine-pathf-dispatch-tree/page.toml b/examples/example-combine-pathf-dispatch-tree/page.toml
index 88557af..b4e64e9 100644
--- a/examples/example-combine-pathf-dispatch-tree/page.toml
+++ b/examples/example-combine-pathf-dispatch-tree/page.toml
@@ -6,5 +6,5 @@ category = "advanced"
desc = """
Demonstrates combining the `pathf` and `dispatch_tree` features. Types are defined in submodules and automatically resolved. Requires `dispatch_tree` in both `[dependencies]` and `[build-dependencies]`.
"""
-tags = ["pathf", "dispatch_tree", "extras", "build.rs"]
-files = ["src/main.rs", "src/sub/mod.rs", "build.rs", "Cargo.toml"]
+tags = ["pathf", "dispatch_tree", "extras"]
+files = ["src/main.rs", "src/sub/mod.rs", "Cargo.toml"]
diff --git a/examples/example-combine-pathf-dispatch-tree/src/main.rs b/examples/example-combine-pathf-dispatch-tree/src/main.rs
index 75888ee..a93e7ae 100644
--- a/examples/example-combine-pathf-dispatch-tree/src/main.rs
+++ b/examples/example-combine-pathf-dispatch-tree/src/main.rs
@@ -4,11 +4,13 @@
//! > Types are defined in a submodule (`sub`), and `gen_program!()` resolves
//! > them automatically via pathf without explicit `use` imports.
//! >
-//! > **Important**: `dispatch_tree` must be enabled in BOTH `[dependencies]`
-//! > AND `[build-dependencies]` so that pathf's builder can detect
-//! > `__internal_dispatcher_*` types needed by the dispatch tree.
+//! > **Important**: `dispatch_tree` must be enabled so that pathf's builder can
+//! > detect `__internal_dispatcher_*` types needed by the dispatch tree.
//! >
//! > Also requires `extras` for the implicit `dispatcher!("hello")` form.
+//! >
+//! > With the `pathf` feature, `gen_program!()` automatically invokes
+//! > `build_pathf!()` at compile time — no `build.rs` needed.
//!
//! Run:
//! ```bash
diff --git a/examples/example-combine-pathf-metadata/Cargo.lock b/examples/example-combine-pathf-metadata/Cargo.lock
index 0e1782e..304810b 100644
--- a/examples/example-combine-pathf-metadata/Cargo.lock
+++ b/examples/example-combine-pathf-metadata/Cargo.lock
@@ -3,6 +3,39 @@
version = 4
[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -32,6 +65,12 @@ dependencies = [
]
[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
name = "just_fmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -69,7 +108,6 @@ version = "0.5.0"
dependencies = [
"just_fmt",
"might_be_async",
- "mingling_pathf",
]
[[package]]
@@ -77,6 +115,7 @@ name = "mingling_macros"
version = "0.5.0"
dependencies = [
"just_fmt",
+ "mingling_pathf",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -86,6 +125,7 @@ dependencies = [
name = "mingling_pathf"
version = "0.5.0"
dependencies = [
+ "cargo_metadata",
"just_fmt",
"proc-macro2",
"syn 2.0.119",
@@ -110,12 +150,23 @@ dependencies = [
]
[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[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]]
@@ -139,6 +190,19 @@ dependencies = [
]
[[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"
@@ -170,6 +234,26 @@ dependencies = [
]
[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+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"
@@ -224,3 +308,9 @@ 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/examples/example-combine-pathf-metadata/Cargo.toml b/examples/example-combine-pathf-metadata/Cargo.toml
index fd14c74..26ed74a 100644
--- a/examples/example-combine-pathf-metadata/Cargo.toml
+++ b/examples/example-combine-pathf-metadata/Cargo.toml
@@ -12,13 +12,4 @@ features = [
"pathf",
]
-[build-dependencies.mingling]
-path = "../../mingling"
-features = [
- # Enable the `build` feature for build-time support
- "build",
- # `pathf` must also be enabled in build-dependencies
- "pathf",
-]
-
[workspace]
diff --git a/examples/example-combine-pathf-metadata/build.rs b/examples/example-combine-pathf-metadata/build.rs
deleted file mode 100644
index d909431..0000000
--- a/examples/example-combine-pathf-metadata/build.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-fn main() {
- mingling::build::analyze_and_build_type_mapping().unwrap();
-}
diff --git a/examples/example-combine-pathf-metadata/page.toml b/examples/example-combine-pathf-metadata/page.toml
index 76c1700..7e9b48c 100644
--- a/examples/example-combine-pathf-metadata/page.toml
+++ b/examples/example-combine-pathf-metadata/page.toml
@@ -6,5 +6,5 @@ category = "advanced"
desc = """
Combines the `pathf` feature with entry metadata. The metadata `DataType` and the entry `BindType` are defined inside a submodule, and `pathf` resolves them for `gen_program!()` at build time.
"""
-tags = ["pathf", "metadata", "build.rs"]
-files = ["src/main.rs", "src/sub/mod.rs", "build.rs", "Cargo.toml"]
+tags = ["pathf", "metadata"]
+files = ["src/main.rs", "src/sub/mod.rs", "Cargo.toml"]
diff --git a/examples/example-completion/Cargo.lock b/examples/example-completion/Cargo.lock
index d4b2f33..d98661c 100644
--- a/examples/example-completion/Cargo.lock
+++ b/examples/example-completion/Cargo.lock
@@ -20,6 +20,39 @@ dependencies = [
]
[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -49,6 +82,12 @@ dependencies = [
]
[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
name = "just_fmt"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -113,7 +152,6 @@ name = "mingling_core"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
- "just_template",
"might_be_async",
]
@@ -122,12 +160,24 @@ name = "mingling_macros"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
+ "just_template",
+ "mingling_pathf",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
+name = "mingling_pathf"
+version = "0.5.0"
+dependencies = [
+ "cargo_metadata",
+ "just_fmt 0.2.0",
+ "proc-macro2",
+ "syn 2.0.118",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -146,12 +196,23 @@ dependencies = [
]
[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[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]]
@@ -175,6 +236,19 @@ dependencies = [
]
[[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"
@@ -206,6 +280,26 @@ dependencies = [
]
[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+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"
@@ -260,3 +354,9 @@ 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/examples/example-completion/Cargo.toml b/examples/example-completion/Cargo.toml
index 9884f4e..59b6902 100644
--- a/examples/example-completion/Cargo.toml
+++ b/examples/example-completion/Cargo.toml
@@ -12,16 +12,4 @@ features = [
"picker",
]
-[build-dependencies.mingling]
-path = "../../mingling"
-
-features = [
- # Enable `comp` features
- "comp",
-
- # If you want to build completion scripts,
- # enable `build` features
- "build",
-]
-
[workspace]
diff --git a/examples/example-completion/build.rs b/examples/example-completion/build.rs
deleted file mode 100644
index e1ffba1..0000000
--- a/examples/example-completion/build.rs
+++ /dev/null
@@ -1,14 +0,0 @@
-fn main() {
- build_scripts();
-}
-
-/// Generate completion scripts
-fn build_scripts() {
- // `env!("CARGO_PKG_NAME")` equals the crate name, which matches the binary name.
- // If your binary name differs from the crate name, specify it explicitly.
- mingling::build::build_comp_scripts(
- // Your binary name:
- env!("CARGO_PKG_NAME"),
- )
- .unwrap();
-}
diff --git a/examples/example-completion/page.toml b/examples/example-completion/page.toml
index adccd86..4336513 100644
--- a/examples/example-completion/page.toml
+++ b/examples/example-completion/page.toml
@@ -7,4 +7,4 @@ desc = """
Demonstrates how to implement dynamic shell completion with `#[completion]` and generate scripts for bash, zsh, fish, and pwsh.
"""
tags = ["comp"]
-files = ["src/main.rs", "build.rs", "Cargo.toml"]
+files = ["src/main.rs", "Cargo.toml"]
diff --git a/examples/example-completion/src/main.rs b/examples/example-completion/src/main.rs
index e14326f..385387b 100644
--- a/examples/example-completion/src/main.rs
+++ b/examples/example-completion/src/main.rs
@@ -7,30 +7,15 @@
//! To make your completions work, you need to generate a completion script using Mingling's tools
//!
//! 1. Enable features
-//! You need to enable the `build` and `comp` features for `mingling` in `[build-dependencies]`
+//! Enable the `comp` feature for `mingling` in `[dependencies]`
//!
-//! 2. Write `build.rs`
-//! Write the following in `build.rs`
-//!
-//! ```rust,ignore
-//! fn main() {
-//! build_scripts();
-//! }
-//!
-//! /// Generate completion scripts
-//! fn build_scripts() {
-//! // `env!("CARGO_PKG_NAME")` equals the crate name, which matches the binary name.
-//! // If your binary name differs from the crate name, specify it explicitly.
-//! mingling::build::build_comp_scripts(
-//! // Your binary name:
-//! env!("CARGO_PKG_NAME"),
-//! )
-//! .unwrap();
-//! }
-//! ```
+//! 2. Generate completion scripts
+//! When the `comp` feature is enabled, `gen_program!()` automatically invokes
+//! `build_comp!()` at compile time, which generates the completion scripts
+//! (named after `CARGO_PKG_NAME`) into `target/mingling/`.
//!
//! 3. Verify
-//! Build your project with `cargo build --release`. The completion scripts will be generated in `target/release/`
+//! Build your project with `cargo build`. The completion scripts will be generated in `target/mingling/`
//!
//! Execute the script or have it be automatically sourced by your Shell
//!
@@ -56,12 +41,12 @@ fn main() {
}
// --------- IMPORTANT ---------
-// __________________________________________ Entry point bound to completion behavior
-// / _________________________ Shell context for obtaining user input state
-// | / ________ Suggest, used to return completion results
-// vvvvvvvvvv | /
-#[completion(EntryGreet)] // vvvvvvvvvvvv vvvvvvv
-fn complete_greet_entry(ctx: &ShellContext) -> Suggest {
+// _________________________________________ Entry point bound to completion behavior
+// / _________________________ Shell context for obtaining user input state
+// | / ________ Suggest, used to return completion results
+// vvvvvvvvvv | /
+#[completion(EntryGreet)] // vvvvvvvvvvvv vvvvvvv
+fn complete_greet_entry(ctx: ShellContext) -> Suggest {
// When the previous word is `greet` (the current command being typed)
if ctx.previous_word == "greet" {
// Return suggestions
diff --git a/examples/example-enum-tag/Cargo.lock b/examples/example-enum-tag/Cargo.lock
index 492d708..9b97008 100644
--- a/examples/example-enum-tag/Cargo.lock
+++ b/examples/example-enum-tag/Cargo.lock
@@ -20,6 +20,39 @@ dependencies = [
]
[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -49,6 +82,12 @@ dependencies = [
]
[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
name = "just_fmt"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -113,7 +152,6 @@ name = "mingling_core"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
- "just_template",
"might_be_async",
]
@@ -122,12 +160,24 @@ name = "mingling_macros"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
+ "just_template",
+ "mingling_pathf",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
+name = "mingling_pathf"
+version = "0.5.0"
+dependencies = [
+ "cargo_metadata",
+ "just_fmt 0.2.0",
+ "proc-macro2",
+ "syn 2.0.118",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -146,12 +196,23 @@ dependencies = [
]
[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[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]]
@@ -175,6 +236,19 @@ dependencies = [
]
[[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"
@@ -206,6 +280,26 @@ dependencies = [
]
[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+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"
@@ -260,3 +354,9 @@ 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/examples/example-enum-tag/src/main.rs b/examples/example-enum-tag/src/main.rs
index c7fb502..63ac3ba 100644
--- a/examples/example-enum-tag/src/main.rs
+++ b/examples/example-enum-tag/src/main.rs
@@ -114,7 +114,7 @@ pub fn render_programming_language(lang: ProgrammingLanguages) -> RenderResult {
}
#[completion(EntryLanguageSelection)]
-fn complete_language_selection(_: &ShellContext) -> Suggest {
+fn complete_language_selection(_: ShellContext) -> Suggest {
// Use `suggest_enum!` directly to generate enum suggestions
suggest_enum!(ProgrammingLanguages)
}
diff --git a/examples/example-exitcode/src/main.rs b/examples/example-exitcode/src/main.rs
index c7f731d..f0db05b 100644
--- a/examples/example-exitcode/src/main.rs
+++ b/examples/example-exitcode/src/main.rs
@@ -28,7 +28,7 @@ fn main() {
// --------- IMPORTANT ---------
// Register `ExitCodeSetup` for the program to enable exit codes
- program.with_setup(ExitCodeSetup::default());
+ program.with_setup(ExitCodeSetup);
// --------- IMPORTANT ---------
program.exec_and_exit();
diff --git a/examples/example-pathfinder/Cargo.lock b/examples/example-pathfinder/Cargo.lock
index b93e443..155e762 100644
--- a/examples/example-pathfinder/Cargo.lock
+++ b/examples/example-pathfinder/Cargo.lock
@@ -3,6 +3,39 @@
version = 4
[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -32,6 +65,12 @@ dependencies = [
]
[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
name = "just_fmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -69,7 +108,6 @@ version = "0.5.0"
dependencies = [
"just_fmt",
"might_be_async",
- "mingling_pathf",
]
[[package]]
@@ -77,6 +115,7 @@ name = "mingling_macros"
version = "0.5.0"
dependencies = [
"just_fmt",
+ "mingling_pathf",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -86,6 +125,7 @@ dependencies = [
name = "mingling_pathf"
version = "0.5.0"
dependencies = [
+ "cargo_metadata",
"just_fmt",
"proc-macro2",
"syn 2.0.118",
@@ -110,12 +150,23 @@ dependencies = [
]
[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[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]]
@@ -139,6 +190,19 @@ dependencies = [
]
[[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"
@@ -170,6 +234,26 @@ dependencies = [
]
[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+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"
@@ -224,3 +308,9 @@ 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/examples/example-pathfinder/Cargo.toml b/examples/example-pathfinder/Cargo.toml
index bc41bd2..00fc2a1 100644
--- a/examples/example-pathfinder/Cargo.toml
+++ b/examples/example-pathfinder/Cargo.toml
@@ -11,15 +11,4 @@ features = [
"pathf",
]
-[build-dependencies.mingling]
-path = "../../mingling"
-
-features = [
- # Enable `pathf` features
- "pathf",
-
- # Enable the `build` feature for build-time support
- "build",
-]
-
[workspace]
diff --git a/examples/example-pathfinder/build.rs b/examples/example-pathfinder/build.rs
deleted file mode 100644
index e96f978..0000000
--- a/examples/example-pathfinder/build.rs
+++ /dev/null
@@ -1,10 +0,0 @@
-use mingling::build::analyze_and_build_type_mapping;
-
-fn main() {
- // --------- IMPORTANT ---------
- // Use this method in build.rs,
- // to analyze the project structure at build time,
- // and automatically introduce members from other modules into gen_program!()
- analyze_and_build_type_mapping().unwrap();
- // --------- IMPORTANT ---------
-}
diff --git a/examples/example-pathfinder/page.toml b/examples/example-pathfinder/page.toml
index 54f4118..6cf42e3 100644
--- a/examples/example-pathfinder/page.toml
+++ b/examples/example-pathfinder/page.toml
@@ -6,5 +6,5 @@ category = "advanced"
desc = """
Demonstrates the `pathf` feature, which automatically resolves type module paths at build time. Types can be defined in submodules without explicit `use` in the main module.
"""
-tags = ["pathf", "build.rs", "architecture"]
-files = [ "Cargo.toml", "build.rs", "src/main.rs", "src/sub/mod.rs"]
+tags = ["pathf", "architecture"]
+files = [ "Cargo.toml", "src/main.rs", "src/sub/mod.rs"]
diff --git a/examples/full-todolist/src/main.rs b/examples/full-todolist/src/main.rs
index 9c7bfe8..cac2e13 100644
--- a/examples/full-todolist/src/main.rs
+++ b/examples/full-todolist/src/main.rs
@@ -60,7 +60,7 @@ fn main() {
let mut program = ThisProgram::new();
// Setups
- program.with_setup(ExitCodeSetup::default());
+ program.with_setup(ExitCodeSetup);
program.with_setup(StructuralRendererSetup);
program.with_setup(HelpFlagSetup::new(&arg![help: Flag, 'h']));
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/Cargo.toml b/mingling/Cargo.toml
index 6130b1b..7c7993b 100644
--- a/mingling/Cargo.toml
+++ b/mingling/Cargo.toml
@@ -25,7 +25,6 @@ features = [
"docs_rs",
"core",
"macros",
- "builds",
"structural_renderer",
"repl",
"comp",
@@ -40,8 +39,6 @@ features = [
mini = ["extras", "picker"]
advanced = ["extras", "picker", "repl", "comp", "dispatch_tree", "structural_renderer"]
full = ["extras", "picker", "repl", "clap", "comp", "dispatch_tree", "structural_renderer_full", "pathf"]
-build_advanced = ["build", "comp"]
-build_full = ["build", "comp", "pathf", "dispatch_tree"]
# Core
core = ["dep:mingling_core", "mingling_core/default"]
@@ -52,7 +49,6 @@ nightly = ["mingling_core/nightly", "mingling_macros/nightly"]
debug = ["mingling_core/debug"]
async = ["mingling_core/async", "mingling_macros/async"]
default = ["core", "macros"]
-build = ["mingling_core/build"]
# - Section only shown in docs.rs
docs_rs = []
@@ -62,7 +58,7 @@ dispatch_tree = ["mingling_macros/dispatch_tree"]
repl = ["mingling_core/repl", "mingling_macros/repl"]
comp = ["mingling_core/comp", "mingling_macros/comp"]
picker = ["mingling_core/picker", "dep:arg-picker", "arg-picker/mingling_support"]
-pathf = ["mingling_core/pathf", "mingling_macros/pathf"]
+pathf = ["mingling_macros/pathf"]
structural_renderer = [
"mingling_core/structural_renderer",
@@ -98,12 +94,6 @@ ron_serde_fmt = ["mingling_core/ron_serde_fmt"]
extras = ["mingling_macros/extras"]
-# - LEGACY -
-# These are old names, will be Breaking Change in the future
-
-builds = ["mingling_core/build"]
-extra_macros = ["mingling_macros/extras"]
-
[dependencies]
mingling_core = { workspace = true, optional = true }
mingling_macros = { workspace = true, optional = true }
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/example_docs.rs b/mingling/src/example_docs.rs
index d37e762..055615d 100644
--- a/mingling/src/example_docs.rs
+++ b/mingling/src/example_docs.rs
@@ -595,11 +595,13 @@ pub mod example_clap_binding {}
/// > Types are defined in a submodule (`sub`), and `gen_program!()` resolves
/// > them automatically via pathf without explicit `use` imports.
/// >
-/// > **Important**: `dispatch_tree` must be enabled in BOTH `[dependencies]`
-/// > AND `[build-dependencies]` so that pathf's builder can detect
-/// > `__internal_dispatcher_*` types needed by the dispatch tree.
+/// > **Important**: `dispatch_tree` must be enabled so that pathf's builder can
+/// > detect `__internal_dispatcher_*` types needed by the dispatch tree.
/// >
/// > Also requires `extras` for the implicit `dispatcher!("hello")` form.
+/// >
+/// > With the `pathf` feature, `gen_program!()` automatically invokes
+/// > `build_pathf!()` at compile time — no `build.rs` needed.
///
/// Run:
/// ```bash
@@ -625,19 +627,6 @@ pub mod example_clap_binding {}
/// "pathf",
/// ] }
///
-/// [build-dependencies]
-/// mingling = { path = "../../mingling", features = [
-/// "builds",
-///
-/// # --------- IMPORTANT ---------
-/// # To use pathf under dispatch_tree
-/// # **must** enable the `dispatch_tree`
-/// # feature in build dependencies
-/// "dispatch_tree",
-/// "pathf",
-/// # --------- IMPORTANT ---------
-/// ] }
-///
/// [workspace]
/// ```
///
@@ -691,15 +680,6 @@ pub mod example_combine_pathf_dispatch_tree {}
/// "pathf",
/// ]
///
-/// [build-dependencies.mingling]
-/// path = "../../mingling"
-/// features = [
-/// # Enable the `build` feature for build-time support
-/// "build",
-/// # `pathf` must also be enabled in build-dependencies
-/// "pathf",
-/// ]
-///
/// [workspace]
/// ```
///
@@ -811,30 +791,15 @@ pub mod example_command_macro {}
/// To make your completions work, you need to generate a completion script using Mingling's tools
///
/// 1. Enable features
-/// You need to enable the `build` and `comp` features for `mingling` in `[build-dependencies]`
-///
-/// 2. Write `build.rs`
-/// Write the following in `build.rs`
-///
-/// ```rust,ignore
-/// fn main() {
-/// build_scripts();
-/// }
-///
-/// /// Generate completion scripts
-/// fn build_scripts() {
-/// // `env!("CARGO_PKG_NAME")` equals the crate name, which matches the binary name.
-/// // If your binary name differs from the crate name, specify it explicitly.
-/// mingling::build::build_comp_scripts(
-/// // Your binary name:
-/// env!("CARGO_PKG_NAME"),
-/// )
-/// .unwrap();
-/// }
-/// ```
+/// Enable the `comp` feature for `mingling` in `[dependencies]`
+///
+/// 2. Generate completion scripts
+/// When the `comp` feature is enabled, `gen_program!()` automatically invokes
+/// `build_comp!()` at compile time, which generates the completion scripts
+/// (named after `CARGO_PKG_NAME`) into `target/mingling/`.
///
/// 3. Verify
-/// Build your project with `cargo build --release`. The completion scripts will be generated in `target/release/`
+/// Build your project with `cargo build`. The completion scripts will be generated in `target/mingling/`
///
/// Execute the script or have it be automatically sourced by your Shell
///
@@ -864,18 +829,6 @@ pub mod example_command_macro {}
/// "picker",
/// ]
///
-/// [build-dependencies.mingling]
-/// path = "../../mingling"
-///
-/// features = [
-/// # Enable `comp` features
-/// "comp",
-///
-/// # If you want to build completion scripts,
-/// # enable `build` features
-/// "build",
-/// ]
-///
/// [workspace]
/// ```
///
@@ -893,12 +846,12 @@ pub mod example_command_macro {}
/// }
///
/// // --------- IMPORTANT ---------
-/// // __________________________________________ Entry point bound to completion behavior
-/// // / _________________________ Shell context for obtaining user input state
-/// // | / ________ Suggest, used to return completion results
-/// // vvvvvvvvvv | /
-/// #[completion(EntryGreet)] // vvvvvvvvvvvv vvvvvvv
-/// fn complete_greet_entry(ctx: &ShellContext) -> Suggest {
+/// // _________________________________________ Entry point bound to completion behavior
+/// // / _________________________ Shell context for obtaining user input state
+/// // | / ________ Suggest, used to return completion results
+/// // vvvvvvvvvv | /
+/// #[completion(EntryGreet)] // vvvvvvvvvvvv vvvvvvv
+/// fn complete_greet_entry(ctx: ShellContext) -> Suggest {
/// // When the previous word is `greet` (the current command being typed)
/// if ctx.previous_word == "greet" {
/// // Return suggestions
@@ -1178,7 +1131,7 @@ pub mod example_dispatch_tree {}
/// }
///
/// #[completion(EntryLanguageSelection)]
-/// fn complete_language_selection(_: &ShellContext) -> Suggest {
+/// fn complete_language_selection(_: ShellContext) -> Suggest {
/// // Use `suggest_enum!` directly to generate enum suggestions
/// suggest_enum!(ProgrammingLanguages)
/// }
@@ -1365,7 +1318,7 @@ pub mod example_error_handling {}
///
/// // --------- IMPORTANT ---------
/// // Register `ExitCodeSetup` for the program to enable exit codes
-/// program.with_setup(ExitCodeSetup::default());
+/// program.with_setup(ExitCodeSetup);
/// // --------- IMPORTANT ---------
///
/// program.exec_and_exit();
@@ -2104,17 +2057,6 @@ pub mod example_panic_unwind {}
/// "pathf",
/// ]
///
-/// [build-dependencies.mingling]
-/// path = "../../mingling"
-///
-/// features = [
-/// # Enable `pathf` features
-/// "pathf",
-///
-/// # Enable the `build` feature for build-time support
-/// "build",
-/// ]
-///
/// [workspace]
/// ```
///
diff --git a/mingling/src/features.rs b/mingling/src/features.rs
index 9445328..8a3ce75 100644
--- a/mingling/src/features.rs
+++ b/mingling/src/features.rs
@@ -31,50 +31,6 @@ pub const MINGLING_ASYNC: bool = false;
#[cfg(feature = "async")]
#[allow(unused)]
pub const MINGLING_ASYNC: bool = true;
-/// Whether the `build` feature is enabled
-/// Current: `disabled`
-#[cfg(not(feature = "build"))]
-#[allow(unused)]
-pub const MINGLING_BUILD: bool = false;
-
-/// Whether the `build` feature is enabled
-/// Current: `enabled`
-#[cfg(feature = "build")]
-#[allow(unused)]
-pub const MINGLING_BUILD: bool = true;
-/// Whether the `build_advanced` feature is enabled
-/// Current: `disabled`
-#[cfg(not(feature = "build_advanced"))]
-#[allow(unused)]
-pub const MINGLING_BUILD_ADVANCED: bool = false;
-
-/// Whether the `build_advanced` feature is enabled
-/// Current: `enabled`
-#[cfg(feature = "build_advanced")]
-#[allow(unused)]
-pub const MINGLING_BUILD_ADVANCED: bool = true;
-/// Whether the `build_full` feature is enabled
-/// Current: `disabled`
-#[cfg(not(feature = "build_full"))]
-#[allow(unused)]
-pub const MINGLING_BUILD_FULL: bool = false;
-
-/// Whether the `build_full` feature is enabled
-/// Current: `enabled`
-#[cfg(feature = "build_full")]
-#[allow(unused)]
-pub const MINGLING_BUILD_FULL: bool = true;
-/// Whether the `builds` feature is enabled
-/// Current: `disabled`
-#[cfg(not(feature = "builds"))]
-#[allow(unused)]
-pub const MINGLING_BUILDS: bool = false;
-
-/// Whether the `builds` feature is enabled
-/// Current: `enabled`
-#[cfg(feature = "builds")]
-#[allow(unused)]
-pub const MINGLING_BUILDS: bool = true;
/// Whether the `clap` feature is enabled
/// Current: `disabled`
#[cfg(not(feature = "clap"))]
@@ -152,17 +108,6 @@ pub const MINGLING_DOCS_RS: bool = false;
#[cfg(feature = "docs_rs")]
#[allow(unused)]
pub const MINGLING_DOCS_RS: bool = true;
-/// Whether the `extra_macros` feature is enabled
-/// Current: `disabled`
-#[cfg(not(feature = "extra_macros"))]
-#[allow(unused)]
-pub const MINGLING_EXTRA_MACROS: bool = false;
-
-/// Whether the `extra_macros` feature is enabled
-/// Current: `enabled`
-#[cfg(feature = "extra_macros")]
-#[allow(unused)]
-pub const MINGLING_EXTRA_MACROS: bool = true;
/// Whether the `extras` feature is enabled
/// Current: `disabled`
#[cfg(not(feature = "extras"))]
diff --git a/mingling/src/lib.rs b/mingling/src/lib.rs
index 55240e5..45c8ec3 100644
--- a/mingling/src/lib.rs
+++ b/mingling/src/lib.rs
@@ -64,6 +64,10 @@ pub mod macros {
#[cfg(feature = "picker")]
pub use arg_picker::macros::*;
pub use mingling_macros::buffer;
+ #[cfg(feature = "comp")]
+ pub use mingling_macros::build_comp;
+ #[cfg(feature = "pathf")]
+ pub use mingling_macros::build_pathf;
pub use mingling_macros::chain;
#[cfg(feature = "extras")]
pub use mingling_macros::command;
diff --git a/mingling/src/setups/dirs.rs b/mingling/src/setups/dirs.rs
index ea7f282..65196c2 100644
--- a/mingling/src/setups/dirs.rs
+++ b/mingling/src/setups/dirs.rs
@@ -1,37 +1,53 @@
-// Doc Not Optimize
-use std::marker::PhantomData;
-
-use mingling_core::{ProgramCollect, setup::ProgramSetup};
+use mingling_core::{Program, ProgramCollect, setup::ProgramSetup};
use crate::res::{ResCurrentDir, ResCurrentExe, ResHomeDir, ResTempDir};
-/// Provides the ability to set up commonly used directory resources for the program.
-///
-/// This setup item registers the following directory resources in the program:
-/// - `ResCurrentDir`: Current working directory
-/// - `ResCurrentExe`: Directory containing the executable
-/// - `ResHomeDir`: User's home directory
-/// - `ResTempDir`: Temporary directory
-pub struct DirectoryEnvironmentSetup<C> {
- _collect: PhantomData<C>,
-}
-
-impl<C> Default for DirectoryEnvironmentSetup<C>
-where
- C: ProgramCollect<Enum = C> + 'static,
-{
- fn default() -> Self {
- Self {
- _collect: PhantomData,
- }
- }
-}
+/// `Directory Environment` Setup for managing common directory resources
+///
+/// This Setup registers commonly used directory resources into the program's
+/// resource store. It provides the current working directory, the executable's
+/// directory, the user's home directory, and the system's temporary directory,
+/// so that these paths can be retrieved from the resource store without
+/// recomputing them each time.
+///
+/// # Usage
+///
+/// This Setup can be registered using the
+/// [`Program`](https://docs.rs/mingling/latest/mingling/struct.Program.html)
+/// `with_setup` method, for example:
+///
+/// ```rust
+/// # use mingling::MockProgramCollect as ThisProgram;
+/// use mingling::Program;
+/// use mingling::setup::DirectoryEnvironmentSetup;
+///
+/// let mut program = Program::<ThisProgram>::new();
+/// program.with_setup(DirectoryEnvironmentSetup);
+/// ```
+///
+/// # Behavior
+///
+/// - Registers an [`ResCurrentDir`] resource containing the current working
+/// directory.
+/// - Registers an [`ResCurrentExe`] resource containing the directory of the
+/// currently running executable.
+/// - Registers an [`ResHomeDir`] resource containing the user's home directory.
+/// - Registers an [`ResTempDir`] resource containing the system's temporary
+/// directory.
+///
+/// # Notes
+///
+/// - All directory values are resolved at setup time and stored in the
+/// resource store.
+/// - These resources can be retrieved later using the program's `resource`
+/// accessor with the corresponding resource type.
+pub struct DirectoryEnvironmentSetup;
-impl<C> ProgramSetup<C> for DirectoryEnvironmentSetup<C>
+impl<C> ProgramSetup<C> for DirectoryEnvironmentSetup
where
C: ProgramCollect<Enum = C> + 'static,
{
- fn setup(self, program: &mut crate::Program<C>) {
+ fn setup(self, program: &mut Program<C>) {
program.with_resource(ResCurrentDir::default());
program.with_resource(ResCurrentExe::default());
program.with_resource(ResHomeDir::default());
diff --git a/mingling/src/setups/exit_code.rs b/mingling/src/setups/exit_code.rs
index e31e511..49d5f9f 100644
--- a/mingling/src/setups/exit_code.rs
+++ b/mingling/src/setups/exit_code.rs
@@ -1,8 +1,5 @@
-// Doc Not Optimize
-use std::marker::PhantomData;
-
use mingling_core::{
- ProgramCollect,
+ Program, ProgramCollect,
hook::{ProgramControlUnit, ProgramControls, ProgramHook},
setup::ProgramSetup,
this,
@@ -10,30 +7,43 @@ use mingling_core::{
use crate::res::ResExitCode;
-/// Provides the ability to control the program's exit code, which is returned when the program ends.
+/// `ExitCodeSetup` — Setup for controlling the program's exit code
///
-/// - Use `mingling::update_exit_code` to update the exit code.
-/// - Use `mingling::current_exit_code` to query the current exit code.
-pub struct ExitCodeSetup<C> {
- _collect: PhantomData<C>,
-}
-
-impl<C> Default for ExitCodeSetup<C>
-where
- C: ProgramCollect<Enum = C> + 'static,
-{
- fn default() -> Self {
- Self {
- _collect: PhantomData,
- }
- }
-}
+/// This Setup registers an [`ResExitCode`] resource that tracks the desired exit
+/// code for the program. When the program finishes, a hook reads this resource
+/// and overrides the program's exit code if it has been modified from its
+/// default value of `0`.
+///
+/// # Usage
+///
+/// This Setup can be registered using the
+/// [`Program`](https://docs.rs/mingling/latest/mingling/struct.Program.html)
+/// `with_setup` method, for example:
+///
+/// ```rust
+/// # use mingling::MockProgramCollect as ThisProgram;
+/// use mingling::Program;
+/// use mingling::setup::ExitCodeSetup;
+///
+/// let mut program = Program::<ThisProgram>::new();
+/// program.with_setup(ExitCodeSetup);
+/// ```
+///
+/// # Behavior
+///
+/// - Registers an [`ResExitCode`] resource initialised to `0`.
+/// - Installs a program-finish hook that:
+/// - Reads the current [`ResExitCode`] value.
+/// - 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`.
+pub struct ExitCodeSetup;
-impl<C> ProgramSetup<C> for ExitCodeSetup<C>
+impl<C> ProgramSetup<C> for ExitCodeSetup
where
C: ProgramCollect<Enum = C> + 'static,
{
- fn setup(self, program: &mut crate::Program<C>) {
+ fn setup(self, program: &mut Program<C>) {
// Insert resource
program.with_resource(ResExitCode { exit_code: 0 });
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_cli/Cargo.lock b/mingling_cli/Cargo.lock
index c738a0e..aab6b82 100644
--- a/mingling_cli/Cargo.lock
+++ b/mingling_cli/Cargo.lock
@@ -95,9 +95,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "camino"
-version = "1.2.4"
+version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
dependencies = [
"serde_core",
]
@@ -746,15 +746,15 @@ dependencies = [
[[package]]
name = "libc"
-version = "0.2.187"
+version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7743783ea728ef5c31194c6590797eed286449b4a4e87d626d8a51f0a94e732"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libredox"
-version = "0.1.19"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa"
+checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
dependencies = [
"libc",
]
@@ -849,9 +849,7 @@ name = "mingling_core"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
- "just_template",
"might_be_async",
- "mingling_pathf",
]
[[package]]
@@ -859,6 +857,8 @@ name = "mingling_macros"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
+ "just_template",
+ "mingling_pathf",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -868,6 +868,7 @@ dependencies = [
name = "mingling_pathf"
version = "0.5.0"
dependencies = [
+ "cargo_metadata",
"just_fmt 0.2.0",
"proc-macro2",
"syn 2.0.119",
@@ -1402,18 +1403,18 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.19"
+version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
-version = "2.0.19"
+version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
@@ -1464,13 +1465,13 @@ dependencies = [
[[package]]
name = "tokio-macros"
-version = "2.7.1"
+version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.2",
]
[[package]]
diff --git a/mingling_cli/Cargo.toml b/mingling_cli/Cargo.toml
index dfc686d..19b4c1b 100644
--- a/mingling_cli/Cargo.toml
+++ b/mingling_cli/Cargo.toml
@@ -30,15 +30,6 @@ features = [
"comp",
]
-[build-dependencies.mingling]
-path = "../mingling"
-features = [
- "build",
- "pathf",
- "dispatch_tree",
- "comp"
-]
-
[dependencies]
# Project analyze
diff --git a/mingling_cli/build.rs b/mingling_cli/build.rs
index 0dc0b28..4df33d0 100644
--- a/mingling_cli/build.rs
+++ b/mingling_cli/build.rs
@@ -1,14 +1,6 @@
-use mingling::build::{analyze_and_build_type_mapping, build_comp_scripts};
-
pub mod pre;
fn main() {
- // Perform path analysis and build type mapping table
- analyze_and_build_type_mapping().ok();
-
- // Generate Mingling CLI Completion Scripts
- build_comp_scripts("mling").unwrap();
-
// Generate lint registry
pre::gen_mod_file().unwrap();
pre::gen_lint_registry().unwrap();
diff --git a/mingling_cli/src/bin/cli.rs b/mingling_cli/src/bin/cli.rs
index f641749..d0c9e8b 100644
--- a/mingling_cli/src/bin/cli.rs
+++ b/mingling_cli/src/bin/cli.rs
@@ -10,8 +10,8 @@ async fn main() {
// Setups
program.with_setup(HelpFlagSetup::default());
- program.with_setup(ExitCodeSetup::default());
- program.with_setup(DirectoryEnvironmentSetup::default());
+ program.with_setup(ExitCodeSetup);
+ program.with_setup(DirectoryEnvironmentSetup);
program.with_setup(MinglingMetadataSetup);
program.with_setup(MlingConfigSetup);
diff --git a/mingling_cli/src/config/cmd_cfg.rs b/mingling_cli/src/config/cmd_cfg.rs
index 8baa4a1..cde7772 100644
--- a/mingling_cli/src/config/cmd_cfg.rs
+++ b/mingling_cli/src/config/cmd_cfg.rs
@@ -109,7 +109,7 @@ pub fn escape_config_value(input: &str) -> String {
}
#[completion(EntryCfg)]
-pub fn complete_config(_ctx: &ShellContext, config: &mut LazyRes<ResMlingConfig>) -> Suggest {
+pub fn complete_config(_ctx: ShellContext, config: &mut LazyRes<ResMlingConfig>) -> Suggest {
let config = config.get_ref();
let keys = config.get_hash_map().keys().cloned().collect::<Vec<_>>();
Suggest::from(keys).combine(suggest! {
diff --git a/mingling_cli/src/lib.rs b/mingling_cli/src/lib.rs
index e82300e..0b93f08 100644
--- a/mingling_cli/src/lib.rs
+++ b/mingling_cli/src/lib.rs
@@ -24,13 +24,17 @@ pub mod proj_mgr;
pub mod updater;
pub mod utils;
+// The `comp` feature makes `gen_program!()` generate completion scripts named after
+// the crate (`mingling-cli_comp.*`). Generate an additional set for the `mling` binary.
+mingling::macros::build_comp!("mling");
+
#[help]
pub fn help_global(_: EntryFallback) -> String {
format!("{}\n", include_str!("../help/help.txt").parse_color_code())
}
#[completion(EntryFallback)]
-pub fn complete_global(_ctx: &ShellContext) -> Suggest {
+pub fn complete_global(_ctx: ShellContext) -> Suggest {
suggest! {
HELP_FLAG: "Show help messages",
ARG_FEATURES.clone(): "List of features to enable",
diff --git a/mingling_cli/src/linter/cmd_explain.rs b/mingling_cli/src/linter/cmd_explain.rs
index 923e890..1d99c62 100644
--- a/mingling_cli/src/linter/cmd_explain.rs
+++ b/mingling_cli/src/linter/cmd_explain.rs
@@ -97,7 +97,7 @@ pub fn render_error_no_such_lint(
}
#[completion(EntryExplain)]
-pub fn complete_explain(ctx: &ShellContext, registry: &mut LazyRes<ResLintRegistry>) -> Suggest {
+pub fn complete_explain(ctx: ShellContext, registry: &mut LazyRes<ResLintRegistry>) -> Suggest {
let registry = registry.get_ref();
if ctx.previous_word != "explain" {
return Suggest::FileCompletion;
diff --git a/mingling_cli/src/linter/cmd_lint.rs b/mingling_cli/src/linter/cmd_lint.rs
index 7cab63a..b49605f 100644
--- a/mingling_cli/src/linter/cmd_lint.rs
+++ b/mingling_cli/src/linter/cmd_lint.rs
@@ -141,7 +141,7 @@ pub async fn handle_state_begin_linter(
}
#[completion(EntryLint)]
-pub fn complete_lint(ctx: &ShellContext) -> Suggest {
+pub fn complete_lint(ctx: ShellContext) -> Suggest {
if mingling::picker::parselib::build_possible_flags(
ParserStyle::global_style(),
&ARG_WITH_CHECKER.into_info(),
diff --git a/mingling_cli/src/pkg_mgr/cmd_install.rs b/mingling_cli/src/pkg_mgr/cmd_install.rs
index 8b2f3b7..0b552a6 100644
--- a/mingling_cli/src/pkg_mgr/cmd_install.rs
+++ b/mingling_cli/src/pkg_mgr/cmd_install.rs
@@ -34,6 +34,9 @@ pub struct StateInstallBuild {
pub workspace_root: PathBuf,
pub install_dir: PathBuf,
pub release_dir: PathBuf,
+ /// Directory holding Mingling's compile-time build outputs
+ /// (`{target_directory}/mingling/`), e.g. the completion scripts.
+ pub mingling_dir: PathBuf,
pub exe_suffix: &'static str,
pub enable: bool,
}
@@ -43,6 +46,9 @@ pub struct StateInstallBuild {
pub struct StateInstallCopy {
pub install_dir: PathBuf,
pub release_dir: PathBuf,
+ /// Directory holding Mingling's compile-time build outputs
+ /// (`{target_directory}/mingling/`), e.g. the completion scripts.
+ pub mingling_dir: PathBuf,
pub exe_suffix: &'static str,
pub installed: Vec<PathBuf>,
pub enable: bool,
@@ -95,6 +101,10 @@ pub fn install(
.target_directory
.join("release")
.into_std_path_buf(),
+ mingling_dir: metadata
+ .target_directory
+ .join("mingling")
+ .into_std_path_buf(),
exe_suffix: env::consts::EXE_SUFFIX,
enable: enable.bool(),
}
@@ -117,6 +127,7 @@ pub fn handle_state_install_build(state: StateInstallBuild) -> Next {
StateInstallCopy {
install_dir: state.install_dir,
release_dir: state.release_dir,
+ mingling_dir: state.mingling_dir,
exe_suffix: state.exe_suffix,
installed: vec![],
enable: state.enable,
@@ -159,13 +170,13 @@ pub fn handle_state_install_copy(
}
}
- // Completion scripts are generated into the build profile directory
- // (OUT_DIR/../../../), copy every one whose name contains `_comp`,
- // regardless of its suffix
- for entry in fs::read_dir(&state.release_dir).map_err(|e| {
+ // Completion scripts are generated into `{target_directory}/mingling/` at
+ // compile time (via `build_comp!()`); copy every one whose name contains
+ // `_comp`, regardless of its suffix.
+ for entry in fs::read_dir(&state.mingling_dir).map_err(|e| {
io::Error::new(
e.kind(),
- format!("failed to read {}: {e}", state.release_dir.display()),
+ format!("failed to read {}: {e}", state.mingling_dir.display()),
)
})? {
let entry = entry.map_err(|e| {
@@ -256,7 +267,7 @@ pub fn render_error_pkg_enable_failed(err: ErrorPkgEnableFailed) -> RenderResult
}
#[completion(EntryInstall)]
-pub fn complete_install(ctx: &ShellContext) -> Suggest {
+pub fn complete_install(ctx: ShellContext) -> Suggest {
if ctx.previous_word != "install" {
return Suggest::FileCompletion;
}
diff --git a/mingling_cli/src/pkg_mgr/cmd_pkg_disable.rs b/mingling_cli/src/pkg_mgr/cmd_pkg_disable.rs
index b4989eb..ea6997f 100644
--- a/mingling_cli/src/pkg_mgr/cmd_pkg_disable.rs
+++ b/mingling_cli/src/pkg_mgr/cmd_pkg_disable.rs
@@ -93,7 +93,7 @@ pub fn render_error_package_not_enabled(err: ErrorPackageNotEnabled) -> RenderRe
}
#[completion(EntryPkgDisable)]
-pub fn complete_pkg_disable(ctx: &ShellContext, packages_dir: &ResPackagesDir) -> Suggest {
+pub fn complete_pkg_disable(ctx: ShellContext, packages_dir: &ResPackagesDir) -> Suggest {
if ctx.previous_word != "pkg-disable" {
return Suggest::FileCompletion;
}
diff --git a/mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs b/mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs
index 1235cd3..377bd33 100644
--- a/mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs
+++ b/mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs
@@ -118,7 +118,7 @@ pub fn render_error_no_matching_version(err: ErrorNoMatchingVersion) -> RenderRe
}
#[completion(EntryPkgEnable)]
-pub fn complete_pkg_enable(ctx: &ShellContext, packages_dir: &ResPackagesDir) -> Suggest {
+pub fn complete_pkg_enable(ctx: ShellContext, packages_dir: &ResPackagesDir) -> Suggest {
if ctx.previous_word != "pkg-enable" {
return Suggest::FileCompletion;
}
diff --git a/mingling_cli/src/pkg_mgr/cmd_uninstall.rs b/mingling_cli/src/pkg_mgr/cmd_uninstall.rs
index 0c3886c..0c8781a 100644
--- a/mingling_cli/src/pkg_mgr/cmd_uninstall.rs
+++ b/mingling_cli/src/pkg_mgr/cmd_uninstall.rs
@@ -154,7 +154,7 @@ pub fn render_error_no_matching_packages(_: ErrorNoMatchingPackages) -> RenderRe
}
#[completion(EntryUninstall)]
-pub fn complete_uninstall(ctx: &ShellContext, packages_dir: &ResPackagesDir) -> Suggest {
+pub fn complete_uninstall(ctx: ShellContext, packages_dir: &ResPackagesDir) -> Suggest {
if ctx.previous_word != "uninstall" {
return Suggest::FileCompletion;
}
diff --git a/mingling_cli/src/proj_mgr/cmd_class_add.rs b/mingling_cli/src/proj_mgr/cmd_class_add.rs
index f9272a5..f42bbff 100644
--- a/mingling_cli/src/proj_mgr/cmd_class_add.rs
+++ b/mingling_cli/src/proj_mgr/cmd_class_add.rs
@@ -250,7 +250,7 @@ pub fn render_error_class_write_failed(err: ErrorClassWriteFailed) -> RenderResu
}
#[completion(EntryClassAdd)]
-pub fn complete_class_add(ctx: &ShellContext, cwd: &ResCurrentDir) -> Suggest {
+pub fn complete_class_add(ctx: ShellContext, cwd: &ResCurrentDir) -> Suggest {
if ctx.previous_word != "class-add" {
return Suggest::file_comp();
}
diff --git a/mingling_core/Cargo.toml b/mingling_core/Cargo.toml
index aecf476..989abe4 100644
--- a/mingling_core/Cargo.toml
+++ b/mingling_core/Cargo.toml
@@ -14,7 +14,6 @@ categories = ["command-line-interface"]
nightly = []
default = []
async = []
-build = []
picker = []
structural_renderer = ["dep:serde"]
@@ -26,18 +25,12 @@ toml_serde_fmt = ["dep:toml"]
repl = []
clap = []
-comp = ["dep:just_template"]
+comp = []
debug = ["dep:log", "dep:env_logger"]
-pathf = ["dep:mingling_pathf"]
[dependencies]
-mingling_pathf = { workspace = true, optional = true }
-
just_fmt.workspace = true
-# comp
-just_template = { workspace = true, optional = true }
-
# structural_renderer
serde = { workspace = true, optional = true }
ron = { workspace = true, optional = true }
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_core/src/build.rs b/mingling_core/src/build.rs
deleted file mode 100644
index 213d529..0000000
--- a/mingling_core/src/build.rs
+++ /dev/null
@@ -1,28 +0,0 @@
-#[doc(hidden)]
-#[cfg(feature = "comp")]
-mod comp;
-
-#[cfg(feature = "comp")]
-mod comp_re_export {
- pub use super::comp::build_comp_script;
- pub use super::comp::build_comp_script_to;
- pub use super::comp::build_comp_script_to_file;
- pub use super::comp::build_comp_scripts;
-}
-
-#[cfg(feature = "comp")]
-pub use comp_re_export::*;
-
-#[doc(hidden)]
-#[cfg(feature = "pathf")]
-mod pathf;
-
-#[cfg(feature = "pathf")]
-mod pathf_re_export {
- pub use super::pathf::analyze;
- pub use super::pathf::analyze_and_build_type_mapping;
- pub use super::pathf::analyze_and_build_type_mapping_for;
-}
-
-#[cfg(feature = "pathf")]
-pub use pathf_re_export::*;
diff --git a/mingling_core/src/build/pathf.rs b/mingling_core/src/build/pathf.rs
deleted file mode 100644
index 4b8af1b..0000000
--- a/mingling_core/src/build/pathf.rs
+++ /dev/null
@@ -1,98 +0,0 @@
-#![allow(unused_imports)]
-
-pub use mingling_pathf::module_pathf::*;
-pub use mingling_pathf::pattern_analyzer::*;
-pub use mingling_pathf::patterns::*;
-
-use std::path::Path;
-
-/// Analyzes and builds a type mapping for a specific crate.
-///
-/// Accepts `crate_dir` and `output_dir`, and invokes `pathf` to build the type mapping.
-///
-/// # Arguments
-///
-/// - `crate_dir`: Root directory of the crate's source code to analyze (usually `CARGO_MANIFEST_DIR`).
-/// - `output_dir`: Output directory for generated artifacts (type mapping data).
-///
-/// # Returns
-///
-/// - On success: returns `Ok(())`;
-/// - On failure: returns the corresponding `MinglingPathfinderError`.
-///
-/// # Example
-///
-/// ```
-/// # #[cfg(all(feature = "build", feature = "pathf"))] {
-/// use mingling_core::build::analyze_and_build_type_mapping_for;
-/// use std::path::Path;
-///
-/// let crate_dir = Path::new(".");
-/// let output_dir = Path::new(".temp/target/out");
-/// analyze_and_build_type_mapping_for(crate_dir, output_dir).expect("analysis failed");
-/// # }
-/// ```
-pub fn analyze_and_build_type_mapping_for(
- crate_dir: &Path,
- output_dir: &Path,
-) -> Result<(), crate::error::MinglingPathfinderError> {
- mingling_pathf::analyze_and_build_type_mapping_for(crate_dir, output_dir)
-}
-
-/// # Analyzes and builds a type mapping
-///
-/// This function reads the current crate directory (`CARGO_PKG_NAME`) and output directory (`OUT_DIR`)
-/// from environment variables, automatically combines them into the target output path, and invokes
-/// the underlying analysis logic. Suitable for use in `build.rs`.
-///
-/// It also sends the `cargo:rerun-if-changed=src/` directive to Cargo so that a rebuild is
-/// automatically triggered when source code changes.
-///
-/// # Prerequisites
-///
-/// This function depends on the following environment variables, which are typically set
-/// automatically during a Cargo build:
-///
-/// - `CARGO_PKG_NAME`: Name of the current crate.
-/// - `OUT_DIR`: Build output directory provided by Cargo.
-///
-/// If these variables are missing, a corresponding [`MinglingPathfinderError`](crate::error::MinglingPathfinderError)
-/// is returned.
-///
-/// # Returns
-///
-/// Returns `Ok(())` on success; returns a corresponding
-/// [`MinglingPathfinderError`](crate::error::MinglingPathfinderError) on failure.
-///
-/// # Example
-///
-/// ```
-/// # #[cfg(all(feature = "build", feature = "pathf"))] {
-/// use mingling_core::build::analyze_and_build_type_mapping;
-///
-/// fn main() {
-/// analyze_and_build_type_mapping().expect("failed to build type mapping");
-/// }
-/// # }
-/// ```
-
-pub fn analyze_and_build_type_mapping() -> Result<(), crate::error::MinglingPathfinderError> {
- let crate_dir =
- std::env::current_dir().map_err(crate::error::MinglingPathfinderError::IoError)?;
- let crate_name = std::env::var("CARGO_PKG_NAME").map_err(|_| {
- crate::error::MinglingPathfinderError::IoError(std::io::Error::new(
- std::io::ErrorKind::NotFound,
- "CARGO_PKG_NAME not set",
- ))
- })?;
- let out_dir = std::env::var("OUT_DIR").map_err(|_| {
- crate::error::MinglingPathfinderError::IoError(std::io::Error::new(
- std::io::ErrorKind::NotFound,
- "OUT_DIR not set",
- ))
- })?;
- let output_dir = Path::new(&out_dir).join(&crate_name);
- mingling_pathf::analyze_and_build_type_mapping_for(&crate_dir, &output_dir)?;
- println!("cargo:rerun-if-changed=src/");
- Ok(())
-}
diff --git a/mingling_core/src/docs/build.md b/mingling_core/src/docs/build.md
deleted file mode 100644
index 6f9285a..0000000
--- a/mingling_core/src/docs/build.md
+++ /dev/null
@@ -1,57 +0,0 @@
-Provide Mingling's build script module for build-time behavior of specific features in `build.rs`.
-
-To use it, add a dependency on mingling under `[build-dependencies]` in `Cargo.toml`, and enable the relevant features:
-
-## Build-Time Related Features
-
-| Name | Purpose |
-| ---------------- | ------------------------------------------------------------------------------------------------- |
-| `build` | Master switch for build-time features |
-| `build_advanced` | Master switch for build-time features, paired with the `advanced` feature |
-| `build_full` | Master switch for build-time features, paired with the `full` feature |
-| `comp` | Completion script builder; both sides must enable it, generates cross-platform completion scripts |
-| `pathf` | Type path analyzer; both sides must enable it, generates type mapping tables |
-| `dispatch_tree` | Compile-time dispatch tree; when `pathf` is a build-time dependency, |
-| | and `dispatch_tree` (included in `advanced` or `full`) is enabled, both sides should enable it |
-
-```toml
-# Cargo.toml
-[dependencies.mingling]
-features = [
- "advanced", # Enable `advanced` if using it
-]
-
-[build-dependencies.mingling]
-features = [
- "build_advanced" # This side should enable `build_advanced`
-]
-```
-
-## `build.rs` Templates
-
-You can use the following template to write `build.rs` to quickly gain the build-time capabilities of `comp` and `pathf`:
-
-```rust,ignore
-// build.rs
-fn main() {
- build_scripts();
- build_pathf_mapping();
-}
-
-/// Generate completion scripts
-fn build_scripts() {
- // `env!("CARGO_PKG_NAME")` equals the crate name, which matches the binary name.
- // If your binary name differs from the crate name, specify it explicitly.
- mingling::build::build_comp_scripts(
- // Your binary name:
- env!("CARGO_PKG_NAME"),
- )
- .unwrap();
-}
-
-fn build_pathf_mapping() {
- // Build pathf type mapping to ensure that the enabled `pathf` feature
- // can correctly scan macros in the project
- mingling::build::analyze_and_build_type_mapping().unwrap();
-}
-```
diff --git a/mingling_core/src/lib.rs b/mingling_core/src/lib.rs
index 2aa2ce1..1d26ebb 100644
--- a/mingling_core/src/lib.rs
+++ b/mingling_core/src/lib.rs
@@ -46,10 +46,6 @@ pub mod core_res {
#[cfg(feature = "comp")]
pub(crate) mod comp;
-#[cfg(feature = "build")]
-#[doc = include_str!("docs/build.md")]
-pub mod build;
-
// Public Modules
/// Provides a toolkit for `Mingling` testing capabilities.
@@ -90,9 +86,6 @@ pub mod error {
#[cfg(feature = "structural_renderer")]
pub use crate::renderer::structural::error::*;
-
- #[cfg(feature = "pathf")]
- pub use mingling_pathf::error::*;
}
#[doc(hidden)]
diff --git a/mingling_core/tests/test-all/Cargo.lock b/mingling_core/tests/test-all/Cargo.lock
index d239541..79202f9 100644
--- a/mingling_core/tests/test-all/Cargo.lock
+++ b/mingling_core/tests/test-all/Cargo.lock
@@ -16,7 +16,7 @@ version = "0.2.0"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -35,6 +35,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -108,7 +141,7 @@ checksum = "1471eb68722ecefeb71debdde2859e8725341f171d3f42b3a98a0862ad19416e"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -140,7 +173,7 @@ checksum = "eca871cea620b07bd2e6da0c883891a25bead698c43a9ab64b0fd663a7a78d5f"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
"toml 0.8.23",
]
@@ -159,7 +192,6 @@ name = "mingling_core"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
- "just_template",
"might_be_async",
"ron",
"serde",
@@ -173,9 +205,21 @@ name = "mingling_macros"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
+ "just_template",
+ "mingling_pathf",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "mingling_pathf"
+version = "0.5.0"
+dependencies = [
+ "cargo_metadata",
+ "just_fmt 0.2.0",
+ "proc-macro2",
+ "syn 2.0.118",
]
[[package]]
@@ -278,6 +322,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -304,7 +358,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -389,6 +443,17 @@ dependencies = [
]
[[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 = "test-all"
version = "0.1.0"
dependencies = [
@@ -398,6 +463,26 @@ dependencies = [
]
[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -422,7 +507,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
diff --git a/mingling_core/tests/test-all/Cargo.toml b/mingling_core/tests/test-all/Cargo.toml
index df2efdb..7f50f2f 100644
--- a/mingling_core/tests/test-all/Cargo.toml
+++ b/mingling_core/tests/test-all/Cargo.toml
@@ -10,7 +10,6 @@ publish = false
mingling = { path = "../../../mingling", features = [
"structural_renderer_full",
"comp",
- "builds",
"repl",
"dispatch_tree",
"picker",
diff --git a/mingling_core/tests/test-comp/Cargo.lock b/mingling_core/tests/test-comp/Cargo.lock
index 1a37590..199c76e 100644
--- a/mingling_core/tests/test-comp/Cargo.lock
+++ b/mingling_core/tests/test-comp/Cargo.lock
@@ -3,6 +3,39 @@
version = 4
[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -25,6 +58,12 @@ dependencies = [
]
[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
name = "just_fmt"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -88,7 +127,6 @@ name = "mingling_core"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
- "just_template",
"might_be_async",
]
@@ -97,12 +135,24 @@ name = "mingling_macros"
version = "0.5.0"
dependencies = [
"just_fmt 0.2.0",
+ "just_template",
+ "mingling_pathf",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
+name = "mingling_pathf"
+version = "0.5.0"
+dependencies = [
+ "cargo_metadata",
+ "just_fmt 0.2.0",
+ "proc-macro2",
+ "syn 2.0.118",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -121,12 +171,23 @@ dependencies = [
]
[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[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]]
@@ -150,6 +211,19 @@ dependencies = [
]
[[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"
@@ -188,6 +262,26 @@ dependencies = [
]
[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+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"
@@ -242,3 +336,9 @@ 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_core/tests/test-comp/Cargo.toml b/mingling_core/tests/test-comp/Cargo.toml
index 9ceca3e..789e172 100644
--- a/mingling_core/tests/test-comp/Cargo.toml
+++ b/mingling_core/tests/test-comp/Cargo.toml
@@ -7,4 +7,4 @@ publish = false
[workspace]
[dependencies]
-mingling = { path = "../../../mingling", features = ["comp", "builds"] }
+mingling = { path = "../../../mingling", features = ["comp"] }
diff --git a/mingling_macros/Cargo.toml b/mingling_macros/Cargo.toml
index 137213a..6ae7aeb 100644
--- a/mingling_macros/Cargo.toml
+++ b/mingling_macros/Cargo.toml
@@ -19,11 +19,11 @@ default = []
async = []
clap = []
-comp = []
+comp = ["dep:just_template", "dep:mingling_pathf"]
dispatch_tree = []
structural_renderer = []
repl = []
-pathf = []
+pathf = ["dep:mingling_pathf"]
extras = []
@@ -33,3 +33,9 @@ quote.workspace = true
proc-macro2.workspace = true
just_fmt.workspace = true
+
+# comp — compile-time completion script generation (build_comp!())
+just_template = { workspace = true, optional = true }
+
+# pathf — compile-time type path analysis (build_pathf!())
+mingling_pathf = { workspace = true, optional = true }
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_macros/src/attr/completion.rs b/mingling_macros/src/attr/completion.rs
index cbc0fef..71acaae 100644
--- a/mingling_macros/src/attr/completion.rs
+++ b/mingling_macros/src/attr/completion.rs
@@ -34,52 +34,92 @@ pub(crate) fn completion_attr(attr: TokenStream, item: TokenStream) -> TokenStre
let inputs = &sig.inputs;
let output = &sig.output;
- // The first parameter (if any) is the completion context. It may be
- // `&ShellContext`, an owned `ShellContext`, or any other type that
- // implements `From<&ShellContext>`. With no parameters, the completion
- // function simply ignores the shell context.
- let ctx_ty: Option<Type> = match inputs.first() {
- None => None,
- Some(FnArg::Typed(PatType { ty, .. })) => Some((**ty).clone()),
- Some(FnArg::Receiver(_)) => {
- return syn::Error::new(
- inputs.span(),
- "Completion function cannot have self parameter",
- )
- .to_compile_error()
- .into();
- }
- };
+ // Parameter classification:
+ // - Owned (non-reference) parameters are **Shell sources**: each is derived
+ // from `&ShellContext` via `From<&ShellContext>` (identity `From` covers
+ // `ShellContext` itself).
+ // - `&T` / `&mut T` parameters are resource injections (unchanged).
+ // - `&ShellContext` is rejected with a helpful message: use the owned
+ // `ShellContext` (or any other `From<&ShellContext>` type) instead.
+ let mut derived_stmts: Vec<proc_macro2::TokenStream> = Vec::new();
+ let mut call_args: Vec<proc_macro2::TokenStream> = Vec::new();
+ let mut resources = Vec::new();
- // Resource injection starts after the context parameter.
- let resource_skip = usize::from(ctx_ty.is_some());
- let resources = match extract_resources_from_args(sig, resource_skip) {
- Ok(r) => r,
- Err(e) => return e.to_compile_error().into(),
- };
- if ctx_ty.is_none() && !resources.is_empty() {
- return syn::Error::new(
- inputs.span(),
- "A completion function without a context parameter cannot inject resources",
- )
- .to_compile_error()
- .into();
- }
+ for (idx, arg) in inputs.iter().enumerate() {
+ match arg {
+ FnArg::Typed(PatType { pat, ty, .. }) => {
+ if let Type::Reference(ref_type) = &**ty {
+ // `&ShellContext` is no longer allowed: it clashes with the
+ // resource-injection semantics of references.
+ if is_shell_context_path(&ref_type.elem) {
+ return syn::Error::new(
+ ty.span(),
+ "`&ShellContext` is not supported; use the owned `ShellContext` \
+ (or any other type implementing `From<&ShellContext>`) as a value \
+ parameter",
+ )
+ .to_compile_error()
+ .into();
+ }
- // Bind the shell context to the declared parameter type (identity `From`
- // covers `&ShellContext` itself).
- let (ctx_bind_stmt, ctx_call_arg) = ctx_ty.as_ref().map_or_else(
- || (quote! { let _ = ctx; }, quote! {}),
- |ty| {
- (
- quote! {
- let __ctx: #ty =
- <#ty as ::std::convert::From<&::mingling::ShellContext>>::from(ctx);
- },
- quote! { __ctx },
- )
- },
- );
+ // Reference: resource injection (requires a named binding).
+ let var_name = match &**pat {
+ Pat::Ident(pat_ident) => pat_ident.ident.clone(),
+ _ => {
+ return syn::Error::new(
+ pat.span(),
+ "Resource injection parameter must be a simple identifier",
+ )
+ .to_compile_error()
+ .into();
+ }
+ };
+
+ // Reference: resource injection.
+ let (inner_type, is_mut) = match &*ref_type.elem {
+ Type::Path(type_path) => {
+ let is_mut = ref_type.mutability.is_some();
+ (type_path.clone(), is_mut)
+ }
+ _ => {
+ return syn::Error::new(
+ ty.span(),
+ "Reference resource type must be a type path",
+ )
+ .to_compile_error()
+ .into();
+ }
+ };
+ resources.push(ResourceInjection {
+ var_name: var_name.clone(),
+ full_type: (**ty).clone(),
+ inner_type,
+ is_ref: true,
+ is_mut,
+ });
+ call_args.push(quote! { #var_name });
+ } else {
+ // Owned value: derive from `&ShellContext`. The parameter
+ // name is irrelevant (anonymous `_` is fine) since the
+ // derived binding is generated by this macro.
+ let derived_ident = Ident::new(&format!("__ctx_derived_{idx}"), pat.span());
+ derived_stmts.push(quote! {
+ let #derived_ident: #ty =
+ <#ty as ::std::convert::From<&::mingling::ShellContext>>::from(ctx);
+ });
+ call_args.push(quote! { #derived_ident });
+ }
+ }
+ FnArg::Receiver(_) => {
+ return syn::Error::new(
+ arg.span(),
+ "Completion function cannot have self parameter",
+ )
+ .to_compile_error()
+ .into();
+ }
+ }
+ }
let fn_body = &input_fn.block;
@@ -96,26 +136,11 @@ pub(crate) fn completion_attr(attr: TokenStream, item: TokenStream) -> TokenStre
let struct_name = Ident::new(&internal_name, fn_name.span());
let program_type = crate::default_program_path();
- let has_resources = !resources.is_empty();
let mut_resources: Vec<_> = resources.iter().filter(|r| r.is_mut).collect();
let immut_resource_stmts = generate_immut_resource_bindings(resources.iter(), &program_type);
- let resource_args: Vec<_> = resources
- .iter()
- .map(|res| {
- let var_name = &res.var_name;
- quote! { #var_name }
- })
- .collect();
-
- let fn_call = if has_resources {
- quote! { #fn_name(#ctx_call_arg, #(#resource_args),*) }
- } else if ctx_ty.is_some() {
- quote! { #fn_name(#ctx_call_arg) }
- } else {
- quote! { #fn_name() }
- };
+ let fn_call = quote! { #fn_name(#(#call_args),*) };
let inner_call = if mut_resources.is_empty() {
fn_call
@@ -133,13 +158,10 @@ pub(crate) fn completion_attr(attr: TokenStream, item: TokenStream) -> TokenStre
wrapped
};
- let comp_body = if has_resources {
- quote! {
- #(#immut_resource_stmts)*
- #inner_call
- }
- } else {
- quote! { #inner_call }
+ let comp_body = quote! {
+ #(#derived_stmts)*
+ #(#immut_resource_stmts)*
+ #inner_call
};
// A `()` return (or no return type) means "no suggestions": map it to an
@@ -172,7 +194,6 @@ pub(crate) fn completion_attr(attr: TokenStream, item: TokenStream) -> TokenStre
type Previous = #previous_type_path;
fn comp(ctx: &::mingling::ShellContext) -> ::mingling::Suggest {
- #ctx_bind_stmt
#return_stmt
}
}
@@ -208,69 +229,16 @@ pub(crate) fn completion_attr(attr: TokenStream, item: TokenStream) -> TokenStre
expanded.into()
}
-/// Extract resource injection parameters from function arguments (skipping the first N params).
-fn extract_resources_from_args(
- sig: &syn::Signature,
- skip: usize,
-) -> syn::Result<Vec<ResourceInjection>> {
- let mut resources = Vec::new();
- for arg in sig.inputs.iter().skip(skip) {
- match arg {
- FnArg::Typed(PatType { pat, ty, .. }) => {
- let var_name = match &**pat {
- Pat::Ident(pat_ident) => pat_ident.ident.clone(),
- _ => {
- return Err(syn::Error::new(
- pat.span(),
- "Resource injection parameter must be a simple identifier",
- ));
- }
- };
-
- let full_type = *(*ty).clone();
-
- let (inner_type, is_ref, is_mut) = match &full_type {
- Type::Reference(ref_type) => match &*ref_type.elem {
- Type::Path(type_path) => {
- let is_mut = ref_type.mutability.is_some();
- (type_path.clone(), true, is_mut)
- }
- _ => {
- return Err(syn::Error::new(
- ty.span(),
- "Reference resource type must be a type path",
- ));
- }
- },
- Type::Path(_) => {
- return Err(syn::Error::new(
- ty.span(),
- "Resource injection parameter must be a reference (`&T` or `&mut T`)",
- ));
- }
- _ => {
- return Err(syn::Error::new(
- ty.span(),
- "Resource injection type must be a type path or reference",
- ));
- }
- };
-
- resources.push(ResourceInjection {
- var_name,
- full_type,
- inner_type,
- is_ref,
- is_mut,
- });
- }
- FnArg::Receiver(_) => {
- return Err(syn::Error::new(
- arg.span(),
- "Resource injection parameter cannot be self",
- ));
- }
- }
+/// Returns `true` when the type is a path whose last segment is `ShellContext`
+/// (e.g. `ShellContext` or `mingling::ShellContext`).
+fn is_shell_context_path(ty: &Type) -> bool {
+ if let Type::Path(type_path) = ty {
+ type_path
+ .path
+ .segments
+ .last()
+ .is_some_and(|seg| seg.ident == "ShellContext")
+ } else {
+ false
}
- Ok(resources)
}
diff --git a/mingling_macros/src/build.rs b/mingling_macros/src/build.rs
new file mode 100644
index 0000000..8f2949d
--- /dev/null
+++ b/mingling_macros/src/build.rs
@@ -0,0 +1,56 @@
+//! Compile-time build logic for `build_comp!()` and `build_pathf!()`.
+//!
+//! The build steps run as a side effect of macro expansion (during `gen_program!`),
+//! writing artifacts under `{target_directory}/mingling/`.
+
+#[doc(hidden)]
+#[cfg(feature = "comp")]
+pub(crate) mod comp;
+
+#[doc(hidden)]
+#[cfg(feature = "pathf")]
+pub(crate) mod pathf;
+
+/// Shared implementation behind `build_comp!()`.
+///
+/// Accepts an optional string literal (the binary name); defaults to
+/// `CARGO_PKG_NAME`. Returns an empty token stream on success, or a
+/// `compile_error!` token stream on failure.
+#[cfg(feature = "comp")]
+pub(crate) fn comp_build_impl(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
+ let bin_name: String = if input.is_empty() {
+ std::env::var("CARGO_PKG_NAME").unwrap_or_default()
+ } else {
+ match syn::parse::<syn::LitStr>(input) {
+ Ok(lit) => lit.value(),
+ Err(e) => return e.to_compile_error().into(),
+ }
+ };
+
+ match comp::build_comp_scripts(&bin_name) {
+ Ok(()) => proc_macro::TokenStream::new(),
+ Err(e) => {
+ let msg = format!("build_comp: failed to generate completion scripts: {e}");
+ syn::Error::new(proc_macro2::Span::call_site(), msg)
+ .to_compile_error()
+ .into()
+ }
+ }
+}
+
+/// Shared implementation behind `build_pathf!()`.
+///
+/// Runs the pathf type-mapping analysis. Returns an empty token stream on
+/// success, or a `compile_error!` token stream on failure.
+#[cfg(feature = "pathf")]
+pub(crate) fn pathf_build_impl(_input: proc_macro::TokenStream) -> proc_macro::TokenStream {
+ match pathf::analyze_and_build_type_mapping() {
+ Ok(()) => proc_macro::TokenStream::new(),
+ Err(e) => {
+ let msg = format!("build_pathf: type mapping analysis failed: {e}");
+ syn::Error::new(proc_macro2::Span::call_site(), msg)
+ .to_compile_error()
+ .into()
+ }
+ }
+}
diff --git a/mingling_core/src/build/comp.rs b/mingling_macros/src/build/comp.rs
index d6bb34f..81a26e8 100644
--- a/mingling_core/src/build/comp.rs
+++ b/mingling_macros/src/build/comp.rs
@@ -2,7 +2,27 @@ use std::path::PathBuf;
use just_template::tmpl;
-use crate::ShellFlag;
+/// Represents the shell environment for which the output format is intended.
+///
+/// This is an internal copy of `mingling_core::ShellFlag`, kept private to the
+/// build module because the macros crate must not depend on `mingling_core`.
+/// Which variants are constructed depends on the target OS (`#[cfg]`), so
+/// platform-gated variants may be unused on any given host.
+#[allow(dead_code)]
+#[derive(Default, Debug, Clone, PartialEq, Eq)]
+pub(crate) enum ShellFlag {
+ /// Represents the Bash shell.
+ #[default]
+ Bash,
+ /// Represents the Zsh shell.
+ Zsh,
+ /// Represents the Fish shell.
+ Fish,
+ /// Represents `PowerShell`.
+ Powershell,
+ /// A custom or unsupported shell type, identified by the provided string.
+ Other(String),
+}
const TMPL_COMP_BASH: &str = include_str!("../../tmpls/comps/bash.sh");
const TMPL_COMP_ZSH: &str = include_str!("../../tmpls/comps/zsh.zsh");
@@ -15,21 +35,10 @@ const TMPL_COMP_PWSH: &str = include_str!("../../tmpls/comps/pwsh.ps1");
/// On Linux, generates Zsh, Bash, and Fish completions.
/// Scripts are written to the `OUT_DIR` (or `target/` if `OUT_DIR` is not set).
///
-/// # Example
-/// ```
-/// # #[cfg(all(feature = "build", feature = "comp"))] {
-/// # temp_env::with_var("OUT_DIR", Some(".temp/target/test/out/"), || {
-/// # use mingling_core::ShellFlag;
-/// # use mingling_core::build::build_comp_scripts;
-/// // Generate completion scripts for "myapp"
-/// build_comp_scripts("myapp").unwrap();
+/// # Errors
///
-/// // Generate completion scripts for current package
-/// build_comp_scripts(env!("CARGO_PKG_NAME")).unwrap();
-/// # });
-/// # }
-/// ```
-pub fn build_comp_scripts(name: &str) -> Result<(), std::io::Error> {
+/// Returns an [`std::io::Error`] if a script cannot be written.
+pub(crate) fn build_comp_scripts(name: &str) -> Result<(), std::io::Error> {
#[cfg(target_os = "windows")]
{
build_comp_script(&ShellFlag::Powershell, name)?;
@@ -57,22 +66,23 @@ pub fn build_comp_scripts(name: &str) -> Result<(), std::io::Error> {
///
/// This function takes a shell flag and a binary name, selects the appropriate
/// template, substitutes the binary name into the template, and writes the
-/// resulting completion script to the target directory (typically `target/`).
+/// resulting completion script to the Mingling build directory
+/// (`{target_directory}/mingling/`, resolved via `cargo metadata`).
+///
+/// # Errors
///
-/// # Example
-/// ```
-/// # #[cfg(all(feature = "build", feature = "comp"))] {
-/// # temp_env::with_var("OUT_DIR", Some(".temp/target/test/out/"), || {
-/// # use mingling_core::ShellFlag;
-/// # use mingling_core::build::build_comp_script;
-/// build_comp_script(&ShellFlag::Bash, "myapp").unwrap();
-/// # });
-/// # }
-/// ```
-pub fn build_comp_script(shell_flag: &ShellFlag, bin_name: &str) -> Result<(), std::io::Error> {
- let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
- let target_dir = out_dir.join("../../../");
- build_comp_script_to(shell_flag, bin_name, &target_dir.to_string_lossy())
+/// Returns an [`std::io::Error`] if the script cannot be written.
+pub(crate) fn build_comp_script(
+ shell_flag: &ShellFlag,
+ bin_name: &str,
+) -> Result<(), std::io::Error> {
+ let output_dir = comp_output_dir()?;
+ build_comp_script_to(shell_flag, bin_name, &output_dir.to_string_lossy())
+}
+
+/// The directory where completion scripts are written: `{target_directory}/mingling/`.
+fn comp_output_dir() -> Result<PathBuf, std::io::Error> {
+ mingling_pathf::build_output_dir().map_err(|e| std::io::Error::other(e.to_string()))
}
/// Generate a shell completion script to a specified directory.
@@ -81,17 +91,10 @@ pub fn build_comp_script(shell_flag: &ShellFlag, bin_name: &str) -> Result<(), s
/// selects the appropriate template, substitutes the binary name into the template,
/// and writes the resulting completion script to the specified directory.
///
-/// # Example
-/// ```
-/// # #[cfg(all(feature = "build", feature = "comp"))] {
-/// # temp_env::with_var("OUT_DIR", Some(".temp/target/test/out/"), || {
-/// # use mingling_core::ShellFlag;
-/// # use mingling_core::build::build_comp_script_to;
-/// build_comp_script_to(&ShellFlag::Bash, "myapp", ".temp/target/test/out/").unwrap();
-/// # });
-/// # }
-/// ```
-pub fn build_comp_script_to(
+/// # Errors
+///
+/// Returns an [`std::io::Error`] if the script cannot be written.
+pub(crate) fn build_comp_script_to(
shell_flag: &ShellFlag,
bin_name: &str,
target_dir: &str,
@@ -105,33 +108,6 @@ pub fn build_comp_script_to(
std::fs::write(&output_path, tmpl.to_string())
}
-/// Generate a shell completion script and write it to a specified file path.
-///
-/// This function takes a shell flag, a binary name, and an output file path,
-/// selects the appropriate template, substitutes the binary name into the template,
-/// and writes the resulting completion script directly to the specified file path.
-///
-/// # Example
-/// ```
-/// # #[cfg(all(feature = "build", feature = "comp"))] {
-/// # temp_env::with_var("OUT_DIR", Some(".temp/target/test/out/"), || {
-/// # use mingling_core::ShellFlag;
-/// # use mingling_core::build::build_comp_script_to_file;
-/// build_comp_script_to_file(&ShellFlag::Bash, "myapp", ".temp/target/test/out/myapp.comp.sh").unwrap();
-/// # });
-/// # }
-/// ```
-pub fn build_comp_script_to_file(
- shell_flag: &ShellFlag,
- bin_name: &str,
- output_path: impl Into<PathBuf>,
-) -> Result<(), std::io::Error> {
- let (tmpl_str, _ext) = get_tmpl(shell_flag);
- let mut tmpl = just_template::Template::from(tmpl_str);
- tmpl!(bin_name = bin_name);
- std::fs::write(output_path.into(), tmpl.to_string())
-}
-
const fn get_tmpl(shell_flag: &ShellFlag) -> (&'static str, &'static str) {
match shell_flag {
ShellFlag::Bash | ShellFlag::Other(_) => (TMPL_COMP_BASH, ".sh"),
@@ -144,7 +120,6 @@ const fn get_tmpl(shell_flag: &ShellFlag) -> (&'static str, &'static str) {
#[cfg(test)]
mod tests {
use super::*;
- use crate::ShellFlag;
#[test]
fn get_tmpl_bash() {
diff --git a/mingling_macros/src/build/pathf.rs b/mingling_macros/src/build/pathf.rs
new file mode 100644
index 0000000..788f527
--- /dev/null
+++ b/mingling_macros/src/build/pathf.rs
@@ -0,0 +1,19 @@
+use std::path::PathBuf;
+
+use mingling_pathf::error::MinglingPathfinderError;
+
+/// The directory where pathf's build artifacts are stored for the current
+/// crate: `{target_directory}/mingling/{CARGO_PKG_NAME}`.
+pub fn output_dir() -> Result<PathBuf, MinglingPathfinderError> {
+ Ok(mingling_pathf::build_output_dir()?.join(crate_name()))
+}
+
+/// Runs the pathf type-mapping analysis for the current crate at compile time
+/// (replacing the previous `build.rs` call).
+pub fn analyze_and_build_type_mapping() -> Result<(), MinglingPathfinderError> {
+ mingling_pathf::analyze_and_build_type_mapping()
+}
+
+fn crate_name() -> String {
+ std::env::var("CARGO_PKG_NAME").unwrap_or_default()
+}
diff --git a/mingling_macros/src/func/gen_program.rs b/mingling_macros/src/func/gen_program.rs
index c0a7ea8..35e8352 100644
--- a/mingling_macros/src/func/gen_program.rs
+++ b/mingling_macros/src/func/gen_program.rs
@@ -7,6 +7,11 @@ use quote::quote;
/// Generates the `Next` type alias, `Routable` impl for `ChainProcess`,
/// and delegates to `program_comp_gen!()`, `program_fallback_gen!()`,
/// and `program_final_gen!()`.
+///
+/// When the `comp` / `pathf` features are enabled, the expansion begins by
+/// invoking `build_comp!()` / `build_pathf!()`, which run the build steps
+/// (previously done in `build.rs`) as a compile-time side effect and expand
+/// to nothing.
pub(crate) fn gen_program_impl(_input: TokenStream) -> TokenStream {
#[cfg(feature = "comp")]
let comp_gen = quote! {
@@ -16,18 +21,46 @@ pub(crate) fn gen_program_impl(_input: TokenStream) -> TokenStream {
#[cfg(not(feature = "comp"))]
let comp_gen = quote! {};
- // When pathf is enabled, load the type_using.rs generated by the build script
+ // `build_pathf!()` / `build_comp!()` are invoked at the very beginning of the
+ // expansion: they run the build logic at compile time and expand to nothing.
+ #[cfg(feature = "comp")]
+ let comp_build = quote! {
+ ::mingling::macros::build_comp!();
+ };
+
+ #[cfg(not(feature = "comp"))]
+ let comp_build = quote! {};
+
+ #[cfg(feature = "pathf")]
+ let pathf_build = quote! {
+ ::mingling::macros::build_pathf!();
+ };
+
+ #[cfg(not(feature = "pathf"))]
+ let pathf_build = quote! {};
+
+ // When pathf is enabled, load the type_using.rs generated by the build logic
// and emit its use statements so types from submodules are in scope.
#[cfg(feature = "pathf")]
let pathf_uses: Vec<proc_macro2::TokenStream> = {
+ // The `build_pathf!()` macro emitted above will (re-)run the analysis
+ // during expansion, but the `use` statements are needed right now, so
+ // make sure the mapping exists before reading it.
+ if let Err(e) = crate::build::pathf::analyze_and_build_type_mapping() {
+ let msg = format!("pathf: type mapping analysis failed: {e}");
+ return syn::Error::new(proc_macro2::Span::call_site(), msg)
+ .to_compile_error()
+ .into();
+ }
let uses = load_pathf_uses();
if uses.is_empty() {
- // The file might not exist yet — emit a clear hint
+ // The analyzer found nothing — emit a clear hint
let hint: proc_macro2::TokenStream = syn::parse_quote! {
compile_error!(
- "pathf: `{}` not found or empty.\n\
- Make sure `build.rs` calls `mingling::build::analyze_and_build_type_mapping().unwrap();`\n\
- with features [\"build\", \"pathf\"] enabled."
+ "pathf: no types were found by the analyzer.\n\
+ Make sure the `pathf` feature is enabled (which also enables\n\
+ the `build_pathf!()` macro) and that `gen_program!()` is called\n\
+ in a crate with a `src/` directory."
);
};
vec![hint]
@@ -47,6 +80,8 @@ pub(crate) fn gen_program_impl(_input: TokenStream) -> TokenStream {
};
TokenStream::from(quote! {
+ #comp_build
+ #pathf_build
pub use __this_program_impl::*;
#[doc(hidden)]
@@ -88,24 +123,16 @@ pub(crate) fn gen_program_impl(_input: TokenStream) -> TokenStream {
})
}
-/// Loads `type_using.rs` generated by the pathf build script and returns each
+/// Loads `type_using.rs` generated by the pathf build logic and returns each
/// `use ...;` line as a token stream, ready to be emitted in the generated output.
#[cfg(feature = "pathf")]
fn load_pathf_uses() -> Vec<proc_macro2::TokenStream> {
- let out_dir = match std::env::var("OUT_DIR") {
- Ok(d) => d,
- Err(_) => return Vec::new(),
- };
- let crate_name = match std::env::var("CARGO_PKG_NAME") {
- Ok(n) => n,
- Err(_) => return Vec::new(),
+ let Ok(output_dir) = crate::build::pathf::output_dir() else {
+ return Vec::new();
};
- let path = std::path::Path::new(&out_dir)
- .join(&crate_name)
- .join("type_using.rs");
- let content = match std::fs::read_to_string(&path) {
- Ok(c) => c,
- Err(_) => return Vec::new(),
+ let path = output_dir.join("type_using.rs");
+ let Ok(content) = std::fs::read_to_string(&path) else {
+ return Vec::new();
};
content
.lines()
diff --git a/mingling_macros/src/func/program_final_gen.rs b/mingling_macros/src/func/program_final_gen.rs
index 8123eaf..ceee66d 100644
--- a/mingling_macros/src/func/program_final_gen.rs
+++ b/mingling_macros/src/func/program_final_gen.rs
@@ -273,9 +273,25 @@ pub(crate) fn program_final_gen_impl(_input: TokenStream) -> TokenStream {
.collect();
let do_chain_fn = if chain_tokens.is_empty() {
- quote! {
- fn do_chain(_any: ::mingling::AnyOutput<Self::Enum>) -> ::mingling::ChainProcess<Self::Enum> {
- ::core::panic!("No chain found for type id")
+ // An empty chain list is still valid, but the synthesized `do_chain`
+ // must match the trait signature for the enabled mode. The sync
+ // branch used unconditionally breaks the `async` feature (E0053),
+ // so dispatch on `ASYNC_ENABLED` here as well.
+ if ASYNC_ENABLED {
+ quote! {
+ fn do_chain(
+ _any: ::mingling::AnyOutput<Self::Enum>,
+ ) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::mingling::ChainProcess<Self::Enum>> + ::std::marker::Send>> {
+ ::std::boxed::Box::pin(async {
+ ::core::panic!("No chain found for type id")
+ })
+ }
+ }
+ } else {
+ quote! {
+ fn do_chain(_any: ::mingling::AnyOutput<Self::Enum>) -> ::mingling::ChainProcess<Self::Enum> {
+ ::core::panic!("No chain found for type id")
+ }
}
}
} else if ASYNC_ENABLED {
diff --git a/mingling_macros/src/lib.rs b/mingling_macros/src/lib.rs
index 2271e21..1aaedb1 100644
--- a/mingling_macros/src/lib.rs
+++ b/mingling_macros/src/lib.rs
@@ -20,6 +20,9 @@ mod derive;
mod func;
mod systems;
+#[cfg(any(feature = "comp", feature = "pathf"))]
+mod build;
+
mod extensions;
mod utils;
@@ -689,31 +692,32 @@ pub fn renderer(attr: TokenStream, item: TokenStream) -> TokenStream {
/// 2. Registering the completion mapping for the specified entry type.
/// 3. Keeping the original function for direct calls.
///
-/// # Syntax
-///
-/// The completion function accepts a relaxed signature:
+/// # Signature rules
///
-/// - **Context parameter (optional):** the first parameter may be `&ShellContext`,
-/// an owned `ShellContext`, or any type implementing `From<&ShellContext>`.
-/// With no parameters at all, the shell context is ignored.
-/// - **Return type:** anything implementing `Into<Suggest>`, e.g. `Suggest`,
-/// `Vec<String>`, `Vec<(String, String)>` (suggestion + description), or a
-/// set of [`SuggestItem`](https://docs.rs/mingling/latest/mingling/struct.SuggestItem.html)s.
-/// - **Resource injection:** remaining parameters are injected resources
-/// (only when a context parameter is present).
+/// - **Owned (non-reference) parameters** are *shell sources*: each one is derived
+/// from `&ShellContext` via `From<&ShellContext>`. This covers `ShellContext`
+/// itself (via its `Clone`-based `From` impl), framework state types, and any
+/// user-defined state derived from the shell context.
+/// - **`&T` / `&mut T` parameters** are resource injections (same as `#[chain]`).
+/// - **`&ShellContext` is rejected** — use the owned `ShellContext` instead, since
+/// reference parameters are reserved for resources.
+/// - The return type can be anything implementing `Into<Suggest>`: `Suggest`,
+/// `Vec<String>`, `Vec<&str>`, `Vec<(String, String)>` (suggestion + description),
+/// a set of [`SuggestItem`](https://docs.rs/mingling/latest/mingling/struct.SuggestItem.html)s,
+/// or `()` / no return type for "no suggestions".
///
/// ```rust,ignore
/// // No context, return simple suggestions
/// #[completion(EntryType)]
-/// fn complete_static() -> Vec<String> { vec!["a", "b"].into_iter().map(str::to_string).collect() }
+/// fn complete_static() -> Vec<&str> { vec!["a", "b"] }
///
-/// // Owned context (via `From<&ShellContext>`), suggestions with descriptions
+/// // Multiple shell-derived states + resource injection
/// #[completion(EntryType)]
-/// fn complete_owned(ctx: ShellContext) -> Vec<(String, String)> { /* ... */ }
+/// fn complete_mixed(pos: PositionState, flags: FlagState, db: &ResDb) -> Vec<(String, String)> { /* ... */ }
///
-/// // Borrowed context (classic form)
+/// // Empty function: this command needs no completion
/// #[completion(EntryType)]
-/// fn complete_borrowed(ctx: &ShellContext) -> Suggest { /* ... */ }
+/// fn complete_nothing() {}
/// ```
///
/// # Example
@@ -723,7 +727,7 @@ pub fn renderer(attr: TokenStream, item: TokenStream) -> TokenStream {
/// use mingling::{ShellContext, Suggest};
///
/// #[completion(MyEntry)]
-/// fn complete_my_command(ctx: &ShellContext) -> Suggest {
+/// fn complete_my_command(ctx: ShellContext) -> Suggest {
/// if ctx.previous_word == "--type" {
/// return suggest!();
/// }
@@ -740,8 +744,9 @@ pub fn renderer(attr: TokenStream, item: TokenStream) -> TokenStream {
/// # Requirements
///
/// - The `comp` feature must be enabled.
-/// - The first parameter (if any) must implement `From<&ShellContext>`.
-/// - The return type must implement `Into<Suggest>`.
+/// - Owned parameters must implement `From<&ShellContext>`.
+/// - Reference parameters are resource injections; `&ShellContext` is not allowed.
+/// - The return type must implement `Into<Suggest>` (or be `()`).
/// - The function cannot be async.
#[cfg(feature = "comp")]
#[proc_macro_attribute]
@@ -1651,6 +1656,50 @@ pub fn gen_program(input: TokenStream) -> TokenStream {
func::gen_program::gen_program_impl(input)
}
+/// Executes the completion-script build at compile time and expands to nothing.
+///
+/// **This macro is only available with the `comp` feature.**
+///
+/// The completion scripts are written to `{target_directory}/mingling/` (the
+/// target directory is resolved via `cargo metadata`).
+///
+/// `gen_program!()` calls this macro automatically when the `comp` feature is
+/// enabled. It can also be invoked manually to customize the binary name:
+///
+/// - `build_comp!()` — uses the current package name (`CARGO_PKG_NAME`).
+/// - `build_comp!("mybin")` — uses the given binary name.
+///
+/// ```rust,ignore
+/// mingling::macros::build_comp!();
+/// // or:
+/// mingling::macros::build_comp!("mybin");
+/// ```
+#[cfg(feature = "comp")]
+#[proc_macro]
+pub fn build_comp(input: TokenStream) -> TokenStream {
+ build::comp_build_impl(input)
+}
+
+/// Executes the pathf type-mapping build at compile time and expands to nothing.
+///
+/// **This macro is only available with the `pathf` feature.**
+///
+/// The mapping files are written to `{target_directory}/mingling/{CARGO_PKG_NAME}/`
+/// (the target directory is resolved via `cargo metadata`), and are consumed by
+/// `gen_program!()` so that types defined in submodules are resolved automatically.
+///
+/// `gen_program!()` calls this macro automatically when the `pathf` feature is
+/// enabled.
+///
+/// ```rust,ignore
+/// mingling::macros::build_pathf!();
+/// ```
+#[cfg(feature = "pathf")]
+#[proc_macro]
+pub fn build_pathf(input: TokenStream) -> TokenStream {
+ build::pathf_build_impl(input)
+}
+
/// Internal macro used by `gen_program!` to generate the completion infrastructure for
/// shell completion support.
///
diff --git a/mingling_core/tmpls/comps/bash.sh b/mingling_macros/tmpls/comps/bash.sh
index edec28d..edec28d 100644
--- a/mingling_core/tmpls/comps/bash.sh
+++ b/mingling_macros/tmpls/comps/bash.sh
diff --git a/mingling_core/tmpls/comps/fish.fish b/mingling_macros/tmpls/comps/fish.fish
index 64b4ed3..64b4ed3 100644
--- a/mingling_core/tmpls/comps/fish.fish
+++ b/mingling_macros/tmpls/comps/fish.fish
diff --git a/mingling_core/tmpls/comps/pwsh.ps1 b/mingling_macros/tmpls/comps/pwsh.ps1
index d72a027..d72a027 100644
--- a/mingling_core/tmpls/comps/pwsh.ps1
+++ b/mingling_macros/tmpls/comps/pwsh.ps1
diff --git a/mingling_core/tmpls/comps/zsh.zsh b/mingling_macros/tmpls/comps/zsh.zsh
index 7cf5f7b..7cf5f7b 100644
--- a/mingling_core/tmpls/comps/zsh.zsh
+++ b/mingling_macros/tmpls/comps/zsh.zsh
diff --git a/mingling_pathf/Cargo.toml b/mingling_pathf/Cargo.toml
index 0d4e37a..a649cb4 100644
--- a/mingling_pathf/Cargo.toml
+++ b/mingling_pathf/Cargo.toml
@@ -12,3 +12,4 @@ description = "A library for automatically finding internal types generated by M
syn.workspace = true
proc-macro2.workspace = true
just_fmt.workspace = true
+cargo_metadata.workspace = true
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"]
diff --git a/mingling_pathf/src/error.rs b/mingling_pathf/src/error.rs
index 5a748c4..bd850a8 100644
--- a/mingling_pathf/src/error.rs
+++ b/mingling_pathf/src/error.rs
@@ -53,6 +53,11 @@ pub enum MinglingPathfinderError {
/// Details from the parser about the parse failure.
message: String,
},
+
+ /// `cargo metadata` could not be executed or parsed.
+ ///
+ /// `message` contains the underlying error from the cargo invocation.
+ CargoMetadata(String),
}
impl fmt::Display for MinglingPathfinderError {
@@ -82,6 +87,7 @@ impl fmt::Display for MinglingPathfinderError {
Self::SynError { path, message } => {
write!(f, "Failed to parse {}: {message}", path.display())
}
+ Self::CargoMetadata(message) => write!(f, "cargo metadata failed: {message}"),
}
}
}
diff --git a/mingling_pathf/src/lib.rs b/mingling_pathf/src/lib.rs
index 492bfc7..f0637e8 100644
--- a/mingling_pathf/src/lib.rs
+++ b/mingling_pathf/src/lib.rs
@@ -13,3 +13,5 @@ pub mod patterns;
mod type_mapping_builder;
pub use type_mapping_builder::analyze_and_build_type_mapping;
pub use type_mapping_builder::analyze_and_build_type_mapping_for;
+pub use type_mapping_builder::build_output_dir;
+pub use type_mapping_builder::target_directory;
diff --git a/mingling_pathf/src/type_mapping_builder.rs b/mingling_pathf/src/type_mapping_builder.rs
index 1ccd267..4d0799f 100644
--- a/mingling_pathf/src/type_mapping_builder.rs
+++ b/mingling_pathf/src/type_mapping_builder.rs
@@ -5,7 +5,9 @@
use std::collections::HashSet;
use std::fmt::Write as FmtWrite;
-use std::path::Path;
+use std::path::{Path, PathBuf};
+
+use cargo_metadata::MetadataCommand;
use crate::error::MinglingPathfinderError;
use crate::module_pathf;
@@ -87,40 +89,74 @@ pub fn analyze_and_build_type_mapping_for(
Ok(())
}
-/// Convenience version to be called from `build.rs`, automatically reading configuration
-/// from environment variables.
+/// Runs `cargo metadata` from the given crate directory and returns the
+/// workspace's target directory.
+///
+/// The subprocess resolves the target directory exactly as Cargo does,
+/// honoring `.cargo/config.toml`, `CARGO_TARGET_DIR`, and `--target-dir`.
+///
+/// `crate_dir` — crate root directory (i.e., the directory containing Cargo.toml).
+///
+/// # Errors
+///
+/// Returns a [`MinglingPathfinderError::CargoMetadata`] if `cargo metadata`
+/// cannot be executed or its output cannot be parsed.
+pub fn target_directory(crate_dir: &Path) -> Result<PathBuf, MinglingPathfinderError> {
+ let metadata = MetadataCommand::new()
+ .current_dir(crate_dir)
+ .no_deps()
+ .exec()
+ .map_err(|e| MinglingPathfinderError::CargoMetadata(e.to_string()))?;
+ Ok(metadata.target_directory.into_std_path_buf())
+}
+
+/// The directory where all of Mingling's compile-time build artifacts are
+/// written for the current crate: `{target_directory}/mingling/`.
+///
+/// Reads `CARGO_MANIFEST_DIR` from the environment to locate the crate, then
+/// resolves the target directory via [`target_directory`]. Works both from a
+/// `build.rs` and from proc-macro expansion (no `OUT_DIR` required).
+///
+/// # Errors
+///
+/// Returns a [`MinglingPathfinderError`] if the environment variables are
+/// missing or the target directory cannot be resolved.
+pub fn build_output_dir() -> Result<PathBuf, MinglingPathfinderError> {
+ let crate_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
+ MinglingPathfinderError::IoError(std::io::Error::new(
+ std::io::ErrorKind::NotFound,
+ "CARGO_MANIFEST_DIR not set",
+ ))
+ })?;
+ Ok(target_directory(Path::new(&crate_dir))?.join("mingling"))
+}
+
+/// Convenience version to be called from `build.rs` or macro expansion,
+/// automatically reading configuration from environment variables.
///
-/// Reads `CARGO_PKG_NAME` and `OUT_DIR`, and outputs to `{OUT_DIR}/{CARGO_PKG_NAME}/`.
+/// Reads `CARGO_PKG_NAME` and `CARGO_MANIFEST_DIR`, and outputs to
+/// `{target_directory}/mingling/{CARGO_PKG_NAME}/` (see [`build_output_dir`]).
///
/// # Errors
///
/// Returns a [`MinglingPathfinderError`] if the required environment variables
-/// (`CARGO_PKG_NAME`, `OUT_DIR`) are not set, the current directory cannot be
-/// determined, or the type mapping generation fails.
+/// (`CARGO_PKG_NAME`, `CARGO_MANIFEST_DIR`) are not set or the type mapping
+/// generation fails.
pub fn analyze_and_build_type_mapping() -> Result<(), MinglingPathfinderError> {
let crate_name = std::env::var("CARGO_PKG_NAME").map_err(|_| {
MinglingPathfinderError::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
- "CARGO_PKG_NAME not set (not running in build.rs?)",
+ "CARGO_PKG_NAME not set",
))
})?;
-
- let out_dir = std::env::var("OUT_DIR").map_err(|_| {
+ let crate_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
MinglingPathfinderError::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
- "OUT_DIR not set (not running in build.rs?)",
+ "CARGO_MANIFEST_DIR not set",
))
})?;
- let crate_dir = std::env::current_dir()?;
- let output_dir = Path::new(&out_dir).join(&crate_name);
-
- analyze_and_build_type_mapping_for(&crate_dir, &output_dir)?;
+ let output_dir = build_output_dir()?.join(&crate_name);
- // Notify Cargo to re-run build.rs when source files change
- println!("cargo:rerun-if-changed=src/");
- println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_OS");
- println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_ARCH");
-
- Ok(())
+ analyze_and_build_type_mapping_for(Path::new(&crate_dir), &output_dir)
}
diff --git a/mingling_pathf/test/Cargo.lock b/mingling_pathf/test/Cargo.lock
index 58c7c9a..7dc772a 100644
--- a/mingling_pathf/test/Cargo.lock
+++ b/mingling_pathf/test/Cargo.lock
@@ -3,6 +3,45 @@
version = 4
[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
name = "just_fmt"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -15,12 +54,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6170dccbc3ea15dfb7f2da964097f814aba1dd8f746d4ffc56f33245c38e6d96"
[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
name = "mingling_pathf"
version = "0.5.0"
dependencies = [
+ "cargo_metadata",
"just_fmt 0.2.0",
"proc-macro2",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -42,6 +88,59 @@ dependencies = [
]
[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[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 = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -53,6 +152,17 @@ dependencies = [
]
[[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 = "test-mingling-pathf"
version = "0.1.0"
dependencies = [
@@ -61,7 +171,33 @@ dependencies = [
]
[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"