aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.toml3
-rw-r--r--README.md772
-rw-r--r--dev_tools/src/bin/ci.rs6
-rw-r--r--dev_tools/src/bin/test-readme.rs349
-rw-r--r--dev_tools/src/lib.rs41
5 files changed, 1136 insertions, 35 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 47cbe6c..665f9f6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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",
diff --git a/README.md b/README.md
index 81c2414..62b8b7f 100644
--- a/README.md
+++ b/README.md
@@ -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();