aboutsummaryrefslogtreecommitdiff
path: root/mingling_ci/src
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_ci/src')
-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
36 files changed, 0 insertions, 3921 deletions
diff --git a/mingling_ci/src/bin/ci.rs b/mingling_ci/src/bin/ci.rs
deleted file mode 100644
index 5b1d748..0000000
--- a/mingling_ci/src/bin/ci.rs
+++ /dev/null
@@ -1,30 +0,0 @@
-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
deleted file mode 100644
index b9a02dc..0000000
--- a/mingling_ci/src/cmd.rs
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index 0e9bf22..0000000
--- a/mingling_ci/src/cmd/cmd_git_lock.rs
+++ /dev/null
@@ -1,77 +0,0 @@
-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
deleted file mode 100644
index 41efefc..0000000
--- a/mingling_ci/src/cmd/cmd_git_unlock.rs
+++ /dev/null
@@ -1,116 +0,0 @@
-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
deleted file mode 100644
index 976851e..0000000
--- a/mingling_ci/src/cmd/cmd_report_clean.rs
+++ /dev/null
@@ -1,54 +0,0 @@
-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
deleted file mode 100644
index 2eff074..0000000
--- a/mingling_ci/src/cmd/cmd_report_collect.rs
+++ /dev/null
@@ -1,150 +0,0 @@
-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
deleted file mode 100644
index 5fff0c5..0000000
--- a/mingling_ci/src/cmd/cmd_show_features.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index 2be82d2..0000000
--- a/mingling_ci/src/cmd/cmd_show_manifests.rs
+++ /dev/null
@@ -1,71 +0,0 @@
-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
deleted file mode 100644
index 92d3475..0000000
--- a/mingling_ci/src/examples.rs
+++ /dev/null
@@ -1,186 +0,0 @@
-//! 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
deleted file mode 100644
index a6fab2c..0000000
--- a/mingling_ci/src/git.rs
+++ /dev/null
@@ -1,69 +0,0 @@
-//! 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
deleted file mode 100644
index 32a0cbd..0000000
--- a/mingling_ci/src/lib.rs
+++ /dev/null
@@ -1,28 +0,0 @@
-#![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
deleted file mode 100644
index 75f2cbe..0000000
--- a/mingling_ci/src/markdown.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-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
deleted file mode 100644
index 1bf3c57..0000000
--- a/mingling_ci/src/markdown/compare.rs
+++ /dev/null
@@ -1,203 +0,0 @@
-//! 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
deleted file mode 100644
index d781b7e..0000000
--- a/mingling_ci/src/markdown/project.rs
+++ /dev/null
@@ -1,347 +0,0 @@
-//! 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
deleted file mode 100644
index 8ecf18d..0000000
--- a/mingling_ci/src/markdown/test.rs
+++ /dev/null
@@ -1,152 +0,0 @@
-//! 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
deleted file mode 100644
index bd62ae1..0000000
--- a/mingling_ci/src/progress.rs
+++ /dev/null
@@ -1,24 +0,0 @@
-//! 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
deleted file mode 100644
index 1a1ac08..0000000
--- a/mingling_ci/src/reporter.rs
+++ /dev/null
@@ -1,208 +0,0 @@
-//! 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
deleted file mode 100644
index 54ed503..0000000
--- a/mingling_ci/src/res.rs
+++ /dev/null
@@ -1,14 +0,0 @@
-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
deleted file mode 100644
index 6017168..0000000
--- a/mingling_ci/src/res/collect_logs.rs
+++ /dev/null
@@ -1,203 +0,0 @@
-//! 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
deleted file mode 100644
index b20e83d..0000000
--- a/mingling_ci/src/res/crate_config.rs
+++ /dev/null
@@ -1,79 +0,0 @@
-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
deleted file mode 100644
index 8009514..0000000
--- a/mingling_ci/src/res/features.rs
+++ /dev/null
@@ -1,47 +0,0 @@
-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
deleted file mode 100644
index 91836d6..0000000
--- a/mingling_ci/src/res/manifests.rs
+++ /dev/null
@@ -1,103 +0,0 @@
-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
deleted file mode 100644
index 9d844a6..0000000
--- a/mingling_ci/src/res/print.rs
+++ /dev/null
@@ -1,174 +0,0 @@
-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
deleted file mode 100644
index a42e458..0000000
--- a/mingling_ci/src/task.rs
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index f67fe2e..0000000
--- a/mingling_ci/src/task/cmd_build_check.rs
+++ /dev/null
@@ -1,47 +0,0 @@
-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
deleted file mode 100644
index a0dd46e..0000000
--- a/mingling_ci/src/task/cmd_clippy_check.rs
+++ /dev/null
@@ -1,50 +0,0 @@
-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
deleted file mode 100644
index 3a77d4d..0000000
--- a/mingling_ci/src/task/cmd_docs_check.rs
+++ /dev/null
@@ -1,44 +0,0 @@
-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
deleted file mode 100644
index 1b9f440..0000000
--- a/mingling_ci/src/task/cmd_example_check.rs
+++ /dev/null
@@ -1,69 +0,0 @@
-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
deleted file mode 100644
index 2408636..0000000
--- a/mingling_ci/src/task/cmd_markdown_check.rs
+++ /dev/null
@@ -1,192 +0,0 @@
-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
deleted file mode 100644
index b014f1e..0000000
--- a/mingling_ci/src/task/cmd_markdown_compare.rs
+++ /dev/null
@@ -1,221 +0,0 @@
-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
deleted file mode 100644
index 5b9f55a..0000000
--- a/mingling_ci/src/task/cmd_test.rs
+++ /dev/null
@@ -1,54 +0,0 @@
-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
deleted file mode 100644
index ba752dd..0000000
--- a/mingling_ci/src/task/run.rs
+++ /dev/null
@@ -1,114 +0,0 @@
-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
deleted file mode 100644
index 13c2ec4..0000000
--- a/mingling_ci/src/tools.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-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
deleted file mode 100644
index dfb9b11..0000000
--- a/mingling_ci/src/tools/docsify_refresh.rs
+++ /dev/null
@@ -1,373 +0,0 @@
-//! 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
deleted file mode 100644
index ca8443c..0000000
--- a/mingling_ci/src/tools/example_refresh.rs
+++ /dev/null
@@ -1,279 +0,0 @@
-//! 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
deleted file mode 100644
index 87aeead..0000000
--- a/mingling_ci/src/tools/features_refresh.rs
+++ /dev/null
@@ -1,96 +0,0 @@
-//! 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
-}