From c34545c14fb8a8ab87949d9f3d9711f7277b2818 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 3 Jul 2026 19:37:57 +0200 Subject: [PATCH] chore: add execute command to cli --- .github/workflows/ci.yml | 2 +- Cargo.lock | 1 + acdc-cli/CHANGELOG.md | 3 + acdc-cli/Cargo.toml | 4 +- acdc-cli/README.adoc | 7 +- acdc-cli/src/error.rs | 14 ++ acdc-cli/src/main.rs | 19 ++- acdc-cli/src/subcommands/convert.rs | 2 + acdc-cli/src/subcommands/execute.rs | 204 ++++++++++++++++++++++++++++ acdc-cli/src/subcommands/mod.rs | 3 + 10 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 acdc-cli/src/subcommands/execute.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f32908a..ddbcedc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: version: 0.15.2 - uses: Swatinem/rust-cache@v2 - name: Run clippy - run: cargo clippy --all-targets --all-features -- --deny clippy::pedantic + run: cargo clippy --all-targets --all-features -- --deny clippy::pedantic --deny clippy::todo - name: Install wasm32 target run: rustup target add wasm32-unknown-unknown - name: Run clippy (WASM editor) diff --git a/Cargo.lock b/Cargo.lock index 9a849ca5..0546add4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,7 @@ dependencies = [ "miette", "open", "rayon", + "regex", "serde", "serde_json", "tempfile", diff --git a/acdc-cli/CHANGELOG.md b/acdc-cli/CHANGELOG.md index 53fa13f0..e8c9eb7e 100644 --- a/acdc-cli/CHANGELOG.md +++ b/acdc-cli/CHANGELOG.md @@ -68,6 +68,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `--watermark`, `--watermark-timestamp`, `--page`, `--theme`, `--plain`, `--toc`, and `--emit-typst`; `--strict` now makes unresolved PDF images or logos fail instead of falling back with a warning. +- Builds with the `execute` feature expose placeholder exact-ID, regex-ID, + dry-run, stop-on-failure, and parser safe-mode options for a future command + block runner. Invoking the command is not supported yet. - The `terminal-emulator` build feature renders `[terminal]` session blocks through `libghostty-vt` on the `--backend terminal` path. Requires a Zig toolchain to build the bundled library, which is statically linked so the diff --git a/acdc-cli/Cargo.toml b/acdc-cli/Cargo.toml index 1a38295a..a2983983 100644 --- a/acdc-cli/Cargo.toml +++ b/acdc-cli/Cargo.toml @@ -30,6 +30,7 @@ crossterm.workspace = true miette = { version = "7", features = ["derive", "fancy"] } open = "5" rayon.workspace = true +regex = { version = "1", optional = true } serde.workspace = true serde_json.workspace = true thiserror.workspace = true @@ -81,6 +82,7 @@ terminal-emulator = [ ] # Development tools +execute = ["dep:regex"] # Skeleton for running command blocks from AsciiDoc inspect = [] # AST inspection tool for debugging parser output tck = [] # Technology Compatibility Kit for spec compliance testing @@ -90,7 +92,7 @@ network = ["acdc-parser/network", "acdc-lint?/network", "acdc-converters-pdf?/ne # Feature groups for convenience all-backends = ["html", "manpage", "markdown", "pdf", "terminal"] # Enable all output converters -dev-tools = ["inspect"] # Enable development utilities +dev-tools = ["execute", "inspect"] # Enable development utilities test-tools = ["tck"] # Enable testing and compliance tools [lints.rust] diff --git a/acdc-cli/README.adoc b/acdc-cli/README.adoc index 40583531..5a3804be 100644 --- a/acdc-cli/README.adoc +++ b/acdc-cli/README.adoc @@ -204,12 +204,13 @@ echo '{"contents":"= Hello","path":"test.adoc","type":"block"}' \ | no | Remote includes and remote PDF assets when PDF is selected. -| `inspect`, `tck` +| `execute`, `inspect`, `tck` | no -| Developer/specification commands. +| Developer/specification commands; `execute` is currently scaffolding only. |=== -Convenience groups are `all-backends`, `dev-tools` (`inspect`), and `test-tools` (`tck`). +Convenience groups are `all-backends`, `dev-tools` (`execute`, `inspect`), and `test-tools` (`tck`). +The feature-gated `execute` command is developer scaffolding and is not functional yet. A build with no command-enabling features exits with a diagnostic that lists the available command features. == Diagnostics and exit status diff --git a/acdc-cli/src/error.rs b/acdc-cli/src/error.rs index eb322611..a9b9774b 100644 --- a/acdc-cli/src/error.rs +++ b/acdc-cli/src/error.rs @@ -6,6 +6,13 @@ use std::path::Path; +#[cfg(any( + feature = "html", + feature = "manpage", + feature = "markdown", + feature = "pdf", + feature = "terminal" +))] use acdc_converters_core::Warning as ConverterWarning; #[cfg(feature = "lint")] use acdc_lint::{LintDiagnostic, LintLevel}; @@ -282,6 +289,13 @@ impl WarningReport for ParserWarning { } } +#[cfg(any( + feature = "html", + feature = "manpage", + feature = "markdown", + feature = "pdf", + feature = "terminal" +))] impl WarningReport for ConverterWarning { fn to_report(&self, context: WarningReportContext<'_>) -> Report { build_warning_report( diff --git a/acdc-cli/src/main.rs b/acdc-cli/src/main.rs index 52624876..9ef43342 100644 --- a/acdc-cli/src/main.rs +++ b/acdc-cli/src/main.rs @@ -4,6 +4,7 @@ feature = "markdown", feature = "pdf", feature = "terminal", + feature = "execute", feature = "inspect", feature = "lint", feature = "tck", @@ -16,6 +17,7 @@ use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; feature = "markdown", feature = "pdf", feature = "terminal", + feature = "execute", feature = "lint" ))] mod error; @@ -35,6 +37,7 @@ mod timing; feature = "markdown", feature = "pdf", feature = "terminal", + feature = "execute", feature = "inspect", feature = "lint", feature = "tck", @@ -53,6 +56,7 @@ struct Cli { feature = "markdown", feature = "pdf", feature = "terminal", + feature = "execute", feature = "inspect", feature = "lint", feature = "tck", @@ -69,6 +73,10 @@ enum Commands { /// Convert `AsciiDoc` documents to various output formats Convert(subcommands::convert::Args), + #[cfg(feature = "execute")] + /// Execute command blocks defined in `AsciiDoc` documents + Execute(subcommands::execute::Args), + #[cfg(feature = "inspect")] /// Show a structural outline of an `AsciiDoc` document Inspect(subcommands::inspect::Args), @@ -104,6 +112,7 @@ fn setup_logging() { feature = "markdown", feature = "pdf", feature = "terminal", + feature = "execute", feature = "inspect", feature = "lint", feature = "tck", @@ -127,6 +136,8 @@ fn main() { feature = "terminal" ))] Commands::Convert(_) => true, + #[cfg(feature = "execute")] + Commands::Execute(_) => true, #[cfg(feature = "inspect")] Commands::Inspect(_) => true, #[cfg(feature = "tck")] @@ -142,6 +153,11 @@ fn main() { ))] Commands::Convert(args) => subcommands::convert::run(&args), + #[cfg(feature = "execute")] + Commands::Execute(args) => { + subcommands::execute::run(&args).map_err(|e| miette::miette!("Execute failed: {e}")) + } + #[cfg(feature = "inspect")] Commands::Inspect(args) => { subcommands::inspect::run(&args).map_err(|e| miette::miette!("Inspect failed: {e}")) @@ -188,6 +204,7 @@ fn main() { feature = "markdown", feature = "pdf", feature = "terminal", + feature = "execute", feature = "inspect", feature = "lint", feature = "tck", @@ -196,7 +213,7 @@ fn main() { setup_logging(); eprintln!( "acdc was built without any subcommand features. Enable at least \ - one of: html, manpage, markdown, pdf, terminal, inspect, lint, tck." + one of: html, manpage, markdown, pdf, terminal, execute, inspect, lint, tck." ); std::process::exit(2); } diff --git a/acdc-cli/src/subcommands/convert.rs b/acdc-cli/src/subcommands/convert.rs index 4dc810bc..8c1ffe61 100644 --- a/acdc-cli/src/subcommands/convert.rs +++ b/acdc-cli/src/subcommands/convert.rs @@ -737,6 +737,8 @@ mod tests { let cli = crate::Cli::try_parse_from(raw).map_err(|error| miette::miette!(error))?; match cli.command { crate::Commands::Convert(args) => Ok(args), + #[cfg(feature = "execute")] + crate::Commands::Execute(_) => Err(miette::miette!("test command selected execute")), #[cfg(feature = "inspect")] crate::Commands::Inspect(_) => Err(miette::miette!("test command selected inspect")), #[cfg(feature = "lint")] diff --git a/acdc-cli/src/subcommands/execute.rs b/acdc-cli/src/subcommands/execute.rs new file mode 100644 index 00000000..e34e5e5f --- /dev/null +++ b/acdc-cli/src/subcommands/execute.rs @@ -0,0 +1,204 @@ +//! Skeleton for executing command blocks from `AsciiDoc` files. +//! +//! # Handoff contract +//! +//! The implementation is expected to: +//! +//! - collect listing/source blocks carrying the `command` role and an explicit ID; +//! - recursively visit eligible nested blocks and preserve document order, including +//! blocks originating in included files; +//! - select every command when no selector is supplied, otherwise use the union of +//! exact and regex selectors without executing a command more than once; +//! - report missing selectors and duplicate command IDs as normal CLI diagnostics; +//! - return a failing CLI result if any command fails, with `--exit-on-failure` +//! controlling whether later commands are attempted. +//! +//! Before implementing execution, decide whether scripts use original verbatim source +//! text or parser-transformed inline content, which interpreter and working directory +//! apply, and which environment is inherited. Parser safe mode only limits document +//! reads; it does not sandbox commands. + +use std::path::{Path, PathBuf}; + +use acdc_parser::{Document, Location, ParseResult, SafeMode}; +use clap::{ArgAction, Args as ClapArgs}; +use regex::Regex; + +use crate::error::{self, WarningReport, WarningReportContext}; + +/// Execute command blocks defined in an `AsciiDoc` file +#[derive(ClapArgs, Debug)] +pub struct Args { + /// Input `AsciiDoc` file + pub file: PathBuf, + + /// Select command blocks whose id exactly matches this value + #[arg(long = "id", value_name = "ID", action = ArgAction::Append)] + pub ids: Vec, + + /// Select command blocks whose id matches this regex + #[arg(long = "id-regex", value_name = "REGEX", action = ArgAction::Append)] + pub id_regexes: Vec, + + /// Print selected commands in execution order instead of running them + #[arg(long)] + pub dry_run: bool, + + /// Stop at the first command that exits unsuccessfully + #[arg(long)] + pub exit_on_failure: bool, + + /// Safe mode to use while parsing the document + /// + /// This limits document reads and includes; it does not sandbox commands. + #[arg(short = 'S', long, value_parser = clap::value_parser!(SafeMode), default_value = "safe")] + pub safe_mode: SafeMode, +} + +pub fn run(args: &Args) -> miette::Result<()> { + let parser_options = acdc_parser::Options::builder() + .with_safe_mode(args.safe_mode) + .build(); + let parsed = + acdc_parser::parse_file(&args.file, &parser_options).map_err(|e| error::display(&e))?; + let parsed = report_warnings(parsed, &args.file); + let candidates = collect_command_candidates(parsed.document(), &args.file)?; + let plan = select_candidates(args, candidates)?; + + if args.dry_run { + render_dry_run(&plan)?; + } else { + execute_plan(&plan, args.exit_on_failure)?; + } + + Ok(()) +} + +#[allow(dead_code)] +#[derive(Debug)] +struct CommandCandidate { + id: String, + /// Planned command text. The implementation must decide whether this is original + /// verbatim source or reconstructed parser content before populating it. + script: String, + source: CommandSource, +} + +#[allow(dead_code)] +#[derive(Debug)] +struct CommandSource { + /// Root document supplied on the command line. + root_document: PathBuf, + /// Parser-remapped location. Its positions retain the include chain for content + /// originating outside the root document. + location: Location, +} + +#[allow(dead_code)] +#[derive(Debug)] +struct ExecutionPlan { + commands: Vec, +} + +fn report_warnings(parsed: ParseResult, file: &Path) -> ParseResult { + let context = WarningReportContext::new().with_optional_file(Some(file)); + for warning in parsed.warnings() { + eprintln!("{:?}", warning.to_report(context)); + } + parsed +} + +fn collect_command_candidates( + _document: &Document<'_>, + _root_document: &Path, +) -> miette::Result> { + todo!("collect [.command] listing/source blocks from the parsed AsciiDoc document") +} + +fn select_candidates( + _args: &Args, + _candidates: Vec, +) -> miette::Result { + todo!("apply --id and --id-regex selectors while preserving document order") +} + +fn render_dry_run(_plan: &ExecutionPlan) -> miette::Result<()> { + todo!("print selected command scripts in the sequence they would be executed") +} + +fn execute_plan(_plan: &ExecutionPlan, _exit_on_failure: bool) -> miette::Result<()> { + todo!("execute selected command scripts") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use acdc_parser::{Block, DelimitedBlockType, Options, SafeMode}; + use clap::Parser; + + use super::Args; + + #[derive(Parser)] + struct TestCli { + #[command(flatten)] + args: Args, + } + + #[test] + fn parses_execute_skeleton_flags() { + let cli = TestCli::parse_from([ + "test", + "README.adoc", + "--id", + "build", + "--id", + "test", + "--id-regex", + "^deploy-", + "--dry-run", + "--exit-on-failure", + ]); + + assert_eq!(cli.args.file, PathBuf::from("README.adoc")); + assert_eq!(cli.args.ids, ["build", "test"]); + assert_eq!(cli.args.id_regexes.len(), 1); + assert_eq!( + cli.args.id_regexes.first().map(regex::Regex::as_str), + Some("^deploy-") + ); + assert!(cli.args.dry_run); + assert!(cli.args.exit_on_failure); + assert_eq!(cli.args.safe_mode, SafeMode::Safe); + } + + #[test] + fn rejects_invalid_id_regex() { + let err = TestCli::try_parse_from(["test", "README.adoc", "--id-regex", "["]); + assert!(err.is_err()); + } + + #[test] + fn parses_the_planned_command_block_shape() -> miette::Result<()> { + let input = "[.command, id=build]\n----\necho hello\n----\n"; + let parsed = acdc_parser::parse(input, &Options::default()) + .map_err(|error| miette::miette!(error.to_string()))?; + let Some(Block::DelimitedBlock(block)) = parsed.document().blocks.first() else { + return Err(miette::miette!( + "expected command markup to parse as a delimited block" + )); + }; + + assert!(matches!( + block.inner, + DelimitedBlockType::DelimitedListing(_) + )); + assert_eq!(block.metadata.roles, ["command"]); + assert_eq!( + block.metadata.id.as_ref().map(|anchor| anchor.id), + Some("build") + ); + + Ok(()) + } +} diff --git a/acdc-cli/src/subcommands/mod.rs b/acdc-cli/src/subcommands/mod.rs index 188c9f61..f45a9808 100644 --- a/acdc-cli/src/subcommands/mod.rs +++ b/acdc-cli/src/subcommands/mod.rs @@ -7,6 +7,9 @@ ))] pub mod convert; +#[cfg(feature = "execute")] +pub mod execute; + #[cfg(feature = "inspect")] pub mod inspect;