diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 39cc473..2ae8165 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -38,16 +38,25 @@ pub fn run_cli(cli: Cli) -> Result<()> { profile, exclude, all, - }) => commands::build::run_selection( - &config, - dry_run, - resume, - optimization, - target.as_deref(), - profile.as_deref(), - &exclude, - all, - )?, + }) => { + let target_override = if let Some(target) = target.as_deref() { + commands::build::BuildTargetOverride::Target(target) + } else if let Some(profile) = profile.as_deref() { + commands::build::BuildTargetOverride::Profile(profile) + } else if all { + commands::build::BuildTargetOverride::AllReachable + } else { + commands::build::BuildTargetOverride::Configured + }; + commands::build::run_selection(commands::build::BuildOptions { + config_path: &config, + dry_run, + resume, + optimization, + target_override, + exclude: &exclude, + })?; + } Some(Commands::Watch { config, debounce }) => commands::watch::run(&config, debounce)?, Some(Commands::Audit) => commands::audit::run()?, Some(Commands::Inspect { diff --git a/crates/renderflow-core/src/commands/build.rs b/crates/renderflow-core/src/commands/build.rs index 12703c7..cbcc1a3 100644 --- a/crates/renderflow-core/src/commands/build.rs +++ b/crates/renderflow-core/src/commands/build.rs @@ -4,19 +4,56 @@ use tracing::info; use crate::optimization::OptimizationMode; use crate::planning::{execute, resolve, PlanningRequest}; +/// Explicit CLI override for the target intent declared by the configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BuildTargetOverride<'a> { + Configured, + Target(&'a str), + Profile(&'a str), + AllReachable, +} + +/// Typed options for one canonical build selection. +#[derive(Debug)] +pub(crate) struct BuildOptions<'a> { + pub config_path: &'a str, + pub dry_run: bool, + pub resume: bool, + pub optimization: Option, + pub target_override: BuildTargetOverride<'a>, + pub exclude: &'a [String], +} + +impl BuildOptions<'_> { + fn planning_request(&self) -> Result { + let mut request = PlanningRequest::from_path(self.config_path); + if let Some(optimization) = self.optimization { + request = request.with_optimization(optimization); + } + request = match self.target_override { + BuildTargetOverride::Configured => request, + BuildTargetOverride::Target(target) => request.with_target(target), + BuildTargetOverride::Profile(profile) => request.with_profile(profile), + BuildTargetOverride::AllReachable => request.with_all_reachable(), + }; + for selector in self.exclude { + request = request.with_exclude(selector)?; + } + Ok(request) + } +} + /// Run the canonical Renderflow execution lifecycle using the target intent /// declared in the v1/v2 configuration. pub fn run(config_path: &str, dry_run: bool, optimization: Option) -> Result<()> { - run_selection( + run_selection(BuildOptions { config_path, dry_run, - false, + resume: false, optimization, - None, - None, - &[], - false, - ) + target_override: BuildTargetOverride::Configured, + exclude: &[], + }) } /// Compatibility entrypoint for watch mode. @@ -29,38 +66,14 @@ pub fn run_resilient(config_path: &str) -> Result<()> { } /// Run the canonical lifecycle with optional CLI target overrides. -pub(crate) fn run_selection( - config_path: &str, - dry_run: bool, - resume: bool, - optimization: Option, - target: Option<&str>, - profile: Option<&str>, - exclude: &[String], - all_reachable: bool, -) -> Result<()> { - if dry_run { +pub(crate) fn run_selection(options: BuildOptions<'_>) -> Result<()> { + if options.dry_run { info!( "Dry-run mode enabled — planning and bounded provider probes may run, but transforms and output writes are disabled" ); } - let mut request = PlanningRequest::from_path(config_path); - if let Some(optimization) = optimization { - request = request.with_optimization(optimization); - } - if let Some(target) = target { - request = request.with_target(target); - } else if let Some(profile) = profile { - request = request.with_profile(profile); - } else if all_reachable { - request = request.with_all_reachable(); - } - for selector in exclude { - request = request.with_exclude(selector)?; - } - - let resolved = resolve(request)?.with_resume(resume); + let resolved = resolve(options.planning_request()?)?.with_resume(options.resume); info!( source = %resolved.source_format(), targets = %resolved @@ -74,13 +87,13 @@ pub(crate) fn run_selection( "Resolved canonical execution plan" ); - let result = execute(resolved, dry_run)?; - if dry_run { + let result = execute(resolved, options.dry_run)?; + if options.dry_run { // stdout is reserved for machine-readable plan evidence; tracing remains on stderr. println!("{}", serde_json::to_string_pretty(&result.plan)?); } for output in &result.run_manifest.artifact_manifest.outputs { - if dry_run { + if options.dry_run { info!("[DRY RUN] Planned output: {}", output); } else { info!("✔ Output written to: {}", output); @@ -106,3 +119,52 @@ pub(crate) fn run_selection( } Ok(()) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn build_options_map_profile_and_exclusions_into_planning_request() { + let exclusions = vec![ + "family:video".to_string(), + "provider:tool.ffmpeg".to_string(), + ]; + let request = BuildOptions { + config_path: "custom.yaml", + dry_run: true, + resume: true, + optimization: Some(OptimizationMode::Quality), + target_override: BuildTargetOverride::Profile("everything"), + exclude: &exclusions, + } + .planning_request() + .unwrap(); + + assert_eq!(request.config_path, PathBuf::from("custom.yaml")); + assert_eq!(request.optimization, Some(OptimizationMode::Quality)); + assert_eq!(request.profile.as_deref(), Some("everything")); + assert!(request.target.is_none()); + assert!(!request.all_reachable); + assert_eq!(request.exclude.families, ["video"]); + assert_eq!(request.exclude.providers, ["tool.ffmpeg"]); + } + + #[test] + fn build_options_reject_invalid_exclusion_syntax() { + let exclusions = vec!["video".to_string()]; + let result = BuildOptions { + config_path: "renderflow.yaml", + dry_run: false, + resume: false, + optimization: None, + target_override: BuildTargetOverride::AllReachable, + exclude: &exclusions, + } + .planning_request(); + + assert!(result.is_err()); + } +} diff --git a/crates/renderflow-core/src/graph/dag_executor.rs b/crates/renderflow-core/src/graph/dag_executor.rs index 8a0e6e2..bcf1eb5 100644 --- a/crates/renderflow-core/src/graph/dag_executor.rs +++ b/crates/renderflow-core/src/graph/dag_executor.rs @@ -154,7 +154,7 @@ fn failed_step( started_at_unix_ms: u64, duration_ms: u64, ) -> StepEvidence { - let message = redact_sensitive_text(&error.to_string()); + let message = redact_sensitive_text(&format!("{error:#}")); let step_id = format!("step:{}-to-{}", edge.from, edge.to); StepEvidence { step_id: step_id.clone(), diff --git a/crates/renderflow-core/tests/canonical_planner.rs b/crates/renderflow-core/tests/canonical_planner.rs index ec14010..e25fc71 100644 --- a/crates/renderflow-core/tests/canonical_planner.rs +++ b/crates/renderflow-core/tests/canonical_planner.rs @@ -62,8 +62,14 @@ fn v1_and_v2_resolve_through_the_same_canonical_planner() { assert_eq!(v1.target_formats(), v2.target_formats()); assert_eq!(v1.plan().source, v2.plan().source); assert_eq!(v1.plan().targets, v2.plan().targets); - assert_eq!(v1.plan().metadata.total_edges, v2.plan().metadata.total_edges); - assert_eq!(v1.plan().metadata.execution_depth, v2.plan().metadata.execution_depth); + assert_eq!( + v1.plan().metadata.total_edges, + v2.plan().metadata.total_edges + ); + assert_eq!( + v1.plan().metadata.execution_depth, + v2.plan().metadata.execution_depth + ); } #[test] @@ -73,12 +79,16 @@ fn dry_run_returns_the_exact_frozen_plan_without_writing_outputs() { let frozen_plan = serde_json::to_value(resolved.plan()).expect("plan should serialize"); assert!(!configs.v2_output.exists()); - let result = execute(resolved, true).expect("dry-run should succeed without provider execution"); + let result = + execute(resolved, true).expect("dry-run should succeed without provider execution"); assert_eq!( serde_json::to_value(&result.plan).expect("result plan should serialize"), frozen_plan ); - assert!(!configs.v2_output.exists(), "dry-run must not create output root"); + assert!( + !configs.v2_output.exists(), + "dry-run must not create output root" + ); } #[test] @@ -87,5 +97,8 @@ fn v1_dry_run_is_also_side_effect_free() { let resolved = resolve(PlanningRequest::from_path(&configs.v1)).expect("v1 should resolve"); assert!(!configs.v1_output.exists()); execute(resolved, true).expect("v1 dry-run should succeed"); - assert!(!configs.v1_output.exists(), "v1 dry-run must not create output root"); + assert!( + !configs.v1_output.exists(), + "v1 dry-run must not create output root" + ); } diff --git a/docs/publication-hygiene.md b/docs/publication-hygiene.md index 20f9516..c0eede4 100644 --- a/docs/publication-hygiene.md +++ b/docs/publication-hygiene.md @@ -83,4 +83,4 @@ Probabilistic redaction always produces `review_required` status for the resulti SDK integrations implement `ContentRedactionProvider` and declare a stable provider ID, version, and determinism class. Providers receive bytes plus configured content classes and return new bytes, changed classes, and safe findings. They must not mutate source files or include sensitive values in diagnostics. -Hygiene evidence uses [`renderflow.hygiene/v1`](../schemas/renderflow-hygiene-v1.schema.json) and is embedded in terminal artifact evidence. A `publication.hygiene` step records the policy digest, provider, input candidate, sanitized output, duration, and fidelity. +Hygiene evidence uses [`renderflow.hygiene/v1`](https://github.com/egohygiene/renderflow/blob/main/schemas/renderflow-hygiene-v1.schema.json) and is embedded in terminal artifact evidence. A `publication.hygiene` step records the policy digest, provider, input candidate, sanitized output, duration, and fidelity. diff --git a/docs/user-guide/tool-registry.md b/docs/user-guide/tool-registry.md index 57de46c..1bbcf45 100644 --- a/docs/user-guide/tool-registry.md +++ b/docs/user-guide/tool-registry.md @@ -20,10 +20,12 @@ without editing this built-in catalog. | `tool.epubcheck` | EPUBCheck | executable: `epubcheck` | optional | deterministic | local | | `tool.ffmpeg` | FFmpeg | executable: `ffmpeg` | optional | configuration_dependent | local | | `tool.ghostscript` | Ghostscript | executable: `gs` | experimental | configuration_dependent | local | +| `tool.handbrake` | HandBrakeCLI | executable: `HandBrakeCLI` | optional | configuration_dependent | local | | `tool.imagemagick` | ImageMagick | executable: `magick` | experimental | configuration_dependent | local | | `tool.img2pdf` | img2pdf | executable: `img2pdf` | experimental | deterministic | local | | `tool.jq` | jq | executable: `jq` | experimental | deterministic | local | | `tool.kepubify` | Kepubify | executable: `kepubify` | optional | configuration_dependent | local | +| `tool.lulu-rules` | Pinned Lulu publication rule pack | virtual | optional | deterministic | local | | `tool.pandoc` | Pandoc | executable: `pandoc` | required | configuration_dependent | local | | `tool.tectonic` | Tectonic | executable: `tectonic` | optional | configuration_dependent | network_optional | | `tool.tesseract` | Tesseract OCR | executable: `tesseract` | experimental | configuration_dependent | local | @@ -42,10 +44,16 @@ without editing this built-in catalog. | `video.convert` | `tool.ffmpeg` | | `pdf.process` | `tool.ghostscript` | | `tiff.aggregate.press_pdf` | `tool.ghostscript` | +| `video.transcode.whole_file` | `tool.handbrake` | | `image.convert` | `tool.imagemagick` | | `image.aggregate.pdf` | `tool.img2pdf` | | `data.json.transform` | `tool.jq` | | `ebook.convert.kepub` | `tool.kepubify` | +| `publication.lulu.bookstore.preflight` | `tool.lulu-rules` | +| `publication.lulu.epub-distribution.preflight` | `tool.lulu-rules` | +| `publication.lulu.global-distribution.preflight` | `tool.lulu-rules` | +| `publication.lulu.pdf-ebook.preflight` | `tool.lulu-rules` | +| `publication.lulu.print-direct.preflight` | `tool.lulu-rules` | | `document.convert` | `tool.pandoc` | | `document.generate` | `tool.pandoc` | | `latex.compile` | `tool.tectonic` | diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index b7d9ae2..858b067 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -361,7 +361,6 @@ fn test_dry_run_output_labeled() { ); } - #[test] fn test_v2_dry_run_serializes_canonical_plan() { let (config, _dir) = common::v2_config_file(); @@ -524,7 +523,9 @@ fn test_all_without_transforms_uses_builtin_capability_registry() { let plan: serde_json::Value = serde_json::from_slice(&output.stdout) .expect("all-reachable dry-run should emit canonical plan JSON"); assert_eq!(plan["source"], "markdown"); - assert!(plan["targets"].as_array().is_some_and(|targets| !targets.is_empty())); + assert!(plan["targets"] + .as_array() + .is_some_and(|targets| !targets.is_empty())); } #[test] @@ -665,7 +666,7 @@ fn test_inspect_missing_config_exits_with_error() { } #[test] -fn test_inspect_without_transforms_exits_with_error() { +fn test_inspect_without_transforms_uses_builtin_capability_registry() { let (f, _dir) = common::valid_config_file(); let output = Command::new(env!("CARGO_BIN_EXE_renderflow")) .args(["inspect", "--config"]) @@ -674,13 +675,14 @@ fn test_inspect_without_transforms_exits_with_error() { .expect("failed to execute renderflow"); assert!( - !output.status.success(), - "inspect without a 'transforms' key in config should fail" + output.status.success(), + "inspect should use built-in capabilities without a transforms file: {}", + String::from_utf8_lossy(&output.stderr) ); - let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stderr.contains("transforms"), - "error should mention 'transforms', got: {stderr}" + stdout.contains("DAG Execution Plan"), + "inspect should emit a built-in execution plan, got: {stdout}" ); } @@ -884,7 +886,7 @@ fn test_graph_stats_help_exits_successfully() { } #[test] -fn test_graph_plan_without_transforms_exits_with_error() { +fn test_graph_plan_without_transforms_uses_builtin_capability_registry() { let (config_file, _dir) = common::valid_config_file(); let output = Command::new(env!("CARGO_BIN_EXE_renderflow")) .args([ @@ -897,8 +899,14 @@ fn test_graph_plan_without_transforms_exits_with_error() { .expect("failed to execute renderflow"); assert!( - !output.status.success(), - "graph plan without transforms should exit with error" + output.status.success(), + "graph plan should use built-in capabilities without a transforms file: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Execution Plan"), + "graph plan should emit a built-in execution plan, got: {stdout}" ); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 997cffc..ad8d30a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -44,7 +44,7 @@ transforms:\n \ let output_dir = dir.path().join("dist"); let config_content = format!( - "input: \"{}\"\noutput_dir: \"{}\"\ntransforms: \"{}\"\n", + "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\ntransforms: \"{}\"\n", input_path.display(), output_dir.display(), transforms_path.display(), @@ -56,7 +56,6 @@ transforms:\n \ (config_file, dir) } - /// Create a minimal Renderflow v2 config for canonical planner CLI tests. #[allow(dead_code)] pub fn v2_config_file() -> (NamedTempFile, tempfile::TempDir) { diff --git a/tests/graph_integration_test.rs b/tests/graph_integration_test.rs index 1ceab15..45555a0 100644 --- a/tests/graph_integration_test.rs +++ b/tests/graph_integration_test.rs @@ -19,7 +19,7 @@ fn write_graph_config( let output_dir = dir.path().join("dist"); let config_path = dir.path().join("renderflow.yaml"); let config = format!( - "input: \"{}\"\noutput_dir: \"{}\"\ntransforms: \"{}\"\n", + "outputs:\n - type: html\n - type: pdf\n - type: docx\ninput: \"{}\"\noutput_dir: \"{}\"\ntransforms: \"{}\"\n", input_path.display(), output_dir.display(), transforms_path.display(), @@ -39,6 +39,11 @@ fn run_graph_build(config_path: &Path, args: &[&str]) -> std::process::Output { .expect("failed to execute renderflow") } +fn run_evidence(output_dir: &Path) -> String { + fs::read_to_string(output_dir.join("renderflow-run.json")) + .unwrap_or_else(|error| format!("")) +} + #[test] fn test_graph_single_node_execution_builds_output() { let dir = tempfile::tempdir().expect("failed to create temp dir"); @@ -52,12 +57,12 @@ transforms: program: python3 args: - -c - - "from pathlib import Path; import sys; Path(sys.argv[2]).write_text(Path(sys.argv[1]).read_text() + '->html')" + - "from pathlib import Path; import sys; Path(sys.argv[2]).write_text('

' + Path(sys.argv[1]).read_text() + '

')" - "{input}" - "{output}" from: markdown to: html - cost: 1.0 + cost: 0.1 quality: 1.0 "#, ); @@ -65,12 +70,13 @@ transforms: let output = run_graph_build(&config_path, &["--target", "html"]); assert!( output.status.success(), - "graph build should succeed, stderr: {}", - String::from_utf8_lossy(&output.stderr) + "graph build should succeed, stderr: {}\nrun evidence: {}", + String::from_utf8_lossy(&output.stderr), + run_evidence(&output_dir) ); let html = fs::read_to_string(output_dir.join("doc.html")).expect("missing html output"); - assert_eq!(html, "hello->html"); + assert_eq!(html, "

hello

"); } #[test] @@ -86,56 +92,63 @@ transforms: program: python3 args: - -c - - "from pathlib import Path; import sys; Path(sys.argv[2]).write_text(Path(sys.argv[1]).read_text() + '->html')" + - "from pathlib import Path; import sys; Path(sys.argv[2]).write_text('

' + Path(sys.argv[1]).read_text() + '

')" - "{input}" - "{output}" from: markdown to: html - cost: 1.0 + cost: 0.1 quality: 1.0 - name: html-to-pdf program: python3 args: - -c - - "from pathlib import Path; import sys; Path(sys.argv[2]).write_text(Path(sys.argv[1]).read_text() + '->pdf')" + - "from pathlib import Path; import sys; Path(sys.argv[2]).write_text('%PDF-1.4' + Path(sys.argv[1]).read_text() + '%%EOF')" - "{input}" - "{output}" from: html to: pdf - cost: 1.0 + cost: 0.1 quality: 1.0 - name: html-to-docx program: python3 args: - -c - - "from pathlib import Path; import sys; Path(sys.argv[2]).write_text(Path(sys.argv[1]).read_text() + '->docx')" + - "from pathlib import Path; import sys; Path(sys.argv[2]).write_text('PK' + chr(5) + chr(6) + chr(0) * 18 + Path(sys.argv[1]).read_text() + '->docx')" - "{input}" - "{output}" from: html to: docx - cost: 1.0 + cost: 0.1 quality: 1.0 "#, ); - let output = run_graph_build(&config_path, &["--all"]); + let output = run_graph_build(&config_path, &[]); assert!( output.status.success(), - "graph build should succeed, stderr: {}", - String::from_utf8_lossy(&output.stderr) + "graph build should succeed, stderr: {}\nrun evidence: {}", + String::from_utf8_lossy(&output.stderr), + run_evidence(&output_dir) ); assert_eq!( fs::read_to_string(output_dir.join("doc.html")).expect("missing html output"), - "start->html" + "

start

" ); assert_eq!( fs::read_to_string(output_dir.join("doc.pdf")).expect("missing pdf output"), - "start->html->pdf" + "%PDF-1.4

start

%%EOF" ); - assert_eq!( - fs::read_to_string(output_dir.join("doc.docx")).expect("missing docx output"), - "start->html->docx" + let docx_contents = fs::read_to_string(output_dir.join("doc.docx")) + .expect("missing UTF-8-compatible DOCX envelope output"); + assert!( + docx_contents.starts_with("PK\u{5}\u{6}"), + "DOCX output should begin with a ZIP envelope" + ); + assert!( + docx_contents.ends_with("

start

->docx"), + "DOCX output should preserve the shared HTML intermediate" ); } @@ -154,13 +167,13 @@ transforms: program: python3 args: - -c - - "from pathlib import Path; import sys; counter = Path(sys.argv[3]); count = int(counter.read_text()) + 1 if counter.exists() else 1; counter.write_text(str(count)); Path(sys.argv[2]).write_text(Path(sys.argv[1]).read_text() + '->html')" + - "from pathlib import Path; import sys; counter = Path(sys.argv[3]); count = int(counter.read_text()) + 1 if counter.exists() else 1; counter.write_text(str(count)); Path(sys.argv[2]).write_text('

' + Path(sys.argv[1]).read_text() + '

')" - "{{input}}" - "{{output}}" - "{}" from: markdown to: html - cost: 1.0 + cost: 0.1 quality: 1.0 "#, counter_path.display() @@ -170,8 +183,9 @@ transforms: let first = run_graph_build(&config_path, &["--target", "html"]); assert!( first.status.success(), - "first graph build should succeed, stderr: {}", - String::from_utf8_lossy(&first.stderr) + "first graph build should succeed, stderr: {}\nrun evidence: {}", + String::from_utf8_lossy(&first.stderr), + run_evidence(&output_dir) ); let second = run_graph_build(&config_path, &["--target", "html"]); @@ -185,14 +199,14 @@ transforms: assert_eq!(count.trim(), "1", "cached graph node should not re-execute"); assert_eq!( fs::read_to_string(output_dir.join("doc.html")).expect("missing html output"), - "cache-me->html" + "

cache-me

" ); } #[test] fn test_graph_error_propagation_surfaces_transform_failure() { let dir = tempfile::tempdir().expect("failed to create temp dir"); - let (config_path, _output_dir, _) = write_graph_config( + let (config_path, output_dir, _) = write_graph_config( &dir, "doc.md", "boom", @@ -202,12 +216,12 @@ transforms: program: python3 args: - -c - - "from pathlib import Path; import sys; Path(sys.argv[2]).write_text(Path(sys.argv[1]).read_text() + '->html')" + - "from pathlib import Path; import sys; Path(sys.argv[2]).write_text('

' + Path(sys.argv[1]).read_text() + '

')" - "{input}" - "{output}" from: markdown to: html - cost: 1.0 + cost: 0.1 quality: 1.0 - name: html-to-pdf program: python3 @@ -218,7 +232,7 @@ transforms: - "{output}" from: html to: pdf - cost: 1.0 + cost: 0.1 quality: 1.0 "#, ); @@ -226,14 +240,14 @@ transforms: let output = run_graph_build(&config_path, &["--target", "pdf"]); assert!(!output.status.success(), "graph build should fail"); - let stderr = String::from_utf8_lossy(&output.stderr); + let evidence = run_evidence(&output_dir); assert!( - stderr.contains("Graph execution failed"), - "stderr should contain graph execution context, got: {stderr}" + evidence.contains("execution.transform_failed"), + "run evidence should contain graph execution context, got: {evidence}" ); assert!( - stderr.contains("intentional graph failure"), - "stderr should include underlying transform failure, got: {stderr}" + evidence.contains("intentional graph failure"), + "run evidence should include underlying transform failure, got: {evidence}" ); }