aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mingling_ci/Cargo.lock22
-rw-r--r--mingling_ci/Cargo.toml2
-rw-r--r--mingling_ci/help.txt5
-rw-r--r--mingling_ci/src/lib.rs1
-rw-r--r--mingling_ci/src/tools.rs3
-rw-r--r--mingling_ci/src/tools/docsify_refresh.rs373
-rw-r--r--mingling_ci/src/tools/example_refresh.rs279
-rw-r--r--mingling_ci/src/tools/features_refresh.rs96
8 files changed, 781 insertions, 0 deletions
diff --git a/mingling_ci/Cargo.lock b/mingling_ci/Cargo.lock
index 1a86bbf..796176a 100644
--- a/mingling_ci/Cargo.lock
+++ b/mingling_ci/Cargo.lock
@@ -327,6 +327,8 @@ dependencies = [
"just_template",
"mingling",
"prettytable-rs",
+ "serde",
+ "serde_json",
"tokio",
"toml",
]
@@ -456,6 +458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
+ "serde_derive",
]
[[package]]
@@ -479,6 +482,19 @@ dependencies = [
]
[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -753,3 +769,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/mingling_ci/Cargo.toml b/mingling_ci/Cargo.toml
index 7c747db..c96043b 100644
--- a/mingling_ci/Cargo.toml
+++ b/mingling_ci/Cargo.toml
@@ -36,6 +36,8 @@ prettytable-rs = "0.10.0"
toml = "0.8"
just_template = "0.2.1"
just_fmt = "0.2.1"
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
[build-dependencies]
mingling = { version = "0.4.0", features = [
diff --git a/mingling_ci/help.txt b/mingling_ci/help.txt
index 9d80d2f..77d86d9 100644
--- a/mingling_ci/help.txt
+++ b/mingling_ci/help.txt
@@ -14,6 +14,11 @@ COMMANDS:
show-features Print the docs.rs feature list of mingling
show-manifests Print all crate paths that need to be checked
+ TOOLS:
+ example-refresh Regenerate example docs module and examples index
+ docsify-refresh Fix docsify code boxes and regenerate sidebars
+ features-refresh Regenerate the features module
+
TASKS:
markdown-check <PATH> Verify rust code blocks in one markdown file
markdown-check-all Verify rust code blocks in all configured markdown files
diff --git a/mingling_ci/src/lib.rs b/mingling_ci/src/lib.rs
index fad8d72..8ce8ce7 100644
--- a/mingling_ci/src/lib.rs
+++ b/mingling_ci/src/lib.rs
@@ -17,6 +17,7 @@ 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 {
diff --git a/mingling_ci/src/tools.rs b/mingling_ci/src/tools.rs
new file mode 100644
index 0000000..13c2ec4
--- /dev/null
+++ b/mingling_ci/src/tools.rs
@@ -0,0 +1,3 @@
+pub(crate) mod docsify_refresh;
+pub(crate) mod example_refresh;
+pub(crate) mod features_refresh;
diff --git a/mingling_ci/src/tools/docsify_refresh.rs b/mingling_ci/src/tools/docsify_refresh.rs
new file mode 100644
index 0000000..dfb9b11
--- /dev/null
+++ b/mingling_ci/src/tools/docsify_refresh.rs
@@ -0,0 +1,373 @@
+//! Docsify maintenance: fix code-box blank lines and regenerate `_sidebar.md`
+//! files under `docs/`.
+
+use std::collections::BTreeMap;
+use std::fmt::Write as _;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{buffer, command, r_println, renderer},
+};
+
+use crate::Next;
+use crate::res::{CargoError, MessagePrinter};
+
+const DOCS_DIR: &str = "./docs";
+const SIDEBAR_HEAD: &str = "- [Welcome!](README)\n";
+
+#[command(node = "docsify-refresh")]
+pub fn docsify_refresh() -> Next {
+ match refresh_all() {
+ Ok(written) => ResultDocsifyRefresh { written }.to_chain(),
+ Err(e) => ErrorDocsifyRefresh(e).to_chain(),
+ }
+}
+
+fn refresh_all() -> Result<Vec<String>, String> {
+ let mut written = Vec::new();
+ written.extend(fix_code_boxes());
+ written.extend(gen_sidebars()?);
+ Ok(written)
+}
+
+/// Part 1: docsify renders code blocks poorly when the blank lines around
+/// them are completely empty — replace them with a single space.
+fn fix_code_boxes() -> Vec<String> {
+ let mut file_count = 0;
+ let mut fixed_count = 0;
+ let mut written = Vec::new();
+
+ collect_md_files(Path::new(DOCS_DIR), &mut |path| {
+ if path
+ .file_name()
+ .is_some_and(|n| n.to_string_lossy().to_lowercase() == "_sidebar.md")
+ {
+ return;
+ }
+ let content = fs::read_to_string(path).unwrap_or_default();
+ if content.is_empty() {
+ return;
+ }
+ let new_content = fix_code_box_empty_lines(&content);
+ if new_content != content {
+ fs::write(path, &new_content).unwrap();
+ written.push(format!("fixed: {}", path.display()));
+ fixed_count += 1;
+ }
+ file_count += 1;
+ });
+
+ written.push(format!("scanned {file_count} files, fixed {fixed_count}"));
+ written
+}
+
+/// Replaces completely empty lines adjacent to fenced code blocks with lines
+/// containing a single space.
+fn fix_code_box_empty_lines(content: &str) -> String {
+ let mut result = String::new();
+ let lines: Vec<&str> = content.lines().collect();
+ let len = lines.len();
+
+ let mut i = 0;
+ while i < len {
+ let line = lines[i];
+ result.push_str(line);
+ result.push('\n');
+ i += 1;
+
+ if !line.trim_start().starts_with("```") {
+ continue;
+ }
+
+ // In a code block: find the closing fence.
+ let code_start = i;
+ let mut code_end = len;
+ let mut found_end = false;
+ while i < len {
+ let cline = lines[i];
+ if cline.trim_start().starts_with("```") && !cline.trim().is_empty() {
+ code_end = i;
+ found_end = true;
+ break;
+ }
+ i += 1;
+ }
+
+ ensure_space_before_code_block(&mut result);
+
+ for code_line in lines.iter().take(code_end).skip(code_start) {
+ if code_line.is_empty() {
+ result.push(' ');
+ } else {
+ result.push_str(code_line);
+ }
+ result.push('\n');
+ }
+
+ if found_end {
+ result.push_str(lines[code_end]);
+ result.push('\n');
+ i += 1;
+
+ if i < len && lines[i].trim().is_empty() && lines[i].is_empty() {
+ result.push(' ');
+ result.push('\n');
+ i += 1;
+ }
+ }
+ }
+
+ while result.ends_with('\n') {
+ result.pop();
+ }
+ result.push('\n');
+ result
+}
+
+/// Turns a trailing `\n\n` before a code block into `\n \n`.
+fn ensure_space_before_code_block(result: &mut String) {
+ let len = result.len();
+ if len >= 2 && &result[len - 2..] == "\n\n" {
+ result.insert(len - 1, ' ');
+ }
+}
+
+/// Part 2: find every README.md under `docs/` (each is a site root) and
+/// regenerate its `_sidebar.md`.
+fn gen_sidebars() -> Result<Vec<String>, String> {
+ let mut written = Vec::new();
+ for readme_path in find_all_readmes(Path::new(DOCS_DIR)) {
+ let site_root = readme_path
+ .parent()
+ .ok_or_else(|| format!("{} has no parent", readme_path.display()))?;
+ if let Some(content_dir) = find_content_dir(site_root) {
+ let lines = build_sidebar_content(site_root, &content_dir, SIDEBAR_HEAD);
+ let sidebar_path = site_root.join("_sidebar.md");
+ fs::write(&sidebar_path, lines)
+ .map_err(|e| format!("failed to write {}: {e}", sidebar_path.display()))?;
+ written.push(format!("generated: {}", sidebar_path.display()));
+ }
+ }
+ Ok(written)
+}
+
+/// Recursively finds all README.md files under a directory.
+fn find_all_readmes(dir: &Path) -> Vec<PathBuf> {
+ let mut results = Vec::new();
+ if let Ok(read_dir) = fs::read_dir(dir) {
+ let mut entries: Vec<_> = read_dir.flatten().collect();
+ entries.sort_by_key(std::fs::DirEntry::path);
+ for entry in entries {
+ let path = entry.path();
+ if path.is_dir() {
+ results.extend(find_all_readmes(&path));
+ } else if path.file_name().is_some_and(|n| n == "README.md") {
+ results.push(path);
+ }
+ }
+ }
+ results
+}
+
+/// The content directory of a site: `pages/` if present, else the first
+/// subdirectory containing markdown files.
+fn find_content_dir(site_root: &Path) -> Option<PathBuf> {
+ let pages_dir = site_root.join("pages");
+ if pages_dir.is_dir() {
+ return Some(pages_dir);
+ }
+ if let Ok(read_dir) = fs::read_dir(site_root) {
+ let mut entries: Vec<_> = read_dir.flatten().collect();
+ entries.sort_by_key(std::fs::DirEntry::path);
+ for entry in entries {
+ let path = entry.path();
+ if path.is_dir() && has_markdown_files(&path) {
+ return Some(path);
+ }
+ }
+ }
+ None
+}
+
+fn has_markdown_files(dir: &Path) -> bool {
+ if let Ok(read_dir) = fs::read_dir(dir) {
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ if has_markdown_files(&path) {
+ return true;
+ }
+ } else if path.extension().is_some_and(|ext| ext == "md") {
+ return true;
+ }
+ }
+ }
+ false
+}
+
+#[derive(Clone)]
+struct SidebarEntry {
+ title: String,
+ link: String,
+}
+
+/// Builds the sidebar content from the markdown files under `pages_dir`.
+fn build_sidebar_content(base_dir: &Path, pages_dir: &Path, sidebar_head: &str) -> String {
+ let mut lines = String::from(sidebar_head);
+
+ let mut root_files: Vec<SidebarEntry> = Vec::new();
+ let mut sub_dirs: BTreeMap<String, Vec<SidebarEntry>> = BTreeMap::new();
+
+ if let Ok(read_dir) = fs::read_dir(pages_dir) {
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ let dir_name = entry.file_name().to_string_lossy().into_owned();
+ let entries = collect_markdown_files(&path, base_dir);
+ if !entries.is_empty() {
+ let display_name = get_directory_display_name(&path, &dir_name);
+ sub_dirs.insert(display_name, entries);
+ }
+ } else if path.extension().is_some_and(|ext| ext == "md") {
+ root_files.push(SidebarEntry {
+ title: extract_title(&path),
+ link: relative_link(&path, base_dir),
+ });
+ }
+ }
+ }
+
+ root_files.sort_by(|a, b| natural_cmp(&a.link, &b.link));
+ for f in &root_files {
+ let _ = writeln!(lines, "* [{}]({})", f.title, f.link);
+ }
+
+ for (dir_name, entries) in &sub_dirs {
+ let mut sorted_entries = entries.clone();
+ sorted_entries.sort_by(|a, b| natural_cmp(&a.link, &b.link));
+ let _ = writeln!(lines, "* {dir_name}");
+ for f in &sorted_entries {
+ let _ = writeln!(lines, " * [{}]({})", f.title, f.link);
+ }
+ }
+
+ lines
+}
+
+/// All `.md` files directly under `dir`, as sidebar entries.
+fn collect_markdown_files(dir: &Path, base_dir: &Path) -> Vec<SidebarEntry> {
+ let mut entries = Vec::new();
+ if let Ok(read_dir) = fs::read_dir(dir) {
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.extension().is_some_and(|ext| ext == "md") {
+ entries.push(SidebarEntry {
+ title: extract_title(&path),
+ link: relative_link(&path, base_dir),
+ });
+ }
+ }
+ }
+ entries
+}
+
+/// The link of a file relative to `base_dir`, without the `.md` suffix.
+fn relative_link(path: &Path, base_dir: &Path) -> String {
+ path.strip_prefix(base_dir)
+ .unwrap_or(path)
+ .to_string_lossy()
+ .replace('\\', "/")
+ .strip_suffix(".md")
+ .unwrap_or_default()
+ .to_string()
+}
+
+/// Extracts the title from the first line `<h1 align="center">TITLE</h1>`,
+/// falling back to the file stem.
+fn extract_title(path: &Path) -> String {
+ let content = fs::read_to_string(path).unwrap_or_default();
+ if let Some(first_line) = content.lines().next() {
+ let trimmed = first_line.trim();
+ if let Some(start) = trimmed.find('>') {
+ let after_start = &trimmed[start + 1..];
+ if let Some(end) = after_start.find('<') {
+ return after_start[..end].to_string();
+ }
+ }
+ }
+ path.file_stem().map_or_else(
+ || "Untitled".to_string(),
+ |s| s.to_string_lossy().into_owned(),
+ )
+}
+
+/// Reads a directory's `.name` file to override its sidebar display name.
+fn get_directory_display_name(dir_path: &Path, fallback: &str) -> String {
+ let name_file = dir_path.join(".name");
+ if name_file.is_file() {
+ fs::read_to_string(&name_file)
+ .ok()
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ .unwrap_or_else(|| fallback.to_string())
+ } else {
+ fallback.to_string()
+ }
+}
+
+/// Numeric-aware comparison: `1-x` sorts before `10-x`, unnumbered last.
+fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
+ extract_leading_number(a)
+ .cmp(&extract_leading_number(b))
+ .then_with(|| a.cmp(b))
+}
+
+/// The leading numeric prefix of a link's file stem, `usize::MAX` if absent.
+fn extract_leading_number(link: &str) -> usize {
+ if let Some(file_stem) = link.rsplit('/').next()
+ && let Some(num_end) = file_stem.find('-')
+ && let Ok(num) = file_stem[..num_end].parse::<usize>()
+ {
+ return num;
+ }
+ usize::MAX
+}
+
+/// Recursively collects all `.md` files under a directory.
+fn collect_md_files(dir: &Path, callback: &mut dyn FnMut(&Path)) {
+ if let Ok(entries) = fs::read_dir(dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_md_files(&path, callback);
+ } else if path.extension().is_some_and(|ext| ext == "md") {
+ callback(&path);
+ }
+ }
+ }
+}
+
+/// Files written by `docsify-refresh`.
+#[derive(Grouped)]
+pub struct ResultDocsifyRefresh {
+ pub written: Vec<String>,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorDocsifyRefresh(pub String);
+
+#[renderer(buffer)]
+pub fn render_docsify_refresh(r: ResultDocsifyRefresh) {
+ for item in r.written {
+ r_println!("{item}");
+ }
+}
+
+#[renderer]
+pub fn render_error_docsify_refresh(e: ErrorDocsifyRefresh, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![e.0]);
+ render_result
+}
diff --git a/mingling_ci/src/tools/example_refresh.rs b/mingling_ci/src/tools/example_refresh.rs
new file mode 100644
index 0000000..ca8443c
--- /dev/null
+++ b/mingling_ci/src/tools/example_refresh.rs
@@ -0,0 +1,279 @@
+//! Regenerates the example documentation module and the examples index.
+
+use std::collections::HashMap;
+use std::fs;
+use std::path::Path;
+
+use just_fmt::snake_case;
+use just_template::Template;
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{buffer, command, r_println, renderer},
+};
+use serde::Serialize;
+
+use crate::Next;
+use crate::res::{CargoError, MessagePrinter};
+
+const EXAMPLE_ROOT: &str = "./examples";
+const EXAMPLE_DOCS_OUTPUT: &str = "./mingling/src/example_docs.rs";
+const EXAMPLE_DOCS_TEMPLATE: &str = include_str!("../../../mingling/src/example_docs.rs.tmpl");
+const EXAMPLES_JSON_OUTPUT: &str = "./docs/example-pages/examples.json";
+
+#[command(node = "example-refresh")]
+pub fn example_refresh() -> Next {
+ match refresh_all() {
+ Ok(written) => ResultExampleRefresh { written }.to_chain(),
+ Err(e) => ErrorExampleRefresh(e).to_chain(),
+ }
+}
+
+fn refresh_all() -> Result<Vec<String>, String> {
+ let mut written = Vec::new();
+ written.extend(refresh_example_docs()?);
+ written.extend(sync_examples()?);
+ Ok(written)
+}
+
+/// Part 1: regenerate `mingling/src/example_docs.rs` from the examples'
+/// `src/main.rs` (header `//!` + code) and `Cargo.toml`.
+fn refresh_example_docs() -> Result<Vec<String>, String> {
+ let mut template = Template::from(EXAMPLE_DOCS_TEMPLATE);
+
+ let mut examples = Vec::new();
+ let entries =
+ fs::read_dir(EXAMPLE_ROOT).map_err(|e| format!("failed to read {EXAMPLE_ROOT}: {e}"))?;
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_dir() {
+ continue;
+ }
+ let name = entry.file_name().to_string_lossy().into_owned();
+ if !name.starts_with("example-") {
+ continue;
+ }
+ examples.push(ExampleContent::read(&name));
+ }
+ examples.sort_by(|a, b| a.name.cmp(&b.name));
+
+ let mut written = Vec::new();
+ for example in examples {
+ template
+ .add_impl("examples".to_string())
+ .push(HashMap::from([
+ ("example_header".to_string(), example.header),
+ ("example_import".to_string(), example.cargo_toml),
+ ("example_code".to_string(), example.code),
+ ("example_name".to_string(), snake_case!(&example.name)),
+ ]));
+ written.push(format!("example_docs: {}", example.name));
+ }
+
+ let template_str = template.to_string();
+ let template_str = template_str
+ .lines()
+ .map(str::trim_end)
+ .collect::<Vec<_>>()
+ .join("\n")
+ + "\n";
+ fs::write(EXAMPLE_DOCS_OUTPUT, template_str)
+ .map_err(|e| format!("failed to write {EXAMPLE_DOCS_OUTPUT}: {e}"))?;
+ written.push(format!("written: {EXAMPLE_DOCS_OUTPUT}"));
+ Ok(written)
+}
+
+struct ExampleContent {
+ name: String,
+ header: String,
+ code: String,
+ cargo_toml: String,
+}
+
+impl ExampleContent {
+ fn read(name: &str) -> Self {
+ let prefix = |s: &str| {
+ s.lines()
+ .map(|line| format!("/// {line}"))
+ .collect::<Vec<_>>()
+ .join("\n")
+ };
+
+ let (header, code) = read_header_and_code(name);
+ Self {
+ name: name.to_string(),
+ header: prefix(&header),
+ code: prefix(&code),
+ cargo_toml: prefix(&read_cargo_toml(name)),
+ }
+ }
+}
+
+/// Reads an example's `src/main.rs`, splitting `//!` doc header from code.
+fn read_header_and_code(name: &str) -> (String, String) {
+ let content = fs::read_to_string(Path::new(EXAMPLE_ROOT).join(name).join("src/main.rs"))
+ .unwrap_or_default();
+ let mut lines = content.lines();
+ let mut header = String::new();
+ let mut code = String::new();
+
+ for line in lines.by_ref() {
+ if line.trim_start().starts_with("//!") {
+ header.push_str(line.trim_start_matches("//!"));
+ header.push('\n');
+ } else {
+ code.push_str(line);
+ code.push('\n');
+ break;
+ }
+ }
+ for line in lines {
+ code.push_str(line);
+ code.push('\n');
+ }
+
+ (header.trim().to_string(), code.trim().to_string())
+}
+
+fn read_cargo_toml(name: &str) -> String {
+ fs::read_to_string(Path::new(EXAMPLE_ROOT).join(name).join("Cargo.toml")).unwrap_or_default()
+}
+
+/// Part 2: regenerate `docs/example-pages/examples.json` from each example's
+/// `page.toml`.
+fn sync_examples() -> Result<Vec<String>, String> {
+ fs::create_dir_all("docs/example-pages")
+ .map_err(|e| format!("failed to create docs/example-pages: {e}"))?;
+
+ let mut examples = Vec::new();
+ let entries =
+ fs::read_dir(EXAMPLE_ROOT).map_err(|e| format!("failed to read {EXAMPLE_ROOT}: {e}"))?;
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_dir() {
+ continue;
+ }
+ let dir_name = entry.file_name().to_string_lossy().into_owned();
+ let page_toml = path.join("page.toml");
+ if !page_toml.is_file() {
+ continue;
+ }
+ let Ok(content) = fs::read_to_string(&page_toml) else {
+ continue;
+ };
+ let Ok(table) = content.parse::<toml::Value>() else {
+ eprintln!("Warning: failed to parse {}", page_toml.display());
+ continue;
+ };
+ let Some(example) = table.get("example") else {
+ continue;
+ };
+
+ let get = |key: &str| {
+ example
+ .get(key)
+ .and_then(|v| v.as_str())
+ .unwrap_or_default()
+ };
+ let str_vec = |key: &str| {
+ example
+ .get(key)
+ .and_then(|v| v.as_array())
+ .map(|a| {
+ a.iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default()
+ };
+
+ let id = get("id");
+ examples.push(ExampleMeta {
+ id: if id.is_empty() {
+ dir_name.clone()
+ } else {
+ id.to_string()
+ },
+ name: {
+ let name = get("name");
+ if name.is_empty() {
+ dir_name.clone()
+ } else {
+ name.to_string()
+ }
+ },
+ icon: {
+ let icon = get("icon");
+ if icon.is_empty() {
+ "📦".to_string()
+ } else {
+ icon.to_string()
+ }
+ },
+ category: get("category").to_string(),
+ desc: get("desc").to_string(),
+ tags: str_vec("tags"),
+ files: {
+ let files = str_vec("files");
+ if files.is_empty() {
+ vec!["Cargo.toml".to_string(), "src/main.rs".to_string()]
+ } else {
+ files
+ }
+ },
+ });
+ }
+
+ // Basic first, then alphabetical.
+ examples.sort_by(
+ |a, b| match (a.id == "example-basic", b.id == "example-basic") {
+ (true, false) => std::cmp::Ordering::Less,
+ (false, true) => std::cmp::Ordering::Greater,
+ _ => a.id.cmp(&b.id),
+ },
+ );
+
+ let json = serde_json::to_string_pretty(&examples)
+ .map_err(|e| format!("failed to serialize examples: {e}"))?;
+ fs::write(EXAMPLES_JSON_OUTPUT, json)
+ .map_err(|e| format!("failed to write {EXAMPLES_JSON_OUTPUT}: {e}"))?;
+
+ Ok(vec![format!(
+ "synced: {} examples -> {EXAMPLES_JSON_OUTPUT}",
+ examples.len()
+ )])
+}
+
+/// One entry of `docs/example-pages/examples.json`.
+#[derive(Serialize)]
+struct ExampleMeta {
+ id: String,
+ name: String,
+ icon: String,
+ category: String,
+ desc: String,
+ tags: Vec<String>,
+ files: Vec<String>,
+}
+
+/// Files written by `example-refresh`.
+#[derive(Grouped)]
+pub struct ResultExampleRefresh {
+ pub written: Vec<String>,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorExampleRefresh(pub String);
+
+#[renderer(buffer)]
+pub fn render_example_refresh(r: ResultExampleRefresh) {
+ for item in r.written {
+ r_println!("{item}");
+ }
+}
+
+#[renderer]
+pub fn render_error_example_refresh(e: ErrorExampleRefresh, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![e.0]);
+ render_result
+}
diff --git a/mingling_ci/src/tools/features_refresh.rs b/mingling_ci/src/tools/features_refresh.rs
new file mode 100644
index 0000000..87aeead
--- /dev/null
+++ b/mingling_ci/src/tools/features_refresh.rs
@@ -0,0 +1,96 @@
+//! Regenerates `mingling/src/features.rs` from the `[features]` section of
+//! `mingling/Cargo.toml`.
+
+use std::collections::HashMap;
+use std::fs;
+
+use just_fmt::snake_case;
+use just_template::Template;
+use mingling::{
+ Grouped, RenderResult, Routable,
+ macros::{buffer, command, r_println, renderer},
+};
+
+use crate::Next;
+use crate::res::{CargoError, MessagePrinter};
+
+const CARGO_TOML_PATH: &str = "./mingling/Cargo.toml";
+const OUTPUT_PATH: &str = "./mingling/src/features.rs";
+const TEMPLATE_CONTENT: &str = include_str!("../../../mingling/src/features.rs.tmpl");
+
+#[command(node = "features-refresh")]
+pub fn features_refresh() -> Next {
+ match gen_feature_module() {
+ Ok(written) => ResultFeaturesRefresh { written }.to_chain(),
+ Err(e) => ErrorFeaturesRefresh(e).to_chain(),
+ }
+}
+
+fn gen_feature_module() -> Result<Vec<String>, String> {
+ let features = parse_features()?;
+
+ let mut template = Template::from(TEMPLATE_CONTENT);
+ let mut written = Vec::new();
+ for feat_name in &features {
+ let feat_const_name = snake_case!(feat_name).to_uppercase();
+ template
+ .add_impl("features".to_string())
+ .push(HashMap::from([
+ ("feat_name".to_string(), feat_name.clone()),
+ ("feat_const_name".to_string(), feat_const_name),
+ ]));
+ written.push(format!("feature: {feat_name}"));
+ }
+
+ let template_str = template.to_string();
+ let template_str = template_str
+ .lines()
+ .map(str::trim_end)
+ .collect::<Vec<_>>()
+ .join("\n")
+ + "\n";
+ fs::write(OUTPUT_PATH, template_str)
+ .map_err(|e| format!("failed to write {OUTPUT_PATH}: {e}"))?;
+ written.push(format!("written: {OUTPUT_PATH}"));
+ Ok(written)
+}
+
+/// All feature names from the `[features]` section, sorted.
+fn parse_features() -> Result<Vec<String>, String> {
+ let content = fs::read_to_string(CARGO_TOML_PATH)
+ .map_err(|e| format!("failed to read {CARGO_TOML_PATH}: {e}"))?;
+ let table: toml::Value = content
+ .parse()
+ .map_err(|e| format!("failed to parse {CARGO_TOML_PATH}: {e}"))?;
+ let features = table
+ .get("features")
+ .and_then(|v| v.as_table())
+ .ok_or_else(|| format!("no [features] section in {CARGO_TOML_PATH}"))?;
+
+ let mut names: Vec<String> = features.keys().cloned().collect();
+ names.sort();
+ Ok(names)
+}
+
+/// Feature names written by `features-refresh`.
+#[derive(Grouped)]
+pub struct ResultFeaturesRefresh {
+ pub written: Vec<String>,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorFeaturesRefresh(pub String);
+
+#[renderer(buffer)]
+pub fn render_features_refresh(r: ResultFeaturesRefresh) {
+ for item in r.written {
+ r_println!("{item}");
+ }
+}
+
+#[renderer]
+pub fn render_error_features_refresh(e: ErrorFeaturesRefresh, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![e.0]);
+ render_result
+}