diff options
| -rw-r--r-- | Cargo.toml | 3 | ||||
| -rw-r--r-- | README.md | 772 | ||||
| -rw-r--r-- | dev_tools/src/bin/ci.rs | 6 | ||||
| -rw-r--r-- | dev_tools/src/bin/test-readme.rs | 349 | ||||
| -rw-r--r-- | dev_tools/src/lib.rs | 41 |
5 files changed, 1136 insertions, 35 deletions
@@ -2,6 +2,9 @@ resolver = "2" members = ["mingling", "mingling_core", "mingling_macros", "mling"] exclude = [ + # README-Tests + "./temp/readme-test", + # Examples "examples/example-argument-parse", "examples/example-async-support", @@ -21,7 +21,6 @@ </a> </p> - > [!WARNING] > > **Note**: Mingling is still under active development, and its API may change. Feel free to try it out and give us feedback! @@ -38,22 +37,50 @@ ### Mingling's Core Capabilities 1. **Separation of Concerns, Clear Logic**: Mingling decouples logic by responsibility, helping you organize your CLI program more clearly. -See example: [Example](https://github.com/catilgrass/mingling/blob/main/examples/example-basic/src/main.rs) + See example: [Example](https://github.com/catilgrass/mingling/blob/main/examples/example-basic/src/main.rs) 2. **"All Logic is Functions"**: Execution logic, rendering logic, completion logic, help logic — everything is a function. Just attach the corresponding attribute macro to bind them to your program. 3. **Fully Dynamic Completion System**: With the `comp` feature, you can flexibly implement dynamic completion logic for any subcommand. -See examples: [Example](https://github.com/catilgrass/mingling/blob/main/examples/example-completion/src/main.rs) + See examples: [Example](https://github.com/catilgrass/mingling/blob/main/examples/example-completion/src/main.rs) 4. **Lightning-Fast Subcommand Dispatch**: With the `dispatch_tree` feature, Mingling hardens the subcommand structure into a prefix tree at **compile time**, enabling blazing-fast subcommand lookup. -See examples: [Example](https://github.com/catilgrass/mingling/blob/main/examples/example-dispatch-tree/src/main.rs) + See examples: [Example](https://github.com/catilgrass/mingling/blob/main/examples/example-dispatch-tree/src/main.rs) 5. **Lightweight Dependencies, On-Demand Importing**: Minimal core dependencies keep builds fast; enhanced features are imported on demand through fine-grained feature flags. 6. **Structured Output**: Enabling the `general_renderer` feature adds support for flags like `--json` and `--yaml`, providing structured output capabilities. -See examples: [Example](https://github.com/catilgrass/mingling/blob/main/examples/example-general-renderer/src/main.rs) + See examples: [Example](https://github.com/catilgrass/mingling/blob/main/examples/example-general-renderer/src/main.rs) + +<h1 align="center"> + ✍️ Writing with Mingling ✍️ +</h1> + +### The Big Picture + +Mingling organizes your CLI program into three distinct phases: + +``` +User Input → [Dispatcher] → Entry → [Chain(s)] → Result → [Renderer] → Output +``` + +The user's raw arguments flow in. A **Dispatcher** picks them up, wraps them into an **Entry** type, and hands it off to a **Chain** function. The chain processes the entry and produces a **Result**. A **Renderer** takes that result and writes it to the terminal. + +Everything in this pipeline is a plain Rust function with an attribute macro on top. You never need to manually implement traits or construct boilerplate. + +--- +### 1. Defining Commands — `dispatcher!` -### What does Mingling look like? +The entry point for every subcommand is the `dispatcher!` macro. It generates two structs for you: a **Dispatcher** (used to register the command with the program) and an **Entry** (a wrapper around `Vec<String>` that holds the raw arguments). -Here is a basic project written using **Mingling**: -- When the user types `greet`, the program outputs `Hello, World!` -- When the user types `greet Alice`, the program outputs `Hello, Alice!` +```rust +use mingling::prelude::*; + +// "command.name" dispatcher entry type +// │ │ │ +dispatcher!("greet", CMDGreet => EntryGreet); + +// Nested subcommand: `remote add` +dispatcher!("remote.add", CMDRemoteAdd => EntryRemoteAdd); +``` + +Then in `main()`, register the dispatcher with the program: ```rust use mingling::prelude::*; @@ -65,58 +92,733 @@ fn main() { program.with_dispatcher(CMDGreet); program.exec_and_exit(); } +``` + +Mingling also supports an abbreviated form (with the `extra_macros` feature): + +```rust +// Features: ["extra_macros"] + +use mingling::prelude::*; + +// Auto-generates CMDGreet / EntryGreet from "greet" +dispatcher!("greet"); +``` + +--- + +### 2. The Chain — "#[chain]" — Where Logic Lives + +The `#[chain]` attribute turns a plain function into an execution step. Think of it as "the logic that transforms one typed value into another." + +```rust +use mingling::prelude::*; + +dispatcher!("greet", CMDGreet => EntryGreet); pack!(ResultGreeting = String); #[chain] fn handle_greet(args: EntryGreet) -> Next { - let greeting = args.pick_or::<String>((), "World").unpack(); + let greeting = args + .inner + .first() + .cloned() + .unwrap_or_else(|| "World".to_string()); ResultGreeting::new(greeting) } +``` + +Key points: + +- The return type is `Next` — a type alias for `ChainProcess<ThisProgram>`. +- You chain results by calling `.to_chain()` on any `pack!`-ed type. +- You can have **multiple chain functions** for the same command, each transforming the data further. +- With the `async` feature, chain functions can be `async fn`. + +--- + +### 3. The Renderer — "#[renderer]" — How Output Works + +The `#[renderer]` attribute turns a function into an output handler. It receives the final result of a chain and writes it to the terminal. + +```rust +use mingling::prelude::*; + +pack!(ResultGreeting = String); #[renderer] fn render_greeting(greeting: ResultGreeting) { r_println!("Hello, {}!", *greeting); } +``` + +Inside a renderer, use `r_print!` / `r_println!` to write to the output buffer. This is not `println!` — it writes into Mingling's internal `RenderResult` buffer, which is flushed at the end of the pipeline. + +You can write renderers for **any type** in your program, including error types: + +```rust +use mingling::prelude::*; + +#[renderer] +fn render_dispatcher_not_found(err: ErrorDispatcherNotFound) { + r_println!("Command not found: [{}]", err.join(" ")); +} +``` + +--- + +### 4. Parsing Arguments — The Picker + +Mingling provides a **Picker** for zero-cost argument extraction. You use `pick()` or `pick_or()` on an entry to extract typed values, then `unpack()` to get the final tuple. + +```rust +// Features: ["parser"] + +use mingling::prelude::*; +use mingling::parser::Picker; + +dispatcher!("greet", CMDGreet => EntryGreet); +pack!(ResultGreeting = String); + +#[chain] +fn handle_greet(args: EntryGreet) -> Next { + let (name, count) = Picker::new(args.inner) + .pick::<String>(()) // positional: first string + .pick_or::<u8>(["-r", "--repeat"], 1) // optional flag with default + .unpack(); + ResultGreeting::new(format!("{} x{}", name, count)) +} +``` + +With the `parser` feature, the `AsPicker` trait provides a shorthand directly on entries: + +```rust +// Features: ["parser"] + +use mingling::prelude::*; + +dispatcher!("greet", CMDGreet => EntryGreet); +pack!(ResultGreeting = String); + +#[chain] +fn handle(args: EntryGreet) -> Next { + let (name, count) = args + .pick::<Option<String>>(()) + .pick_or::<u8>(["-r", "--repeat"], 1) + .unpack(); + ResultGreeting::new(format!("{} x{}", name.unwrap_or_default(), count)) +} +``` + +For enums, derive `EnumTag` and implement `PickableEnum` to parse enum variants from strings: + +```rust +// Features: ["parser", "extra_macros"] + +use mingling::prelude::*; +use mingling::{EnumTag, Groupped}; +use mingling::parser::PickableEnum; + +dispatcher!("lang.select", CMDLang => EntryLang); + +#[derive(Debug, Default, EnumTag, Groupped)] +pub enum Language { + #[default] + Rust, + #[enum_rename("C++")] + CPlusPlus, +} + +impl PickableEnum for Language {} + +#[chain] +fn handle(args: EntryLang) -> Next { + let lang: Language = args.pick(()).unpack(); + lang +} +``` + +--- + +### 5. The Help System — "#[help]" + +Help is just another attribute macro. When the user passes `--help` or `-h`, the program skips the normal chain/render pipeline and routes directly to your `#[help]` function. + +Enable it by adding `BasicProgramSetup`: + +```rust +use mingling::{macros::help, prelude::*, setup::BasicProgramSetup}; + +dispatcher!("greet", CMDGreet => EntryGreet); + +#[help] +fn help_greet(_prev: EntryGreet) { + r_println!("Usage: greet <NAME>"); + r_println!("Greets the user with the given name."); +} + +fn main() { + let mut program = ThisProgram::new(); + program.with_setup(BasicProgramSetup); // enables --help / -h + program.with_dispatcher(CMDGreet); + program.exec_and_exit(); +} gen_program!(); ``` -<h1 align="center"> - 🚀 How to get started? 🚀 -</h1> +The flow is: -There are multiple ways to import **Mingling**: +- User types `greet --help` +- `BasicProgramSetup` sets `program.user_context.help = true` +- The dispatcher sees this flag and routes to the `#[help]` function instead of the `#[chain]` -1. From [crates.io](https://crates.io/crates/mingling): -```toml -[dependencies.mingling] -version = "0.1.9" -features = [] +--- + +### 6. Completion — "#[completion]" — Dynamic Shell Completions + +With the `comp` feature, Mingling provides a fully dynamic completion system. You write a function that returns `Suggest` based on the current shell context, and Mingling generates the completion scripts for bash, zsh, fish, and pwsh. + +```rust +// Features: ["comp", "extra_macros"] + +use mingling::{macros::suggest, prelude::*, ShellContext, Suggest}; + +dispatcher!("greet", CMDGreet => EntryGreet); +pack!(ResultName = (u8, String)); + +#[completion(EntryGreet)] +fn complete_greet(ctx: &ShellContext) -> Suggest { + // Suggest positional arguments + if ctx.previous_word == "greet" { + return suggest! { + "Alice": "Likes to receive messages", + "Bob": "Likes to pass messages", + "World" + }; + } + + // Suggest flag arguments + if ctx.typing_argument() { + return suggest! { + "-r": "Number of repetitions", + "--repeat": "Number of repetitions", + } + .strip_typed_argument(ctx); + } + + suggest!() // no suggestions +} ``` -2. From [GitHub](https://github.com/catilgrass/mingling): -```toml -[dependencies.mingling] -git = "https://github.com/catilgrass/mingling" -branch = "main" -features = [] +You also need to register the built-in completion dispatcher: + +```rust +// Features: ["comp"] + +fn main() { + let mut program = ThisProgram::new(); + program.with_dispatcher(crate::CMDCompletion); + program.exec_and_exit(); +} +``` + +In your `build.rs`, generate the shell scripts: + +```rust +// Features: ["comp", "builds"] + +fn main() { + mingling::build::build_comp_scripts(env!("CARGO_PKG_NAME")).unwrap(); +} +``` + +For enum-based completions, use `suggest_enum!`: + +```rust +// Features: ["comp", "extra_macros"] + +use mingling::prelude::*; +use mingling::{ShellContext, Suggest}; +use mingling::macros::suggest_enum; +use mingling::EnumTag; + +dispatcher!("lang.select", CMDLang => EntryLang); + +#[derive(EnumTag)] +pub enum ProgrammingLanguages { + Rust, + Python, + JavaScript, +} + +#[completion(EntryLang)] +fn complete_lang(_: &ShellContext) -> Suggest { + suggest_enum!(ProgrammingLanguages) +} +``` + +--- + +### 7. Error Handling + +Mingling doesn't use `?` operator propagation. Instead, errors are just **alternative results** that flow through the same chain/render pipeline. Create error types with `pack!` and route to them with `.to_render()`: + +```rust +use mingling::prelude::*; + +dispatcher!("hello", CMDHello => EntryHello); +pack!(ResultName = String); +pack!(ErrorNoNameProvided = ()); +pack!(ErrorNameTooLong = u16); + +#[chain] +fn handle(args: EntryHello) -> Next { + let Some(name) = args.inner.first().cloned() else { + return ErrorNoNameProvided::default().to_render(); // ← early return to error renderer + }; + + if name.len() > 10 { + return ErrorNameTooLong::new(name.len() as u16).to_render(); + } + + ResultName::new(name).to_render() // ← success path +} + +#[renderer] +fn render_no_name(_: ErrorNoNameProvided) { + r_println!("No name provided"); +} + +#[renderer] +fn render_too_long(len: ErrorNameTooLong) { + r_println!("Name too long: {} > 10", *len); +} +``` + +Two built-in fallback types are always available: + +- `ErrorDispatcherNotFound` — rendered when no dispatcher matches the input +- `ErrorRendererNotFound` — rendered when no renderer is found for a result type + +--- + +### 8. Resource Injection + +Chain and renderer functions can accept **additional parameters** for the program's global state. Resources are singleton values registered with `program.with_resource(...)`. + +```rust +// Features: ["parser", "extra_macros"] + +use std::path::PathBuf; +use mingling::prelude::*; + +dispatcher!("current", CMDCurrent => EntryCurrent); +dispatcher!("cd", CMDCd => EntryCd); + +#[derive(Default, Clone)] +struct ResCurrentDir { + current_dir: PathBuf, +} + +fn main() { + let mut program = ThisProgram::new(); + program.with_resource(ResCurrentDir { + current_dir: std::env::current_dir().unwrap(), + }); + program.with_dispatcher(CMDCurrent); + program.with_dispatcher(CMDCd); + program.exec_and_exit(); +} + +// Read-only access (shared reference): +#[chain] +fn show_current(_prev: EntryCurrent, current_dir: &ResCurrentDir) -> Next { + println!("Current: {}", current_dir.current_dir.display()); + empty_result!() +} + +// Mutable access: +#[chain] +fn change_dir(prev: EntryCd, current_dir: &mut ResCurrentDir) -> Next { + let path: String = prev.pick(()).unpack(); + current_dir.current_dir = current_dir.current_dir.join(path); + empty_result!() +} ``` -3. Alternatively, you can quickly scaffold a new project from the [Mingling-Template](https://github.com/catilgrass/mingling-template) using: +Resources can also be injected into `#[renderer]`: + +```rust + +use mingling::prelude::*; + +dispatcher!("current", CMDCurrent => EntryCurrent); + +#[derive(Default, Clone)] +struct ResCurrentDir { + current_dir: std::path::PathBuf, +} + +#[renderer] +fn render_current(_: EntryCurrent, current_dir: &ResCurrentDir) { + r_println!("Current directory: {}", current_dir.current_dir.display()); +} +``` + +--- + +### 9. Dispatch Tree — Compile-Time Command Trie + +As your program grows to dozens or hundreds of subcommands, linear dispatcher lookup becomes slow. Enable the `dispatch_tree` feature to convert the command structure into a **prefix tree (Trie)** at compile time. + +```rust +// Features: ["dispatch_tree"] + +use mingling::prelude::*; + +dispatcher!("cmd1", CMD1 => Entry1); +dispatcher!("cmd2.sub1", CMD2Sub1 => Entry2Sub1); +dispatcher!("cmd2.sub2", CMD2Sub2 => Entry2Sub2); +dispatcher!("cmd3.sub1.leaf1", CMD3Sub1Leaf1 => Entry3Sub1Leaf1); +dispatcher!("cmd3.sub1.leaf2", CMD3Sub1Leaf2 => Entry3Sub1Leaf2); +// ... dozens more + +fn main() { + let program = ThisProgram::new(); + // No more with_dispatcher calls — it's all compile-time! + program.exec_and_exit(); +} +``` + +With `dispatch_tree` enabled: + +- Dispatchers are auto-collected at compile time +- `Program` no longer stores a dispatcher list +- `program.with_dispatcher(...)` is not compiled +- Lookup is **O(n)** where _n_ is input length, not number of commands + +--- + +### 10. Clap Binding — Using Clap's Parser + +If you prefer clap's powerful argument parsing, use `#[dispatcher_clap]`. It generates a dispatcher from a `clap::Parser` struct. + +```rust +// Features: ["clap"] +// Dependencies: +// clap = "4" + +use mingling::macros::dispatcher_clap; +use mingling::prelude::*; +use mingling::Groupped; + +#[derive(Default, clap::Parser, Groupped)] +#[dispatcher_clap( + "greet", CMDGreet, + help = true, // auto-generate #[help] from clap + error = ErrorGreetParsed, // capture parse errors as a renderable type +)] +pub struct EntryGreet { + #[clap(default_value = "World")] + name: String, + + #[arg(short, long, default_value_t = 1)] + repeat: i32, +} + +#[renderer] +fn render_greet(greet: EntryGreet) { + r_print!("Hello, "); + for _ in 0..greet.repeat { r_print!("{}", greet.name); } + r_println!("!"); +} + +#[renderer] +fn render_parse_error(err: ErrorGreetParsed) { + r_println!("{}", *err); +} +``` + +You can control how clap help is displayed: + +```rust +// Features: ["clap"] + +use mingling::prelude::*; + +dispatcher!("greet", CMDGreet => EntryGreet); + +fn main() { + let mut program = ThisProgram::new(); + program.with_dispatcher(CMDGreet); + program.stdout_setting.clap_help_print_behaviour = + mingling::ClapHelpPrintBehaviour::WriteToRenderResult; + // or: PrintDirectly — writes clap help straight to stdout + program.exec_and_exit(); +} +``` + +--- + +### 11. REPL Mode + +With the `repl` feature, turn your CLI into an interactive shell with one method call: + +```rust +// Features: ["repl"] + +fn main() { + ThisProgram::new().exec_repl(); +} +``` + +Mingling provides built-in REPL setups: + +```rust +// Features: ["repl", "extra_macros"] + +use mingling::{ + prelude::*, + res::ResREPL, + setup::{BasicREPLReadlineSetup, BasicREPLOutputSetup, BasicREPLPromptSetup}, +}; + +dispatcher!("cd", CMDCd => EntryCd); +dispatcher!("exit", CMDExit => EntryExit); + +fn main() { + let mut program = ThisProgram::new(); + + program.with_dispatcher(CMDCd); + program.with_dispatcher(CMDExit); + + // Enable line reading from stdin + program.with_setup(BasicREPLReadlineSetup); + + // Enable output flushing after each render + program.with_setup(BasicREPLOutputSetup); + + // Custom prompt + program.with_setup(BasicREPLPromptSetup::func(|| "> ".to_string())); + + program.exec_repl(); // ← interactive loop +} + +// Exit the REPL via the ResREPL resource: +#[chain] +fn handle_exit(_prev: EntryExit, repl: &mut ResREPL) { + repl.exit = true; +} +``` + +--- + +### 12. Hooks — Observing the Pipeline + +Mingling provides a `ProgramHook` system for observing every stage of the execution pipeline. Useful for debugging, logging, or telemetry. + +```rust +use mingling::prelude::*; +use mingling::hook::ProgramHook; + +dispatcher!("greet", CMDGreet => EntryGreet); + +fn main() { + let mut program = ThisProgram::new(); + program.with_dispatcher(CMDGreet); + program.with_hook( + ProgramHook::<ThisProgram>::empty() + .on_begin(|| println!("[DEBUG] Program started")) + .on_pre_dispatch(|args| println!("[DEBUG] Dispatching: {args:?}")) + .on_post_dispatch(|entry| println!("[DEBUG] Dispatched: {entry:?}")) + .on_pre_chain(|entry, _| println!("[DEBUG] Pre chain: {entry}")) + .on_post_chain(|output| println!("[DEBUG] Post chain: {}", output.member_id)) + .on_pre_render(|ty, _| println!("[DEBUG] Pre render: {ty}")) + .on_post_render(|_| println!("[DEBUG] Post render")) + .on_finish(|| { + println!("[DEBUG] Program end"); + 0 // override exit code + }), + ); + program.exec_and_exit(); +} +``` + +--- + +### 13. General Renderer — Structured Output (JSON/YAML) + +With the `general_renderer` feature, users can add `--json` or `--yaml` flags to get structured output instead of human-readable text. + +```rust +// Features: ["general_renderer", "parser"] +// Dependencies: +// serde = "1" + +use mingling::{prelude::*, setup::GeneralRendererSetup}; +use mingling::Groupped; +use serde::Serialize; + +dispatcher!("render", CMDRender => EntryRender); + +#[derive(Default, Serialize, Groupped)] +struct ResultInfo { + name: String, + age: i32, +} + +#[chain] +fn render_info(args: EntryRender) -> Next { + let (name, age) = args.pick::<String>(()).pick::<i32>(()).unpack(); + ResultInfo { name, age }.to_chain() +} + +#[renderer] +fn render_info_result(info: ResultInfo) { + r_println!("{} is {} years old", info.name, info.age); +} + +fn main() { + let mut program = ThisProgram::new(); + program.with_setup(GeneralRendererSetup); // enables --json / --yaml + program.with_dispatcher(CMDRender); + let _ = program.exec(); +} +``` + +Then users can do: + ```bash -cargo generate --git catilgrass/mingling-template +$ myapp render Bob 22 +Bob is 22 years old + +$ myapp render Bob 22 --json +{"name":"Bob","age":22} + +$ myapp render Bob 22 --yaml +name: Bob +age: 22 +``` + +--- + +### 14. Async Support + +Enable the `async` feature to use `async fn` inside `#[chain]`: + +```rust +// Features: ["async", "parser"] +// Dependencies: +// tokio = { version = "1", features = ["full"] } + +use mingling::prelude::*; +use std::time::Duration; + +dispatcher!("download", CMDDownload => EntryDownload); +pack!(ResultDownloaded = String); + +#[chain] +pub async fn handle_download(args: EntryDownload) -> Next { + let file = args.pick(()).unpack(); + download_file(file).await +} + +async fn download_file(name: String) -> ResultDownloaded { + tokio::time::sleep(Duration::from_secs(1)).await; + ResultDownloaded::new(name) +} + +#[renderer] +fn render_downloaded(result: ResultDownloaded) { + r_println!("\"{}\" downloaded.", *result); +} +``` + +Note: `#[renderer]` functions cannot be async. When `async` is enabled, `program.exec_and_exit().await` returns a Future. + +--- + +### 15. Wrapping Up — `gen_program!()` + +At the very end of your crate root (main.rs / lib.rs), call `gen_program!()` to generate the `ThisProgram` struct, the `Next` type alias, and all internal plumbing. + +```rust +use mingling::macros::gen_program; + +gen_program!(); +``` + +It must be placed **after** all your `dispatcher!`, `pack!`, `#[chain]`, `#[renderer]`, and `#[help]` declarations. + +--- + +### Putting It All Together + +Here's a complete, runnable program: + +```rust +use mingling::prelude::*; + +dispatcher!("greet", CMDGreet => EntryGreet); + +fn main() { + let mut program = ThisProgram::new(); + program.with_dispatcher(CMDGreet); + program.exec_and_exit(); +} + +pack!(ResultGreeting = String); + +#[chain] +fn handle_greet(args: EntryGreet) -> Next { + let greeting = args + .inner + .first() + .cloned() + .unwrap_or_else(|| "World".to_string()); + ResultGreeting::new(greeting) +} + +#[renderer] +fn render_greeting(greeting: ResultGreeting) { + r_println!("Hello, {}!", *greeting); +} + +gen_program!(); +``` + +```bash +$ myapp greet +Hello, World! + +$ myapp greet Alice +Hello, Alice! ``` <h1 align="center"> - 💡 How to learn? 💡 + 🚀 Getting Started 🚀 </h1> -You can read the following docs to learn more about the `Mingling` framework: +Add Mingling to your `Cargo.toml`: + +```toml +[dependencies] +mingling = "0.2.0" +``` + +Or use the [template project](https://github.com/catilgrass/mingling-template): + +```bash +cargo generate --git catilgrass/mingling-template +``` + +Then check out: -- 💡 Check out **[Mingling Helpdoc](https://catilgrass.github.io/mingling/)** to learn the basics. -- 💡 Check out **[Examples](https://docs.rs/mingling/latest/mingling/_mingling_examples/index.html)** to learn about the core library. -- 💡 Check out **[docs.rs](https://docs.rs/mingling/latest/mingling/)** to learn how to use the macro system and explore the full API. +- 📖 [Mingling Helpdoc](https://catilgrass.github.io/mingling/) +- 📖 [Examples on docs.rs](https://docs.rs/mingling/latest/mingling/_mingling_examples/index.html) +- 📖 [Full API docs](https://docs.rs/mingling/latest/mingling/) <h1 align="center"> 🗺️ Roadmap 🗺️ @@ -130,8 +832,8 @@ You can read the following docs to learn more about the `Mingling` framework: - [x] \[[0.1.7](https://docs.rs/mingling/0.1.7/mingling/)\] \[`core`\] **Mingling** can intercept `-h` or `--help` flags to display custom help text for each subcommand - [x] \[[0.1.7](https://docs.rs/mingling/0.1.7/mingling/)\] \[`mling`\] Provides a basic scaffolding tool (`mling`) for rapid development and debugging - [x] \[[0.1.8](https://docs.rs/mingling/0.1.8/mingling/)\] \[`core`\] \[`dispatch_tree`\] Converts the subcommand list into a prefix tree to improve command matching speed - - [X] \[[0.1.9](https://docs.rs/mingling/0.1.9/mingling/)\] \[`core`\] \[`dev_toolkits`\] Provides debugging interfaces for developers to capture invocation information when issues arise (`InvokeStackDisplay`) (indirectly implemented via `ProgramHook`) - - [X] \[[0.1.9](https://docs.rs/mingling/0.1.9/mingling/)\] \[`core`\] \[`repl`\] Provides REPL capability (`program.exec_repl();`) + - [x] \[[0.1.9](https://docs.rs/mingling/0.1.9/mingling/)\] \[`core`\] \[`dev_toolkits`\] Provides debugging interfaces for developers to capture invocation information when issues arise (`InvokeStackDisplay`) (indirectly implemented via `ProgramHook`) + - [x] \[[0.1.9](https://docs.rs/mingling/0.1.9/mingling/)\] \[`core`\] \[`repl`\] Provides REPL capability (`program.exec_repl();`) - [ ] \[**0.2.0**\] Complete documentation, tests, and examples - [ ] Milestone.2 "More Comfortable Dev and User Experience" @@ -158,6 +860,6 @@ This is because the Rust ecosystem already has excellent and mature crates to ha 📄 Open Source License 📄 </h1> -This project is licensed under the MIT License. +This project is licensed under the MIT License. See [LICENSE-MIT](LICENSE-MIT) or [LICENSE-APACHE](LICENSE-APACHE) file for details. diff --git a/dev_tools/src/bin/ci.rs b/dev_tools/src/bin/ci.rs index b7934e1..21763d1 100644 --- a/dev_tools/src/bin/ci.rs +++ b/dev_tools/src/bin/ci.rs @@ -73,6 +73,7 @@ fn ci() -> Result<(), i32> { clippy_all()?; test_all()?; test_examples()?; + test_readme()?; docs_refresh()?; run_cmd!("git add --renormalize .")?; @@ -85,6 +86,11 @@ fn test_examples() -> Result<(), i32> { run_cmd!("cargo run --manifest-path dev_tools/Cargo.toml --bin test-examples") } +fn test_readme() -> Result<(), i32> { + println_cargo_style!("Testing: readme code blocks"); + run_cmd!("cargo run --manifest-path dev_tools/Cargo.toml --bin test-readme") +} + fn build_all() -> Result<(), i32> { let cargo_tomls = cargo_tomls(); for cargo_toml in cargo_tomls { diff --git a/dev_tools/src/bin/test-readme.rs b/dev_tools/src/bin/test-readme.rs new file mode 100644 index 0000000..fd1962f --- /dev/null +++ b/dev_tools/src/bin/test-readme.rs @@ -0,0 +1,349 @@ +use colored::Colorize; +use std::path::{Path, PathBuf}; + +use tools::{eprintln_cargo_style, println_cargo_style, run_cmd_and_capture_stderr}; + +/// Represents a parsed code block from README.md +struct CodeBlock { + /// The line number in README.md where this block starts + line: usize, + /// The raw Rust source code + code: String, + /// Feature flags extracted from `// Features: [...]` comment + features: Vec<String>, + /// External dependencies extracted from `// Dependencies:` comments + external_deps: Vec<(String, String)>, + /// Whether this block has a `fn main` entry point + has_main: bool, + /// Whether this block has `gen_program!()` call + has_gen_program: bool, +} + +fn main() { + #[cfg(windows)] + let _ = colored::control::set_virtual_terminal(true); + + let readme_path = PathBuf::from("README.md"); + if !readme_path.exists() { + eprintln_cargo_style!("README.md not found in current directory"); + std::process::exit(1); + } + + let content = std::fs::read_to_string(&readme_path).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read README.md: {}", e); + std::process::exit(1); + }); + + let blocks = parse_code_blocks(&content); + if blocks.is_empty() { + eprintln_cargo_style!("No Rust code blocks found in README.md"); + std::process::exit(1); + } + + println_cargo_style!("Test: found {} Rust code blocks in README.md", blocks.len()); + + // Ensure temp directory exists + let temp_dir = PathBuf::from(".temp/readme-test"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("src")).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to create temp directory: {}", e); + std::process::exit(1); + }); + + let mut passed = 0usize; + let mut failed = 0usize; + let mut results: Vec<(usize, bool, String)> = Vec::new(); + + for (i, block) in blocks.iter().enumerate() { + let label = format!("Block {} (line {})", i + 1, block.line); + print!(" {label} ... "); + + let (ok, err) = build_block(&temp_dir, block); + if ok { + println!("{}", "passed".bold().bright_green()); + passed += 1; + results.push((block.line, true, String::new())); + } else { + println!("{}", "failed".bold().bright_red()); + failed += 1; + results.push((block.line, false, err.clone())); + eprintln_cargo_style!(" {} FAILED:\n{}", label, err); + } + } + + println_cargo_style!( + "Result: {passed}/{total} blocks passed", + total = blocks.len() + ); + + write_summary_report( + Path::new(".temp/README-TEST-RESULT.md"), + &results, + blocks.len(), + passed, + failed, + ); + + if failed > 0 { + eprintln_cargo_style!("{failed} block(s) failed to build"); + std::process::exit(1); + } + + println_cargo_style!("Done: All README code blocks build successfully!"); +} + +/// Parse all ```rust code blocks from README content +fn parse_code_blocks(content: &str) -> Vec<CodeBlock> { + let mut blocks = 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(block) = parse_single_block(&lines, i) { + blocks.push(block); + } + i += 1; + while i < lines.len() && lines[i].trim() != "```" { + i += 1; + } + } + i += 1; + } + + blocks +} + +/// Parse a single code block starting at the ```rust line +fn parse_single_block(lines: &[&str], start: usize) -> Option<CodeBlock> { + let line_num = start + 1; // 1-based line number + + let mut code_lines: Vec<String> = Vec::new(); + let mut features: Vec<String> = Vec::new(); + let mut external_deps: Vec<(String, String)> = Vec::new(); + let mut has_main = false; + let mut has_gen_program = 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; + } + + // Parse header comments + if in_header && trimmed.starts_with("// ") { + if trimmed.starts_with("// Features:") { + let feat_str = trimmed.trim_start_matches("// Features:").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; + // Collect subsequent `// crate = "version"` lines + while idx < lines.len() { + let next = lines[idx].trim(); + if next == "```" { + break; + } + if next.starts_with("// ") { + let dep_line = next.trim_start_matches("// ").trim(); + if let Some((name, ver)) = dep_line.split_once(" = ") { + external_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() { + return None; + } + + Some(CodeBlock { + line: line_num, + code: code_lines.join("\n"), + features, + external_deps, + has_main, + has_gen_program, + }) +} + +/// Generate a Cargo.toml for a block +fn generate_cargo_toml(block: &CodeBlock) -> String { + let features_str = if !block.features.is_empty() { + let feats: Vec<String> = block.features.iter().map(|f| format!("\"{f}\"")).collect(); + format!("features = [{}]", feats.join(", ")) + } else { + String::new() + }; + + let mut extra_deps = String::new(); + for (name, version) in &block.external_deps { + if !version.starts_with('{') { + // Plain version string, e.g. "1" + if name == "serde" || name == "clap" { + extra_deps.push_str(&format!( + "{name} = {{ version = \"{version}\", features = [\"derive\"] }}\n" + )); + } else { + extra_deps.push_str(&format!("{name} = \"{version}\"\n")); + } + } else { + // Already in TOML inline table format, e.g. { version = "1", features = [...] } + extra_deps.push_str(&format!("{name} = {version}\n")); + } + } + + let deps_section = if features_str.is_empty() { + format!("[dependencies]\nmingling = {{ path = \"../../mingling\" }}\n{extra_deps}") + } else { + format!( + "[dependencies]\nmingling = {{ path = \"../../mingling\", {features_str} }}\n{extra_deps}" + ) + }; + + format!( + r#"[package] +name = "readme-test-block" +version = "0.0.0" +edition = "2024" + +{deps_section} +[workspace] +"# + ) +} + +/// Generate main.rs for a block +fn generate_main_rs(block: &CodeBlock) -> String { + let mut output = String::from("#![allow(dead_code)]\n\n"); + + output.push_str(&block.code); + output.push('\n'); + + if !block.has_main { + output.push_str("\nfn main() {}\n"); + } + + if !block.has_gen_program { + output.push_str("\nmingling::macros::gen_program!();\n"); + } + + output +} + +/// Build a single code block as a Cargo project. +/// Always writes to `{temp_dir}/Cargo.toml` and `{temp_dir}/src/main.rs`. +/// Returns (success, error_message). +fn build_block(temp_dir: &Path, block: &CodeBlock) -> (bool, String) { + let src_dir = temp_dir.join("src"); + if let Err(e) = std::fs::create_dir_all(&src_dir) { + return (false, format!("mkdir: {e}")); + } + + // Write Cargo.toml + let cargo_toml = generate_cargo_toml(block); + if let Err(e) = std::fs::write(temp_dir.join("Cargo.toml"), &cargo_toml) { + return (false, format!("write Cargo.toml: {e}")); + } + + // Write main.rs + let main_rs = generate_main_rs(block); + if let Err(e) = std::fs::write(src_dir.join("main.rs"), &main_rs) { + return (false, format!("write main.rs: {e}")); + } + + // Build with release (single run via shared macro) + let manifest_path = temp_dir.join("Cargo.toml"); + match run_cmd_and_capture_stderr!( + "cargo build --release --manifest-path {}", + manifest_path.to_string_lossy() + ) { + Ok(_) => (true, String::new()), + Err((code, log)) => { + let mut last_lines: Vec<&str> = log.lines().rev().take(20).collect(); + last_lines.reverse(); + let detail = last_lines.join("\n"); + (false, format!("exit code {code}\n{detail}")) + } + } +} + +/// Write the .temp/README-TEST-RESULT.md summary report +fn write_summary_report( + path: &Path, + results: &[(usize, bool, String)], + total: usize, + passed: usize, + failed: usize, +) { + let mut content = String::new(); + content.push_str("# README Code Block Test Report\n\n"); + content.push_str(&format!( + "Tested **{total}** code blocks: **{passed}** passed, **{failed}** failed.\n\n" + )); + content.push_str("## Results\n\n"); + content.push_str("| Block | Line | Status |\n"); + content.push_str("|-------|------|--------|\n"); + + for (i, (line, ok, _)) in results.iter().enumerate() { + let status = if *ok { "PASS" } else { "FAIL" }; + content.push_str(&format!("| {} | {} | {status} |\n", i + 1, line)); + } + + let has_failures = results.iter().any(|(_, ok, _)| !ok); + if has_failures { + content.push_str("\n## Failed Blocks\n\n"); + for (i, (line, ok, err)) in results.iter().enumerate() { + if !ok { + content.push_str(&format!( + "### Block {} (line {})\n\n```\n{err}\n```\n\n", + i + 1, + line + )); + } + } + } + + std::fs::write(path, &content).unwrap_or_else(|e| { + eprintln!("Warning: failed to write {path:?}: {e}"); + }); + + println_cargo_style!("Report: written to {}", path.display()); +} diff --git a/dev_tools/src/lib.rs b/dev_tools/src/lib.rs index 59eed0a..9eb1f75 100644 --- a/dev_tools/src/lib.rs +++ b/dev_tools/src/lib.rs @@ -10,6 +10,18 @@ macro_rules! run_cmd { }; } +/// Run a shell command and capture its combined stdout+stderr output. +/// Returns `Ok(output)` on success, `Err((exit_code, stderr))` on failure. +#[macro_export] +macro_rules! run_cmd_and_capture_stderr { + ($fmt:literal, $($arg:tt)*) => { + $crate::run_cmd_capture(format!($fmt, $($arg)*)) + }; + ($cmd:expr) => { + $crate::run_cmd_capture($cmd) + }; +} + #[macro_export] macro_rules! println_cargo_style { ($fmt:literal, $($arg:tt)*) => { @@ -97,6 +109,35 @@ pub fn run_cmd(cmd: impl Into<String>) -> Result<(), i32> { } } +/// Run a shell command and capture its combined stdout+stderr output. +/// +/// On success returns `Ok(combined_output)`. On failure returns `Err((exit_code, stderr))`. +/// Stderr falls back to stdout if stderr is empty. +pub fn run_cmd_capture(cmd: impl Into<String>) -> Result<String, (i32, String)> { + let shell = if cfg!(target_os = "windows") { + "powershell" + } else { + "sh" + }; + let output = std::process::Command::new(shell) + .arg("-c") + .arg(cmd.into()) + .current_dir(std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))) + .output() + .expect("failed to execute command"); + + let exit_code = output.status.code().unwrap_or(1); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let combined = if stderr.is_empty() { stdout } else { stderr }; + + if exit_code == 0 { + Ok(combined) + } else { + Err((exit_code, combined)) + } +} + #[must_use] pub fn cargo_tomls() -> Vec<std::path::PathBuf> { let mut cargo_tomls = Vec::new(); |
