src/bin/weaveback-macro.rs is the weaveback-macro command-line binary. It wraps the macro_api layer with a clap argument parser, adds --dir discovery mode, and exposes --dump-ast for debugging.

Design rationale

--dir discovery mode

When --dir is given, the binary:

  1. Recursively collects all files matching --ext under the directory.

  2. Runs a discovery pass — a second evaluation in discovery_mode: true where %include(path) calls are recorded but not expanded.

  3. Filters out any file that appears as a %include target of another file in the same scan, leaving only driver files (top-level entry points).

  4. Processes the driver files with a fresh evaluator.

This means you can drop all your literate fragments and their driver into one directory and run weaveback-macro --dir . without manually listing which file is the entry point.

Mutual exclusion: positional inputs vs --dir

ArgGroup::new("source").required(true) enforces that exactly one of --dir or positional input files is present. The conflicts_with = "inputs" attribute on --dir is the clap-level guard; the run() function branches on args.directory.

--pathsep and platform defaults

On Unix the path separator for --include is :, on Windows ;. default_pathsep() returns the right one for the current target triple at compile time so the binary behaves correctly without user configuration.

--dump-ast

Skips evaluation entirely and serialises the parsed AST of each input file to <file>.ast. Useful for debugging macro parse errors.

File structure

// <<@file weaveback-macro/src/bin/weaveback-macro.rs>>=
// <<cli preamble>>
// <<cli default pathsep>>
// <<cli find files>>
// <<cli args struct>>
// <<cli run>>
// <<cli main>>
// @

Preamble

// <<cli preamble>>=
// crates/weaveback-macro/src/bin/macro_cli.rs

use weaveback_macro::evaluator::{EvalConfig, EvalError, Evaluator};
use weaveback_macro::macro_api::process_string;
use clap::{ArgGroup, Parser};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
// @

Platform path separator

// <<cli default pathsep>>=
/// Returns the default path separator based on the platform
fn default_pathsep() -> String {
    if cfg!(windows) {
        ";".to_string()
    } else {
        ":".to_string()
    }
}
// @

find_files — recursive extension scanner

// <<cli find files>>=
/// Recursively collect all files whose extension matches any entry in `exts` under `dir`.
fn find_files(dir: &Path, exts: &[String], out: &mut Vec<PathBuf>) -> std::io::Result<()> {
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            find_files(&path, exts, out)?;
        } else if let Some(e) = path.extension().and_then(|e| e.to_str())
            && exts.iter().any(|x| x == e)
        {
            out.push(path);
        }
    }
    Ok(())
}
// @

CLI argument struct

// <<cli args struct>>=
#[derive(Parser, Debug)]
#[command(
    name = "weaveback-macro",
    version,
    about = "Weaveback macros translator (Rust)",
    group(ArgGroup::new("source").required(true).args(["inputs", "directory"]))
)]
struct Args {
    /// Output path (file or '-' for stdout)
    #[arg(long = "output", default_value = "-")]
    output: PathBuf,

    /// Special character for macros
    #[arg(long = "special", default_value = "%")]
    special: char,

    /// List of include paths separated by the path separator
    #[arg(long = "include", default_value = ".")]
    include: String,

    /// Path separator (usually ':' on Unix, ';' on Windows)
    #[arg(long = "pathsep", default_value_t = default_pathsep())]
    pathsep: String,

    /// Base directory for input files
    #[arg(long = "input-dir", default_value = ".")]
    input_dir: PathBuf,

    /// Allow %env(NAME) to read environment variables.
    #[arg(long)]
    allow_env: bool,

    /// The input files (mutually exclusive with --dir)
    #[arg(required = false)]
    inputs: Vec<PathBuf>,

    /// Discover and process driver files under this directory.
    /// A driver is any file (matching --ext) not referenced by a %include() in another such file.
    /// Mutually exclusive with positional input files.
    #[arg(long = "dir", conflicts_with = "inputs")]
    directory: Option<PathBuf>,

    /// File extension(s) to scan in --dir mode (can be repeated).
    /// Default: md. Example: --ext adoc --ext md to scan both.
    #[arg(long, default_value = "md")]
    ext: Vec<String>,

    /// Dump the parsed AST for each input file to <file>.ast (or stdout for stdin).
    /// Skips macro evaluation entirely.
    #[arg(long = "dump-ast")]
    dump_ast: bool,
}
// @

run — core logic

// <<cli run>>=
fn run(args: Args) -> Result<(), EvalError> {
    let include_paths: Vec<PathBuf> = args
        .include
        .split(&args.pathsep)
        .map(PathBuf::from)
        .collect();

    let config = EvalConfig {
        special_char: args.special,
        include_paths,
        discovery_mode: false,
        allow_env: args.allow_env,
    };

    let final_inputs: Vec<PathBuf> = if let Some(ref dir) = args.directory {
        let mut all = Vec::new();
        find_files(dir, &args.ext, &mut all)
            .map_err(|e| EvalError::Runtime(format!("Directory scan failed: {e}")))?;
        all.sort();

        // Discovery pass: identify which files are %include'd by others (fragments).
        let discovery_config = EvalConfig {
            discovery_mode: true,
            ..config.clone()
        };
        let mut included: HashSet<PathBuf> = HashSet::new();
        for f in &all {
            if let Ok(text) = std::fs::read_to_string(f) {
                let mut disc = Evaluator::new(discovery_config.clone());
                if process_string(&text, Some(f), &mut disc).is_ok() {
                    for p in disc.take_discovered_includes() {
                        included.insert(p.canonicalize().unwrap_or(p));
                    }
                }
            }
        }

        all.into_iter()
            .filter(|f| {
                let canon = f.canonicalize().unwrap_or_else(|_| f.to_path_buf());
                !included.contains(&canon)
            })
            .collect()
    } else {
        let mut inputs = Vec::new();
        for inp in &args.inputs {
            let full = args.input_dir.join(inp);
            let canon = full.canonicalize().unwrap_or_else(|_| full.clone());
            if !full.exists() {
                return Err(EvalError::Runtime(format!(
                    "Input file does not exist: {:?}",
                    canon
                )));
            }
            inputs.push(full);
        }
        inputs
    };

    if args.dump_ast {
        return weaveback_macro::ast::dump_macro_ast(args.special, &final_inputs);
    }

    weaveback_macro::macro_api::process_files_from_config(&final_inputs, &args.output, config)
}
// @

main

// <<cli main>>=
fn main() {
    let args = Args::parse();
    match run(args) {
        Ok(()) => std::process::exit(0),
        Err(e) => {
            eprintln!("Error: {e}");
            std::process::exit(1);
        }
    }
}
// @

Tests

Integration tests for the weaveback-macro CLI binary. They use escargot to build and invoke the binary as a subprocess, exercising the full CLI contract:

  • test_basic_macro_processing%def / invocation round-trip to output file

  • test_cli_help--help exits 0 and mentions weaveback-macro / --output

  • test_missing_input_file — non-existent input → non-zero exit + error message

  • test_multiple_inputs — two input files concatenated into one output

  • test_custom_special_char--special @ selects @ as macro sigil

  • test_rhaidef_arithmetic%rhaidef with Rhai expression body computes 21*2

  • test_colon_separated_includes--include ./path colon syntax resolves files

  • test_custom_pathsep_includes--pathsep | overrides the include separator

  • test_large_input — 10 000-line file with 10 000 macro expansions (smoke test)

// <<@file weaveback-macro/tests/test_macro_cli.rs>>=
// crates/weaveback-macro/tests/test_macro_cli.rs

use escargot;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tempfile::TempDir;

// Helper function to create a file with content
fn create_test_file(dir: &Path, name: &str, content: &str) -> PathBuf {
    let path = dir.join(name);
    let mut file = fs::File::create(&path).unwrap();
    write!(file, "{}", content).unwrap();
    path.canonicalize().unwrap()
}

// Helper to build and get command
fn cargo_weaveback_macro_cli() -> Result<escargot::CargoRun, Box<dyn std::error::Error>> {
    Ok(escargot::CargoBuild::new()
        .bin("weaveback-macro")
        .current_release()
        .current_target()
        .run()?)
}

#[test]
fn test_basic_macro_processing() -> Result<(), Box<dyn std::error::Error>> {
    let temp = TempDir::new()?;
    let temp_path = temp.path().canonicalize()?;

    let input = create_test_file(
        &temp_path,
        "input.txt",
        r#"%def(hello, World)
Hello %hello()!"#,
    );
    assert!(input.exists(), "Input file should exist");

    let out_file = temp_path.join("output.txt");

    let run = cargo_weaveback_macro_cli()?;
    let mut cmd = run.command();
    cmd.arg("--output")
        .arg(&out_file)
        .arg(&input);

    let output = cmd.output()?;
    println!("Exit status: {}", output.status);
    println!("Stdout: {}", String::from_utf8_lossy(&output.stdout));
    println!("Stderr: {}", String::from_utf8_lossy(&output.stderr));

    assert!(output.status.success());
    assert!(out_file.exists(), "Output file should exist");

    let output_content = fs::read_to_string(&out_file)?;
    assert_eq!(output_content.trim(), "Hello World!");

    Ok(())
}

// 1) Test the help message
#[test]
fn test_cli_help() -> Result<(), Box<dyn std::error::Error>> {
    let run = cargo_weaveback_macro_cli()?;
    let mut cmd = run.command();
    cmd.arg("--help");

    let output = cmd.output()?;
    assert!(
        output.status.success(),
        "Expected 'weaveback-macro --help' to succeed."
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("weaveback-macro"),
        "Help output did not mention 'weaveback-macro'"
    );
    assert!(
        stdout.contains("--output"),
        "Help output did not mention '--output'"
    );

    Ok(())
}

// 2) Test passing a non-existent input file
#[test]
fn test_missing_input_file() -> Result<(), Box<dyn std::error::Error>> {
    let temp = TempDir::new()?;
    let temp_path = temp.path().canonicalize()?;

    let missing_input = temp_path.join("not_real.txt");
    let out_file = temp_path.join("output.txt");

    let run = cargo_weaveback_macro_cli()?;
    let mut cmd = run.command();
    cmd.arg("--output")
        .arg(&out_file)
        .arg(&missing_input);

    let output = cmd.output()?;
    assert!(
        !output.status.success(),
        "CLI was expected to fail on missing file."
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    println!("(missing_input) stderr:\n{stderr}");
    assert!(
        stderr.contains("Input file does not exist"),
        "Should mention 'Input file does not exist' in error."
    );

    Ok(())
}

// 3) Test multiple input files in a single run
#[test]
fn test_multiple_inputs() -> Result<(), Box<dyn std::error::Error>> {
    let temp = TempDir::new()?;
    let temp_path = temp.path().canonicalize()?;

    let input1 = create_test_file(
        &temp_path,
        "file1.txt",
        "%def(macro1, MACRO_ONE)\n%macro1()",
    );
    let input2 = create_test_file(
        &temp_path,
        "file2.txt",
        "%def(macro2, MACRO_TWO)\n%macro2()",
    );

    let out_file = temp_path.join("combined_output.txt");

    let run = cargo_weaveback_macro_cli()?;
    let mut cmd = run.command();
    cmd.arg("--output")
        .arg(&out_file)
        .arg(&input1)
        .arg(&input2);

    let output = cmd.output()?;
    assert!(output.status.success());

    let content = fs::read_to_string(&out_file)?;
    assert!(
        content.contains("MACRO_ONE"),
        "Expected 'MACRO_ONE' in combined output file."
    );
    assert!(
        content.contains("MACRO_TWO"),
        "Expected 'MACRO_TWO' in combined output file."
    );

    Ok(())
}

// 4) Test a custom special char
#[test]
fn test_custom_special_char() -> Result<(), Box<dyn std::error::Error>> {
    let temp = TempDir::new()?;
    let temp_path = temp.path().canonicalize()?;

    let input = create_test_file(
        &temp_path,
        "input_at.txt",
        "@def(test_macro, Hello from custom char)\n@test_macro()",
    );
    let out_file = temp_path.join("output_at.txt");

    let run = cargo_weaveback_macro_cli()?;
    let mut cmd = run.command();
    cmd.arg("--special")
        .arg("@")
        .arg("--output")
        .arg(&out_file)
        .arg(&input);

    let output = cmd.output()?;
    assert!(
        output.status.success(),
        "CLI run with custom special char should succeed."
    );

    let content = fs::read_to_string(&out_file)?;
    assert!(
        content.contains("Hello from custom char"),
        "Expected to see expansion with '@' as the macro char."
    );

    Ok(())
}

// 5) Test rhaidef: arithmetic via Rhai scripting
#[test]
fn test_rhaidef_arithmetic() -> Result<(), Box<dyn std::error::Error>> {
    let temp = TempDir::new()?;
    let temp_path = temp.path().canonicalize()?;

    let input = create_test_file(
        &temp_path,
        "rhaidef_test.txt",
        "%rhaidef(double, x, %{(parse_int(x) * 2).to_string()%})\n%double(21)",
    );

    let out_file = temp_path.join("output_rhai.txt");

    let run = cargo_weaveback_macro_cli()?;
    let mut cmd = run.command();
    cmd.arg("--output")
        .arg(&out_file)
        .arg(&input);

    let output = cmd.output()?;
    println!(
        "(rhaidef) stderr:\n{}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        output.status.success(),
        "CLI should succeed with rhaidef macro."
    );

    let content = fs::read_to_string(&out_file)?;
    assert!(
        content.contains("42"),
        "Expected '42' from rhaidef arithmetic."
    );

    Ok(())
}

// 6) Test using a colon-separated include path
#[test]
fn test_colon_separated_includes() -> Result<(), Box<dyn std::error::Error>> {
    let temp = TempDir::new()?;
    let temp_path = temp.path().canonicalize()?;

    let includes_dir = temp_path.join("includes");
    fs::create_dir_all(&includes_dir)?;
    let _inc_file = create_test_file(&includes_dir, "my_include.txt", "From includes dir");

    let main_file = create_test_file(&temp_path, "main.txt", "%include(my_include.txt)");

    let out_file = temp_path.join("output_inc.txt");

    let run = cargo_weaveback_macro_cli()?;
    let mut cmd = run.command();
    let includes_str = format!(".:{}", includes_dir.to_string_lossy());

    cmd.arg("--include")
        .arg(&includes_str)
        .arg("--output")
        .arg(&out_file)
        .arg(&main_file);

    let output = cmd.output()?;
    assert!(
        output.status.success(),
        "CLI should succeed with colon-separated includes."
    );

    let content = fs::read_to_string(&out_file)?;
    assert!(
        content.contains("From includes dir"),
        "Expected the included content from includes/my_include.txt."
    );

    Ok(())
}

// 7) Test forcing a custom --pathsep
#[test]
fn test_custom_pathsep_includes() -> Result<(), Box<dyn std::error::Error>> {
    let temp = TempDir::new()?;
    let temp_path = temp.path().canonicalize()?;

    let includes_dir = temp_path.join("my_includes");
    fs::create_dir_all(&includes_dir)?;
    create_test_file(&includes_dir, "m_incl.txt", "Inside custom pathsep dir");

    let main_file = create_test_file(&temp_path, "custom_sep_main.txt", "%include(m_incl.txt)");

    let out_file = temp_path.join("output_sep.txt");
    let includes_str = format!(".|{}", includes_dir.display());

    let run = cargo_weaveback_macro_cli()?;
    let mut cmd = run.command();
    cmd.arg("--include")
        .arg(&includes_str)
        .arg("--pathsep")
        .arg("|")
        .arg("--output")
        .arg(&out_file)
        .arg(&main_file);

    let output = cmd.output()?;
    assert!(output.status.success());

    let content = fs::read_to_string(&out_file)?;
    assert!(
        content.contains("Inside custom pathsep dir"),
        "Expected custom pathsep to locate includes dir."
    );

    Ok(())
}

// 8) Test that the CLI can handle a large input file (smoke test)
#[test]
fn test_large_input() -> Result<(), Box<dyn std::error::Error>> {
    let temp = TempDir::new()?;
    let temp_path = temp.path().canonicalize()?;

    let mut big_content = String::new();
    big_content.push_str("%def(say, HELLO)\n");
    for _ in 0..10_000 {
        big_content.push_str("%say()");
        big_content.push('\n');
    }

    let big_file = create_test_file(&temp_path, "big_file.txt", &big_content);
    let out_file = temp_path.join("output_big.txt");

    let run = cargo_weaveback_macro_cli()?;
    let mut cmd = run.command();
    cmd.arg("--output")
        .arg(&out_file)
        .arg(&big_file);

    let output = cmd.output()?;
    assert!(
        output.status.success(),
        "CLI should handle a large input file."
    );

    let out_content = fs::read_to_string(&out_file)?;
    let line_count = out_content.matches("HELLO").count();
    assert_eq!(
        line_count, 10_000,
        "Expected 10,000 expansions of HELLO in the large output."
    );

    Ok(())
}
// @