diff options
Diffstat (limited to 'mingling_ci/src')
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(¤t) 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 -} |
