From be12f0835c08e575bd0985245a1ecd79828c0c8b Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Tue, 1 Sep 2026 10:52:04 -0400 Subject: [PATCH 1/8] refactor(planner): unify canonical execution lifecycle --- crates/renderflow-core/src/adapters/mod.rs | 1 + .../renderflow-core/src/adapters/strategy.rs | 233 +++ crates/renderflow-core/src/app.rs | 8 +- crates/renderflow-core/src/cli.rs | 11 +- crates/renderflow-core/src/commands/build.rs | 1690 +---------------- crates/renderflow-core/src/commands/graph.rs | 79 +- .../src/commands/graph_build.rs | 204 -- .../renderflow-core/src/commands/inspect.rs | 81 +- crates/renderflow-core/src/commands/mod.rs | 1 - crates/renderflow-core/src/commands/watch.rs | 63 +- crates/renderflow-core/src/config.rs | 21 +- crates/renderflow-core/src/deps.rs | 220 --- crates/renderflow-core/src/files.rs | 90 - .../renderflow-core/src/graph/dag_executor.rs | 33 +- .../src/graph/execution_plan.rs | 7 + crates/renderflow-core/src/graph/mod.rs | 46 +- .../src/graph/transform_edge.rs | 13 + crates/renderflow-core/src/incremental.rs | 503 ----- crates/renderflow-core/src/lib.rs | 5 +- crates/renderflow-core/src/pipeline/mod.rs | 3 - .../renderflow-core/src/pipeline/pipeline.rs | 476 +---- crates/renderflow-core/src/pipeline/step.rs | 13 - .../src/pipeline/strategy_step.rs | 310 --- crates/renderflow-core/src/planning.rs | 1231 ++++++++++++ crates/renderflow-core/src/sdk.rs | 165 +- crates/renderflow-core/src/spec.rs | 14 +- crates/renderflow-core/src/template.rs | 394 ---- crates/renderflow-core/src/toolchain.rs | 9 +- .../src/transforms/yaml_loader.rs | 3 +- .../tests/canonical_planner.rs | 91 + tests/cli_tests.rs | 68 +- tests/common/mod.rs | 19 + 32 files changed, 1911 insertions(+), 4194 deletions(-) create mode 100644 crates/renderflow-core/src/adapters/strategy.rs delete mode 100644 crates/renderflow-core/src/commands/graph_build.rs delete mode 100644 crates/renderflow-core/src/deps.rs delete mode 100644 crates/renderflow-core/src/files.rs delete mode 100644 crates/renderflow-core/src/incremental.rs delete mode 100644 crates/renderflow-core/src/pipeline/step.rs delete mode 100644 crates/renderflow-core/src/pipeline/strategy_step.rs create mode 100644 crates/renderflow-core/src/planning.rs delete mode 100644 crates/renderflow-core/src/template.rs create mode 100644 crates/renderflow-core/tests/canonical_planner.rs diff --git a/crates/renderflow-core/src/adapters/mod.rs b/crates/renderflow-core/src/adapters/mod.rs index 9fe7961..735e676 100644 --- a/crates/renderflow-core/src/adapters/mod.rs +++ b/crates/renderflow-core/src/adapters/mod.rs @@ -1 +1,2 @@ pub mod command; +pub mod strategy; diff --git a/crates/renderflow-core/src/adapters/strategy.rs b/crates/renderflow-core/src/adapters/strategy.rs new file mode 100644 index 0000000..e95374e --- /dev/null +++ b/crates/renderflow-core/src/adapters/strategy.rs @@ -0,0 +1,233 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::artifact::{ + Artifact, ArtifactDescriptor, ArtifactStorageClass, ArtifactStore, ArtifactTransform, +}; +use crate::assets::normalize_asset_paths; +use crate::config::OutputType; +use crate::graph::Format; +use crate::input_format::InputFormat; +use crate::pipeline::Pipeline; +use crate::strategies::{select_strategy, RenderContext}; + +/// Artifact-native compatibility adapter for the mature document/image/audio +/// output strategies. The application planner registers this adapter as a graph +/// capability; callers never dispatch to a family-specific top-level pipeline. +pub struct StrategyArtifactTransform { + from: Format, + to: Format, + output_type: OutputType, + template: Option, + profile: Option, + variables: HashMap, + source_asset_root: Option, + cache_identity: String, +} + +impl StrategyArtifactTransform { + pub fn new( + from: Format, + to: Format, + template: Option, + profile: Option, + variables: BTreeMap, + source_asset_root: Option, + ) -> Result { + let output_type = output_type_for_format(to).ok_or_else(|| { + anyhow::anyhow!( + "format '{}' is not implemented by a built-in output strategy", + to + ) + })?; + let variables: HashMap = variables.into_iter().collect(); + let mut identity_variables: Vec<_> = variables.iter().collect(); + identity_variables.sort_by(|left, right| left.0.cmp(right.0)); + let cache_identity = format!( + "renderflow.strategy-adapter/v1;from={from};to={to};template={template:?};profile={profile:?};source_root={:?};variables={identity_variables:?}", + source_asset_root + ); + Ok(Self { + from, + to, + output_type, + template, + profile, + variables, + source_asset_root, + cache_identity, + }) + } + + fn prepare_document_input( + &self, + input: &Artifact, + store: &ArtifactStore, + work_dir: &Path, + ) -> Result { + let text = store.read_text(input).with_context(|| { + format!( + "built-in document adapter requires UTF-8 input for '{}'", + self.from + ) + })?; + let normalized = if let Some(root) = &self.source_asset_root { + normalize_asset_paths(&text, root)?.into_owned() + } else { + text + }; + let pipeline = Pipeline::with_standard_transforms(&self.variables, &self.output_type); + let transformed = pipeline + .run_transforms(normalized) + .context("built-in document transform phase failed")?; + let path = work_dir.join(format!("input.{}", self.from)); + fs::write(&path, transformed).with_context(|| { + format!( + "failed to stage built-in document input '{}'", + path.display() + ) + })?; + Ok(path) + } +} + +impl ArtifactTransform for StrategyArtifactTransform { + fn name(&self) -> &str { + "renderflow.strategy-adapter" + } + + fn cache_identity(&self) -> String { + self.cache_identity.clone() + } + + fn apply( + &self, + input: &Artifact, + output_format: Format, + store: &ArtifactStore, + ) -> Result { + if output_format != self.to { + anyhow::bail!( + "strategy adapter planned '{}' but executor requested '{}'", + self.to, + output_format + ); + } + + let work_dir = tempfile::tempdir_in(store.temporary_directory()) + .context("failed to create strategy adapter work directory")?; + let document_input = document_input_format(self.from); + let input_path = if document_input.is_some() { + self.prepare_document_input(input, store, work_dir.path())? + } else { + store.payload_path(input)? + }; + let output_path = work_dir.path().join(format!("output.{}", self.to)); + let strategy = select_strategy( + &self.output_type, + self.template.as_deref(), + "templates", + self.profile.as_deref(), + )?; + let input_path_string = input_path + .to_str() + .context("strategy input path contains non-UTF8 characters")?; + let output_path_string = output_path + .to_str() + .context("strategy output path contains non-UTF8 characters")?; + let context = RenderContext { + input_path: input_path_string, + input_format: document_input.unwrap_or_default(), + output_path: output_path_string, + variables: &self.variables, + dry_run: false, + }; + strategy.render(&context).with_context(|| { + format!( + "built-in strategy adapter failed for '{}' -> '{}'", + self.from, self.to + ) + })?; + if !output_path.is_file() { + anyhow::bail!( + "built-in strategy '{}' -> '{}' completed without producing '{}'", + self.from, + self.to, + output_path.display() + ); + } + store.import_path( + &output_path, + ArtifactDescriptor::for_format(output_format, ArtifactStorageClass::Intermediate) + .with_source(input.id().clone()) + .with_metadata("renderflow.adapter", "builtin.strategy") + .with_metadata("renderflow.from", self.from.to_string()) + .with_metadata("renderflow.to", self.to.to_string()), + ) + } +} + +pub fn document_input_format(format: Format) -> Option { + match format { + Format::Markdown => Some(InputFormat::Markdown), + Format::Docx => Some(InputFormat::Docx), + Format::Html => Some(InputFormat::Html), + Format::Epub => Some(InputFormat::Epub), + Format::Rst => Some(InputFormat::Rst), + Format::Latex => Some(InputFormat::Latex), + _ => None, + } +} + +pub fn output_type_for_format(format: Format) -> Option { + match format { + Format::Html => Some(OutputType::Html), + Format::Pdf => Some(OutputType::Pdf), + Format::Docx => Some(OutputType::Docx), + _ => { + let value = format.to_string(); + if let Ok(audio) = value.parse::() { + if audio.supports_encoding() { + return Some(OutputType::Audio(audio)); + } + } + if let Ok(image) = value.parse::() { + if image.supports_encoding() { + return Some(OutputType::Image(image)); + } + } + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_builtin_document_and_media_outputs() { + assert_eq!(output_type_for_format(Format::Html), Some(OutputType::Html)); + assert!(matches!( + output_type_for_format(Format::Png), + Some(OutputType::Image(_)) + )); + assert!(matches!( + output_type_for_format(Format::Flac), + Some(OutputType::Audio(_)) + )); + assert!(output_type_for_format(Format::Svg).is_none()); + } + + #[test] + fn document_input_mapping_is_explicit() { + assert_eq!( + document_input_format(Format::Markdown), + Some(InputFormat::Markdown) + ); + assert_eq!(document_input_format(Format::Png), None); + } +} diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 553bc9f..8e3542d 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -33,13 +33,7 @@ pub fn run_cli(cli: Cli) -> Result<()> { target, all, }) => { - if let Some(ref target_format) = target { - commands::graph_build::run_target(&config, target_format, dry_run, optimization)? - } else if all { - commands::graph_build::run_all(&config, dry_run, optimization)? - } else { - commands::build::run(&config, dry_run, optimization)? - } + commands::build::run_selection(&config, dry_run, optimization, target.as_deref(), all)? } Some(Commands::Watch { config, debounce }) => commands::watch::run(&config, debounce)?, Some(Commands::Audit) => commands::audit::run()?, diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index e4fba9f..aea6b48 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -77,15 +77,14 @@ pub enum Commands { #[arg(long, value_name = "MODE")] optimization: Option, - /// Build only the specified output format using graph-based path resolution. - /// The format must be reachable from the input format via the configured transforms. - /// Requires a 'transforms' key in the config file. - /// Cannot be combined with --all. + /// Build only the specified output format using the canonical capability graph. + /// Built-in document/image/audio capabilities and optional configured transforms are + /// resolved through the same planner. Cannot be combined with --all. #[arg(long, value_name = "FORMAT", conflicts_with = "all")] target: Option, - /// Build all reachable output formats using graph-based path resolution. - /// Requires a 'transforms' key in the config file. + /// Build all policy-allowed output formats reachable through the canonical capability graph. + /// Built-in capabilities and optional configured transforms participate equally. /// Cannot be combined with --target. #[arg(long, conflicts_with = "target")] all: bool, diff --git a/crates/renderflow-core/src/commands/build.rs b/crates/renderflow-core/src/commands/build.rs index b60a673..6a0575b 100644 --- a/crates/renderflow-core/src/commands/build.rs +++ b/crates/renderflow-core/src/commands/build.rs @@ -1,1679 +1,73 @@ -use anyhow::{Context, Result}; -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use itertools::Itertools as _; -use rayon::prelude::*; -use std::collections::HashMap; -use std::fs; -use std::path::Path; -use tracing::{debug, info, warn}; +use anyhow::Result; +use tracing::info; -use crate::assets::normalize_asset_paths; -use crate::audio::is_audio_path; -use crate::cache::{ - compute_input_hash, compute_output_hash, load_cache, load_output_cache, save_cache, - save_output_cache, -}; -use crate::config::{load_config, OutputType}; -use crate::deps::{ - validate_audio_dependencies, validate_dependencies, validate_image_dependencies, -}; -use crate::files::{ensure_output_dir, validate_input}; -use crate::image::is_image_path; -use crate::incremental::{ - build_output_dependencies, load_dependency_map, save_dependency_map, FileDependency, -}; use crate::optimization::OptimizationMode; -use crate::pipeline::{Pipeline, StrategyStep}; -use crate::strategies::{select_strategy, RenderContext}; -use crate::template::{init_tera, validate_templates}; -use crate::transforms::{load_transforms_from_yaml, FailureMode, TransformRegistry}; +use crate::planning::{execute, resolve, PlanningRequest}; -/// Run the full build pipeline. -/// -/// Transforms fail fast: the first transform error aborts the build and returns an error. -/// -/// `optimization` overrides the mode from the config file when `Some`. +/// 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_impl(config_path, dry_run, false, optimization) + run_selection(config_path, dry_run, optimization, None, false) } -/// Run the build pipeline in resilient mode. +/// Compatibility entrypoint for watch mode. /// -/// Like [`run`], but transform failures are logged and skipped rather than -/// aborting the build. Suitable for watch-mode rebuilds where a transient -/// transform error should not stop the file watcher. +/// Watch mode itself owns resilience by keeping the watcher alive after an +/// execution error; individual builds still use the exact same canonical +/// planner/executor and fail atomically. pub fn run_resilient(config_path: &str) -> Result<()> { - run_impl(config_path, false, true, None) -} - -/// Each element produced by the parallel render loop: -/// (format_name, output_path, render_result, optional_output_hash, optional_file_deps). -/// -/// The optional hash and deps are `Some` only for successful renders (including -/// outputs that were skipped as up-to-date) and are used to update the output -/// cache and dependency map after all formats have finished. -type RenderResult = ( - String, - String, - Result<()>, - Option, - Option>, -); - -/// Progress-bar symbols used in render status messages. -const SYMBOL_SKIP: &str = "↩"; -const SYMBOL_OK: &str = "✔"; -const SYMBOL_FAIL: &str = "✘"; - -/// Create and register a per-output spinner progress bar on `mp`. -/// -/// Pre-creating bars before the rayon parallel section avoids calling -/// [`MultiProgress::add`] from multiple threads, which would acquire an -/// internal mutex on every call and could serialise parallel workers. -fn create_output_bar(mp: &MultiProgress, format_label: &str) -> ProgressBar { - let bar = mp.add(ProgressBar::new_spinner()); - bar.set_style( - ProgressStyle::with_template(" {spinner:.blue} [{prefix:.bold.cyan}] {msg}") - .expect("hardcoded per-output progress bar template is valid"), - ); - bar.set_prefix(format_label.to_string()); - bar.set_message("queued"); - bar + run(config_path, false, None) } -fn run_impl( +/// Run the canonical lifecycle with optional CLI target overrides. +pub(crate) fn run_selection( config_path: &str, dry_run: bool, - resilient: bool, optimization: Option, + target: Option<&str>, + all_reachable: bool, ) -> Result<()> { if dry_run { - info!("Dry-run mode enabled — no files will be created and no commands will be executed"); - } - info!("Running build pipeline"); - - let config = load_config(config_path)?; - info!("Loaded config successfully"); - - let canonical_input = validate_input(&config.input)?; - - // Dispatch to the audio pipeline when the input is an audio file. - if is_audio_path(&config.input) { - return run_audio_build(config_path, &config, &canonical_input, dry_run); + info!( + "Dry-run mode enabled — planning and bounded provider probes may run, but transforms and output writes are disabled" + ); } - // Dispatch to the image pipeline when the input is an image file. - if is_image_path(&config.input) { - return run_image_build(config_path, &config, &canonical_input, dry_run); + let mut request = PlanningRequest::from_path(config_path); + if let Some(optimization) = optimization { + request = request.with_optimization(optimization); } - - // Read the raw config file content so it can be included in the transform - // cache hash. Any change to the config file (not only to `variables`) will - // then invalidate the cached transform results and force a fresh pipeline run. - let config_content = fs::read_to_string(config_path) - .with_context(|| format!("Failed to re-read config file for hashing: {}", config_path))?; - - // CLI flag takes precedence over config file; fall back to config value. - let opt_mode = optimization.unwrap_or(config.optimization); - info!(optimization = %opt_mode, "Using optimization mode"); - - // Validate required system dependencies after confirming the config and input - // are accessible. Skip in dry-run mode because no external tools are invoked. - if !dry_run { - let pdf_requested = config - .outputs - .iter() - .any(|o| o.output_type == OutputType::Pdf); - validate_dependencies(pdf_requested)?; + if let Some(target) = target { + request = request.with_target(target); + } else if all_reachable { + request = request.with_all_reachable(); } - let input_dir = canonical_input.parent().ok_or_else(|| { - anyhow::anyhow!( - "Could not determine the parent directory of input file '{}'. \ - Please ensure the input path is a valid file path.", - canonical_input.display() - ) - })?; - let content = fs::read_to_string(&canonical_input) - .with_context(|| format!("Failed to read input file: {}", canonical_input.display()))?; - // Resolve and validate all asset paths referenced in the document. - // The normalized content (with canonical absolute paths) is passed through - // the pipeline so transforms and strategies operate on the actual file content. - let normalized_content = normalize_asset_paths(&content, input_dir)?; - info!("Asset paths validated successfully"); - - let output_dir = if dry_run { - let path = std::path::PathBuf::from(&config.output_dir); - info!( - "[DRY RUN] Would create output directory: {}", - path.display() - ); - path - } else { - ensure_output_dir(&config.output_dir)? - }; - - let input_stem = Path::new(&config.input) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("document"); - - let tera = init_tera("templates")?; - let template_count = tera.get_template_names().count(); - info!( - "Tera template engine initialised with {} template(s)", - template_count - ); - - // Validate all configured templates early, before any pipeline execution, - // so that missing templates are detected immediately with a clear error - // rather than discovered later during rendering. - validate_templates(&config.outputs, "templates")?; - - if config.outputs.is_empty() { - warn!("No output formats configured — nothing to build"); - return Ok(()); - } + let resolved = resolve(request)?; info!( - "Selected outputs: {}", - config - .outputs + source = %resolved.source_format(), + targets = %resolved + .target_formats() .iter() - .map(|o| o.output_type.to_string()) - .join(", ") + .map(ToString::to_string) + .collect::>() + .join(", "), + depth = resolved.plan().metadata.execution_depth, + waves = resolved.plan().metadata.execution_waves, + "Resolved canonical execution plan" ); - // One tick for the transform phase plus one tick per output format for rendering. - let total_steps = 1 + config.outputs.len() as u64; - let mp = MultiProgress::new(); - let pb = mp.add(ProgressBar::new(total_steps)); - pb.set_style( - ProgressStyle::with_template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}") - .expect("hardcoded progress bar template is valid") - .progress_chars("█▓░"), - ); - - // Pre-create one spinner per output format *before* the parallel rendering - // section. See [`create_output_bar`] for the rationale. - let output_bars: Vec = config - .outputs - .iter() - .map(|output| create_output_bar(&mp, &output.output_type.to_string())) - .collect(); - - // Transforms are run once per output format (serially, before parallel rendering) - // because some transforms are format-specific. In particular, EmojiTransform skips - // replacement for HTML (which renders emoji natively) but applies it for PDF, DOCX, - // and other formats. A format-keyed cache avoids redundant work across builds. - let base_input_hash = - compute_input_hash(&normalized_content, &config_content, &config.variables); - let cache_path = output_dir.join(".renderflow-cache.json"); - // Always attempt to read the cache; load_cache handles missing/corrupt files - // gracefully. Only write back to disk in non-dry-run mode. - let mut transform_cache = load_cache(&cache_path); - - // Load command-based transforms from the YAML file specified in config, if any. - // These are applied after the standard built-in pipeline (emoji, variables, syntax). - let command_registry: Option = if let Some(ref path) = config.transforms { - info!(path = %path, "Loading command-based transforms"); - let mode = if resilient { - FailureMode::ContinueOnError - } else { - FailureMode::FailFast - }; - Some( - load_transforms_from_yaml(path) - .with_context(|| { - format!("Failed to load command-based transforms from '{}'", path) - })? - .with_failure_mode(mode), - ) - } else { - None - }; - - pb.set_message(if dry_run { - "[DRY RUN] Applying transforms" - } else { - "Applying transforms" - }); - let mut format_transformed: HashMap = HashMap::new(); - for output in &config.outputs { - let format = &output.output_type; - let format_str = format.to_string(); - // Include the output format in the cache key so that HTML and PDF - // transformations are cached independently. - let hash_key = format!("{base_input_hash}-{format_str}"); - - let transformed = if let Some(cached) = transform_cache.get(&hash_key) { - debug!(hash = %hash_key, format = %format_str, "Transform cache hit — skipping transforms"); - cached.to_string() - } else { - debug!(hash = %hash_key, format = %format_str, "Transform cache miss — running transforms"); - let pipeline = if resilient { - Pipeline::with_standard_transforms_resilient(&config.variables, format) - } else { - Pipeline::with_standard_transforms(&config.variables, format) - }; - let standard_output = pipeline - .run_transforms(normalized_content.as_ref().to_owned()) - .with_context(|| { - format!("Transform pipeline failed for format: {format_str}; aborting build") - })?; - // Apply command-based transforms (e.g. ImageMagick, Pandoc) if configured. - if let Some(ref cmd_registry) = command_registry { - debug!(format = %format_str, "Applying command-based transforms"); - cmd_registry.apply_all(standard_output).with_context(|| { - format!("Command transform pipeline failed for format: {format_str}") - })? - } else { - standard_output - } - }; - - if !dry_run { - transform_cache.insert(hash_key, transformed.clone()); - } - format_transformed.insert(format_str, transformed); - } - pb.inc(1); - - if !dry_run { - if let Err(e) = save_cache(&transform_cache, &cache_path) { - warn!(error = %e, "Failed to save transform cache"); - } + let result = execute(resolved, dry_run)?; + if dry_run { + // stdout is reserved for machine-readable plan evidence; tracing remains on stderr. + println!("{}", serde_json::to_string_pretty(&result.plan)?); } - - // Load the output cache so that individual render steps can be skipped when - // their inputs (transformed content + output type + template) have not changed. - let output_cache_path = output_dir.join(".renderflow-output-cache.json"); - let mut output_cache = load_output_cache(&output_cache_path); - - // Load the dependency map so that file-level dependencies are tracked across - // builds. This records which specific input files (source document, config, - // templates) produced each output, enabling precise change attribution. - let dep_map_path = output_dir.join(".renderflow-deps.json"); - let mut dep_map = load_dependency_map(&dep_map_path); - - // Output formats are rendered concurrently via rayon. Each output has its own - // pre-created progress bar so workers never block each other updating the display. - // Failures are captured per-output and aggregated at the end — a single format - // failure does not abort sibling renders. - // - // Each element is (format_name, output_path, result, Option, - // Option). The optional hash and deps are Some only when the - // render succeeded (or was skipped as up-to-date), and are used to update - // the output cache and dependency map after all formats finish. - let render_results: Vec = config - .outputs - .par_iter() - .zip(output_bars.par_iter()) - .map(|(output, bar)| { - let format = output.output_type.clone(); - let format_str = format.to_string(); - let output_path = format!("{}/{}.{}", output_dir.display(), input_stem, format); - info!(format = %format, output = %output_path, template = ?output.template, "Running pipeline for format"); - - // format_transformed is populated for every configured output in the serial loop above. - let transformed = format_transformed - .get(&format_str) - .expect("format_str must be present in format_transformed") - .clone(); - + for output in &result.outputs { if dry_run { - info!("[DRY RUN] Would render {} output to: {}", format, output_path); - bar.finish_with_message(format!("[DRY RUN] {SYMBOL_OK} Would render to {}", output_path)); - pb.inc(1); - pb.println(format!("[DRY RUN] Would write output to: {}", output_path)); - (format_str, output_path, Ok(()), None, None) + info!("[DRY RUN] Planned output: {}", output); } else { - // Build the list of file dependencies for this output. This is - // used to populate the dependency map after the render completes. - let template_path = output.template.as_deref().map(|name| { - Path::new("templates").join(name) - }); - let file_deps = build_output_dependencies( - &canonical_input, - Path::new(config_path), - template_path.as_deref(), - ); - - // Compute a hash of all inputs that determine this output's content. - // If the stored hash matches and the output file already exists, pandoc - // can be skipped entirely. The template file content (not just its - // name) is included so that edits to a template file invalidate the - // output cache even when the template path is unchanged. - // - // Additionally, log the file-level dependency status (from the - // dependency map) at DEBUG level so that the precise reason a rebuild - // was or was not triggered is visible in verbose output. - debug!( - output = %output_path, - dep_map_up_to_date = dep_map.is_output_up_to_date(&output_path, &file_deps), - recorded_deps = ?dep_map.dependencies_for(&output_path), - "Incremental dependency check" - ); - let template_content = output.template.as_deref().and_then(|name| { - let path = Path::new("templates").join(name); - match fs::read_to_string(&path) { - Ok(content) => Some(content), - Err(e) => { - warn!( - template = %name, - path = %path.display(), - error = %e, - "Failed to read template file for cache hash; template changes may not invalidate cache" - ); - None - } - } - }); - let output_hash = compute_output_hash( - &transformed, - &format_str, - output.template.as_deref(), - template_content.as_deref(), - ); - - if Path::new(&output_path).exists() - && output_cache.get(&output_path) == Some(output_hash.as_str()) - { - debug!(hash = %output_hash, output = %output_path, "Output cache hit — skipping render"); - info!("Skipping {} render (unchanged)", format); - bar.finish_with_message(format!("{SYMBOL_SKIP} unchanged: {}", output_path)); - pb.inc(1); - pb.println(format!("{SYMBOL_SKIP} Skipping {} output (unchanged): {}", format, output_path)); - return (format_str, output_path, Ok(()), Some(output_hash), Some(file_deps)); - } - - bar.set_message("rendering…"); - let result = (|| -> Result<()> { - debug!(hash = %output_hash, output = %output_path, "Output cache miss — rendering output"); - let strategy = select_strategy(&format, output.template.as_deref(), "templates", output.profile.as_deref())?; - let mut pipeline = Pipeline::new(); - pipeline.add_step(Box::new(StrategyStep::new(strategy, &output_path, config.input_format(), config.variables.clone(), false))); - - pipeline.run(transformed)?; - Ok(()) - })(); - - let new_hash = if result.is_ok() { Some(output_hash) } else { None }; - let new_deps = if result.is_ok() { Some(file_deps) } else { None }; - - match &result { - Ok(_) => { - bar.finish_with_message(format!("{SYMBOL_OK} {}", output_path)); - pb.inc(1); - pb.println(format!("{SYMBOL_OK} Output written to: {}", output_path)); - info!(output = %output_path, "Pipeline completed for format: {}", format); - } - Err(e) => { - warn!(format = %format, error = %e, "Rendering failed for output format"); - bar.finish_with_message(format!("{SYMBOL_FAIL} failed: {:#}", e)); - pb.inc(1); - pb.println(format!("{SYMBOL_FAIL} Failed to render {} output: {:#}", format, e)); - } - } - (format_str, output_path, result, new_hash, new_deps) - } - }) - .collect(); - - // Persist updated output cache and dependency map for all successful renders - // (including skipped ones). - if !dry_run { - for (_, output_path, result, new_hash, new_deps) in &render_results { - if result.is_ok() { - if let Some(hash) = new_hash { - output_cache.insert(output_path.clone(), hash.clone()); - } - if let Some(deps) = new_deps { - dep_map.record(output_path.clone(), deps.clone()); - } - } - } - if let Err(e) = save_output_cache(&output_cache, &output_cache_path) { - warn!(error = %e, "Failed to save output cache"); - } - if let Err(e) = save_dependency_map(&dep_map, &dep_map_path) { - warn!(error = %e, "Failed to save dependency map"); + info!("✔ Output written to: {}", output); } } - - let failed_outputs: Vec<(String, anyhow::Error)> = render_results - .into_iter() - .filter_map(|(fmt, _, r, _, _)| r.err().map(|e| (fmt, e))) - .collect(); - - if dry_run { - pb.finish_with_message(format!( - "[DRY RUN] {SYMBOL_OK} Dry-run complete — no output written" - )); - } else if failed_outputs.is_empty() { - pb.finish_with_message(format!("{SYMBOL_OK} Build complete")); - } else { - pb.finish_with_message(format!( - "⚠ Build completed with {} failure(s)", - failed_outputs.len() - )); - let messages: Vec = failed_outputs - .iter() - .map(|(fmt, err)| format!(" - {}: {:#}", fmt, err)) - .collect(); - anyhow::bail!( - "One or more output formats failed to render:\n{}", - messages.join("\n") - ); - } - Ok(()) } - -/// Audio-specific build path. -/// -/// Audio input files are binary; they skip the text transform pipeline -/// entirely and are converted directly via FFmpeg. -fn run_audio_build( - config_path: &str, - config: &crate::config::Config, - canonical_input: &std::path::Path, - dry_run: bool, -) -> Result<()> { - info!("Audio input detected — using audio conversion pipeline"); - - if !dry_run { - validate_audio_dependencies()?; - } - - let output_dir = if dry_run { - let path = std::path::PathBuf::from(&config.output_dir); - info!( - "[DRY RUN] Would create output directory: {}", - path.display() - ); - path - } else { - ensure_output_dir(&config.output_dir)? - }; - - let input_stem = canonical_input - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("audio"); - - let input_path_str = canonical_input.to_str().ok_or_else(|| { - anyhow::anyhow!( - "Input path contains invalid UTF-8: {}", - canonical_input.display() - ) - })?; - - let total_outputs = config.outputs.len() as u64; - let mp = MultiProgress::new(); - let pb = mp.add(ProgressBar::new(total_outputs)); - pb.set_style( - ProgressStyle::with_template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}") - .expect("hardcoded progress bar template is valid") - .progress_chars("█▓░"), - ); - - let output_bars: Vec = config - .outputs - .iter() - .map(|output| create_output_bar(&mp, &output.output_type.to_string())) - .collect(); - - let render_results: Vec<(String, String, Result<()>)> = config - .outputs - .iter() - .zip(output_bars.iter()) - .map(|(output, bar)| { - let format_str = output.output_type.to_string(); - - // Build the output path. When a profile is given, embed it in the - // stem so that multiple profiles of the same format don't collide. - let output_path = if let Some(ref profile) = output.profile { - format!( - "{}/{}_{}.{}", - output_dir.display(), - input_stem, - profile, - format_str - ) - } else { - format!("{}/{}.{}", output_dir.display(), input_stem, format_str) - }; - - if dry_run { - info!( - "[DRY RUN] Would convert {} → {} (profile: {:?})", - input_path_str, - output_path, - output.profile, - ); - bar.finish_with_message(format!( - "[DRY RUN] {SYMBOL_OK} Would write to {}", - output_path - )); - pb.inc(1); - pb.println(format!("[DRY RUN] Would write output to: {}", output_path)); - return (format_str, output_path, Ok(())); - } - - bar.set_message("converting…"); - let vars = HashMap::new(); - let ctx = RenderContext { - input_path: input_path_str, - input_format: crate::input_format::InputFormat::default(), - output_path: &output_path, - variables: &vars, - dry_run: false, - }; - - let result = select_strategy( - &output.output_type, - output.template.as_deref(), - "templates", - output.profile.as_deref(), - ) - .and_then(|strategy| strategy.render(&ctx)) - .with_context(|| { - format!( - "Audio conversion failed: {} → {} (format: {}, profile: {:?}, config: {})", - input_path_str, - output_path, - format_str, - output.profile, - config_path, - ) - }); - - match &result { - Ok(_) => { - bar.finish_with_message(format!("{SYMBOL_OK} {}", output_path)); - pb.inc(1); - pb.println(format!("{SYMBOL_OK} Output written to: {}", output_path)); - info!(output = %output_path, "Audio conversion completed for format: {}", format_str); - } - Err(e) => { - warn!(format = %format_str, error = %e, "Audio conversion failed"); - bar.finish_with_message(format!("{SYMBOL_FAIL} failed: {:#}", e)); - pb.inc(1); - pb.println(format!("{SYMBOL_FAIL} Failed to convert to {}: {:#}", format_str, e)); - } - } - - (format_str, output_path, result) - }) - .collect(); - - let failed: Vec<(String, anyhow::Error)> = render_results - .into_iter() - .filter_map(|(fmt, _, r)| r.err().map(|e| (fmt, e))) - .collect(); - - if dry_run { - pb.finish_with_message(format!( - "[DRY RUN] {SYMBOL_OK} Dry-run complete — no output written" - )); - } else if failed.is_empty() { - pb.finish_with_message(format!("{SYMBOL_OK} Audio build complete")); - } else { - pb.finish_with_message(format!( - "⚠ Build completed with {} failure(s)", - failed.len() - )); - let messages: Vec = failed - .iter() - .map(|(fmt, err)| format!(" - {}: {:#}", fmt, err)) - .collect(); - anyhow::bail!( - "One or more audio conversions failed:\n{}", - messages.join("\n") - ); - } - - Ok(()) -} - -/// Image-specific build path. -/// -/// Image input files are binary; they skip the text transform pipeline -/// entirely and are converted directly via FFmpeg. -fn run_image_build( - config_path: &str, - config: &crate::config::Config, - canonical_input: &std::path::Path, - dry_run: bool, -) -> Result<()> { - info!("Image input detected — using image conversion pipeline"); - - if !dry_run { - validate_image_dependencies()?; - } - - let output_dir = if dry_run { - let path = std::path::PathBuf::from(&config.output_dir); - info!( - "[DRY RUN] Would create output directory: {}", - path.display() - ); - path - } else { - ensure_output_dir(&config.output_dir)? - }; - - let input_stem = canonical_input - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("image"); - - let input_path_str = canonical_input.to_str().ok_or_else(|| { - anyhow::anyhow!( - "Input path contains invalid UTF-8: {}", - canonical_input.display() - ) - })?; - - let total_outputs = config.outputs.len() as u64; - let mp = MultiProgress::new(); - let pb = mp.add(ProgressBar::new(total_outputs)); - pb.set_style( - ProgressStyle::with_template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}") - .expect("hardcoded progress bar template is valid") - .progress_chars("█▓░"), - ); - - let output_bars: Vec = config - .outputs - .iter() - .map(|output| create_output_bar(&mp, &output.output_type.to_string())) - .collect(); - - let render_results: Vec<(String, String, Result<()>)> = config - .outputs - .iter() - .zip(output_bars.iter()) - .map(|(output, bar)| { - let format_str = output.output_type.to_string(); - - // Build the output path. When a profile is given, embed it in the - // stem so that multiple profiles of the same format don't collide. - let output_path = if let Some(ref profile) = output.profile { - format!( - "{}/{}_{}.{}", - output_dir.display(), - input_stem, - profile, - format_str - ) - } else { - format!("{}/{}.{}", output_dir.display(), input_stem, format_str) - }; - - if dry_run { - info!( - "[DRY RUN] Would convert {} → {} (profile: {:?})", - input_path_str, - output_path, - output.profile, - ); - bar.finish_with_message(format!( - "[DRY RUN] {SYMBOL_OK} Would write to {}", - output_path - )); - pb.inc(1); - pb.println(format!("[DRY RUN] Would write output to: {}", output_path)); - return (format_str, output_path, Ok(())); - } - - bar.set_message("converting…"); - let vars = HashMap::new(); - let ctx = RenderContext { - input_path: input_path_str, - input_format: crate::input_format::InputFormat::default(), - output_path: &output_path, - variables: &vars, - dry_run: false, - }; - - let result = select_strategy( - &output.output_type, - output.template.as_deref(), - "templates", - output.profile.as_deref(), - ) - .and_then(|strategy| strategy.render(&ctx)) - .with_context(|| { - format!( - "Image conversion failed: {} → {} (format: {}, profile: {:?}, config: {})", - input_path_str, - output_path, - format_str, - output.profile, - config_path, - ) - }); - - match &result { - Ok(_) => { - bar.finish_with_message(format!("{SYMBOL_OK} {}", output_path)); - pb.inc(1); - pb.println(format!("{SYMBOL_OK} Output written to: {}", output_path)); - info!(output = %output_path, "Image conversion completed for format: {}", format_str); - } - Err(e) => { - warn!(format = %format_str, error = %e, "Image conversion failed"); - bar.finish_with_message(format!("{SYMBOL_FAIL} failed: {:#}", e)); - pb.inc(1); - pb.println(format!("{SYMBOL_FAIL} Failed to convert to {}: {:#}", format_str, e)); - } - } - - (format_str, output_path, result) - }) - .collect(); - - let failed: Vec<(String, anyhow::Error)> = render_results - .into_iter() - .filter_map(|(fmt, _, r)| r.err().map(|e| (fmt, e))) - .collect(); - - if dry_run { - pb.finish_with_message(format!( - "[DRY RUN] {SYMBOL_OK} Dry-run complete — no output written" - )); - } else if failed.is_empty() { - pb.finish_with_message(format!("{SYMBOL_OK} Image build complete")); - } else { - pb.finish_with_message(format!( - "⚠ Build completed with {} failure(s)", - failed.len() - )); - let messages: Vec = failed - .iter() - .map(|(fmt, err)| format!(" - {}: {:#}", fmt, err)) - .collect(); - anyhow::bail!( - "One or more image conversions failed:\n{}", - messages.join("\n") - ); - } - - Ok(()) -} -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::io::Write; - use std::process::Command; - use tempfile::NamedTempFile; - - fn valid_config_file() -> (NamedTempFile, tempfile::TempDir) { - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.md"); - fs::write(&input_path, "# Test\n").expect("failed to write input file"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write temp file"); - (f, dir) - } - - #[test] - #[ignore = "requires pandoc to be installed"] - fn test_build_run_succeeds() { - let (f, _dir) = valid_config_file(); - assert!(run(f.path().to_str().unwrap(), false, None).is_ok()); - } - - #[test] - fn test_build_run_missing_config() { - let result = run("/nonexistent/renderflow.yaml", false, None); - assert!(result.is_err()); - } - - #[test] - fn test_build_run_missing_input_file() { - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\ninput: \"/nonexistent/input.md\"\noutput_dir: \"{}\"\n", - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - let result = run(f.path().to_str().unwrap(), false, None); - assert!(result.is_err(), "expected error when input file is missing"); - let msg = format!("{}", result.unwrap_err()); - assert!( - msg.contains("Input file not found"), - "unexpected error: {}", - msg - ); - } - - #[test] - fn test_build_run_unsupported_format() { - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.md"); - fs::write(&input_path, "# Test\n").expect("failed to write input file"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: epub\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - let result = run(f.path().to_str().unwrap(), false, None); - assert!(result.is_err(), "expected error for unsupported format"); - let msg = format!("{}", result.unwrap_err()); - assert!( - msg.contains("not a valid output type"), - "unexpected error: {}", - msg - ); - } - - #[test] - #[ignore = "requires pandoc to be installed and a valid input file"] - fn test_build_run_with_pandoc() { - let dir = tempfile::tempdir().unwrap(); - let input_path = dir.path().join("input.md"); - fs::write(&input_path, "# Hello\n\nThis is a test.\n").unwrap(); - - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - - let mut config_file = NamedTempFile::new().unwrap(); - config_file.write_all(config_content.as_bytes()).unwrap(); - - assert!(run(config_file.path().to_str().unwrap(), false, None).is_ok()); - assert!(output_dir.join("input.html").exists()); - } - - #[test] - fn test_dry_run_succeeds_without_pandoc() { - let (f, dir) = valid_config_file(); - let output_dir = dir.path().join("dist"); - let result = run(f.path().to_str().unwrap(), true, None); - assert!(result.is_ok(), "dry-run should succeed: {:?}", result); - // No output directory should have been created in dry-run mode - assert!( - !output_dir.exists(), - "output directory must not be created in dry-run mode" - ); - } - - #[test] - fn test_dry_run_does_not_create_output_files() { - let (f, dir) = valid_config_file(); - let output_dir = dir.path().join("dist"); - run(f.path().to_str().unwrap(), true, None).expect("dry-run should not fail"); - // The dist directory and any rendered files must not exist - assert!( - !output_dir.exists(), - "output directory must not be created in dry-run mode" - ); - } - - #[test] - fn test_dry_run_missing_config_still_errors() { - let result = run("/nonexistent/renderflow.yaml", true, None); - assert!( - result.is_err(), - "dry-run with missing config should still error" - ); - } - - /// Build a config with multiple output formats for testing that transforms run once. - fn multi_output_config_file() -> (NamedTempFile, tempfile::TempDir) { - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.md"); - // Content includes emoji and a variable so transforms have real work to do - // across both the EmojiTransform and VariableSubstitutionTransform stages. - fs::write(&input_path, "# Hello 😀\n\nValue: {{greeting}}\n") - .expect("failed to write input file"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\n - type: pdf\ninput: \"{}\"\noutput_dir: \"{}\"\nvariables:\n greeting: world\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write temp file"); - (f, dir) - } - - #[test] - fn test_dry_run_multiple_outputs_succeeds() { - // Dry-run should succeed for multiple output formats without requiring - // any external tools (pandoc/tectonic). Transforms run once and the - // result is reused for each format. - let (f, dir) = multi_output_config_file(); - let output_dir = dir.path().join("dist"); - let result = run(f.path().to_str().unwrap(), true, None); - assert!( - result.is_ok(), - "dry-run with multiple outputs should succeed: {:?}", - result - ); - // No output directory should have been created in dry-run mode. - assert!( - !output_dir.exists(), - "output directory must not be created in dry-run mode" - ); - } - - #[test] - fn test_transforms_applied_once_content_consistent_across_formats() { - // Verify that transform output is consistent when multiple formats are - // configured: the same variable substitution result should appear - // regardless of how many output formats are requested. We exercise - // this indirectly by checking that a dry-run with multiple outputs - // succeeds with the same result as a single-output dry-run. - let (single_f, _single_dir) = valid_config_file(); - let (multi_f, _multi_dir) = multi_output_config_file(); - - let single_result = run(single_f.path().to_str().unwrap(), true, None); - let multi_result = run(multi_f.path().to_str().unwrap(), true, None); - - assert!( - single_result.is_ok(), - "single-output dry-run failed: {:?}", - single_result - ); - assert!( - multi_result.is_ok(), - "multi-output dry-run failed: {:?}", - multi_result - ); - } - - // ── cache integration tests ─────────────────────────────────────────────── - - /// Pre-populate the transform cache file at `output_dir/.renderflow-cache.json` - /// with the given hash → content mapping so that a subsequent build can - /// exercise cache-hit behaviour without running pandoc. - fn write_cache_file(output_dir: &std::path::Path, hash: &str, content: &str) { - fs::create_dir_all(output_dir).expect("failed to create output dir"); - let cache_path = output_dir.join(".renderflow-cache.json"); - let map: std::collections::HashMap<&str, &str> = - std::collections::HashMap::from([(hash, content)]); - let json = serde_json::to_string(&map).expect("failed to serialize cache"); - fs::write(&cache_path, json).expect("failed to write cache file"); - } - - #[test] - fn test_cache_miss_on_fresh_dry_run() { - // A dry-run with no pre-existing cache file should proceed normally - // (transforms run, no cache written). - let (f, dir) = valid_config_file(); - let output_dir = dir.path().join("dist"); - // No cache file exists — this is a fresh state. - let result = run(f.path().to_str().unwrap(), true, None); - assert!( - result.is_ok(), - "dry-run should succeed without a cache: {:?}", - result - ); - // In dry-run mode the output directory is never created. - assert!( - !output_dir.exists(), - "output directory must not be created in dry-run mode" - ); - } - - #[test] - fn test_cache_hit_uses_pre_populated_cache() { - // Pre-populate the cache with the exact hash that the build would - // compute for the input file + config + format, then run a dry-run. - // The build should detect the cache hit and skip the transform phase - // for that format. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_content = "# Test\n"; - let input_path = dir.path().join("input.md"); - fs::write(&input_path, input_content).expect("failed to write input file"); - let output_dir = dir.path().join("dist"); - - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - - // Compute the hash the same way the build command will: base hash + "-html". - // The hash now also incorporates the config file content. - let variables = std::collections::HashMap::new(); - let base_hash = - crate::cache::compute_input_hash(input_content, &config_content, &variables); - let hash_key = format!("{base_hash}-html"); - let cached_transform = "# Test (from cache)\n"; - write_cache_file(&output_dir, &hash_key, cached_transform); - - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - // The dry-run should succeed; cache hit is detected in both modes. - let result = run(f.path().to_str().unwrap(), true, None); - assert!( - result.is_ok(), - "dry-run with cache hit should succeed: {:?}", - result - ); - } - - #[test] - fn test_cache_miss_when_input_changed() { - // After changing the input content the hash changes, so the previously - // cached entry should not match and transforms must run again. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let original_content = "# Original\n"; - let input_path = dir.path().join("input.md"); - fs::write(&input_path, original_content).expect("failed to write input file"); - let output_dir = dir.path().join("dist"); - - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - - // Cache is keyed on the *original* content + config + format. - let variables = std::collections::HashMap::new(); - let old_base_hash = - crate::cache::compute_input_hash(original_content, &config_content, &variables); - let old_hash_key = format!("{old_base_hash}-html"); - write_cache_file(&output_dir, &old_hash_key, "cached result"); - - // Now change the input file — the hash will be different. - let new_content = "# Changed\n"; - fs::write(&input_path, new_content).expect("failed to write updated input"); - - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - // Dry-run still succeeds; it runs transforms because the hash misses. - let result = run(f.path().to_str().unwrap(), true, None); - assert!( - result.is_ok(), - "dry-run with cache miss should still succeed: {:?}", - result - ); - } - - #[test] - fn test_cache_miss_when_config_changed() { - // After changing the config content the hash changes, so the previously - // cached entry should not match and transforms must run again. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_content = "# Hello\n"; - let input_path = dir.path().join("input.md"); - fs::write(&input_path, input_content).expect("failed to write input file"); - let output_dir = dir.path().join("dist"); - - // Original config — used to seed the cache. - let original_config = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let variables = std::collections::HashMap::new(); - let old_base_hash = - crate::cache::compute_input_hash(input_content, &original_config, &variables); - let old_hash_key = format!("{old_base_hash}-html"); - write_cache_file(&output_dir, &old_hash_key, "cached result"); - - // Updated config (adds a variable) — the hash must differ from the original. - let updated_config = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\nvariables:\n title: changed\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(updated_config.as_bytes()) - .expect("failed to write config"); - - // Dry-run succeeds; transforms run because the config hash misses. - let result = run(f.path().to_str().unwrap(), true, None); - assert!( - result.is_ok(), - "dry-run with changed config should succeed: {:?}", - result - ); - } - - #[test] - #[ignore = "requires pandoc to be installed"] - fn test_cache_file_written_after_build() { - // After a real (non-dry-run) build the cache file must exist in the - // output directory and contain the hash of the transformed content. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.md"); - fs::write(&input_path, "# Hello\n").expect("failed to write input"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - run(f.path().to_str().unwrap(), false, None).expect("build should succeed"); - - let cache_path = output_dir.join(".renderflow-cache.json"); - assert!( - cache_path.exists(), - "cache file must exist after a real build" - ); - } - - #[test] - #[ignore = "requires pandoc to be installed"] - fn test_second_build_hits_cache() { - // Running the build twice with the same input must result in a cache - // hit on the second run. We verify this indirectly by checking that - // the cache file still exists and that the second run also succeeds. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.md"); - fs::write(&input_path, "# Hello\n").expect("failed to write input"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - // First build — cache miss, cache written. - run(f.path().to_str().unwrap(), false, None).expect("first build should succeed"); - // Second build — cache hit. - run(f.path().to_str().unwrap(), false, None) - .expect("second build (cache hit) should succeed"); - - let cache_path = output_dir.join(".renderflow-cache.json"); - assert!( - cache_path.exists(), - "cache file must still exist after second build" - ); - } - - // ── output cache integration tests ─────────────────────────────────────── - - /// Write a pre-populated output cache file at `output_dir/.renderflow-output-cache.json`. - fn write_output_cache_file(output_dir: &std::path::Path, output_path: &str, hash: &str) { - fs::create_dir_all(output_dir).expect("failed to create output dir"); - let cache_path = output_dir.join(".renderflow-output-cache.json"); - let map: std::collections::HashMap<&str, &str> = - std::collections::HashMap::from([(output_path, hash)]); - let json = serde_json::to_string(&map).expect("failed to serialize output cache"); - fs::write(&cache_path, json).expect("failed to write output cache file"); - } - - #[test] - #[ignore = "requires pandoc to be installed"] - fn test_output_cache_file_written_after_build() { - // After a successful (non-dry-run) build the output cache file must - // exist alongside the transform cache. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.md"); - fs::write(&input_path, "# Hello\n").expect("failed to write input"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - run(f.path().to_str().unwrap(), false, None).expect("build should succeed"); - - let output_cache_path = output_dir.join(".renderflow-output-cache.json"); - assert!( - output_cache_path.exists(), - "output cache file must exist after a real build" - ); - } - - #[test] - #[ignore = "requires pandoc to be installed"] - fn test_second_build_skips_unchanged_output() { - // Run the build twice with the same inputs; the second run must skip - // pandoc for all outputs because the output cache indicates they are - // already up-to-date. We verify indirectly that both runs succeed and - // the output cache file persists. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.md"); - fs::write(&input_path, "# Hello\n").expect("failed to write input"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - // First build — output cache miss, pandoc runs, cache written. - run(f.path().to_str().unwrap(), false, None).expect("first build should succeed"); - // Second build — output cache hit, pandoc skipped. - run(f.path().to_str().unwrap(), false, None) - .expect("second build (output cache hit) should succeed"); - - let output_cache_path = output_dir.join(".renderflow-output-cache.json"); - assert!( - output_cache_path.exists(), - "output cache must still exist after second build" - ); - } - - #[test] - #[ignore = "requires pandoc to be installed"] - fn test_changed_input_triggers_rebuild() { - // After modifying the input file, a subsequent build must re-run pandoc - // because both the transform cache and output cache hashes change. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.md"); - fs::write(&input_path, "# Original\n").expect("failed to write input"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - // First build with original content. - run(f.path().to_str().unwrap(), false, None).expect("first build should succeed"); - - // Modify input — caches must be invalidated. - fs::write(&input_path, "# Modified\n").expect("failed to write updated input"); - - // Second build must succeed (re-render triggered by cache miss). - run(f.path().to_str().unwrap(), false, None) - .expect("second build after input change should succeed"); - } - - #[test] - fn test_output_cache_not_written_in_dry_run() { - // In dry-run mode the output cache file must never be created. - let (f, dir) = valid_config_file(); - let output_dir = dir.path().join("dist"); - run(f.path().to_str().unwrap(), true, None).expect("dry-run should succeed"); - let output_cache_path = output_dir.join(".renderflow-output-cache.json"); - assert!( - !output_cache_path.exists(), - "output cache must not be written in dry-run mode" - ); - } - - #[test] - fn test_pre_populated_output_cache_loaded_without_error() { - // Even when a pre-populated output cache exists, a dry-run should - // complete without error (the cache is read but never written back). - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_content = "# Test\n"; - let input_path = dir.path().join("input.md"); - fs::write(&input_path, input_content).expect("failed to write input file"); - let output_dir = dir.path().join("dist"); - - // Write a dummy output cache entry. - let output_path = format!("{}/input.html", output_dir.display()); - write_output_cache_file(&output_dir, &output_path, "dummy_hash"); - - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - let result = run(f.path().to_str().unwrap(), true, None); - assert!( - result.is_ok(), - "dry-run with output cache should succeed: {:?}", - result - ); - } - - #[test] - fn test_parallel_dry_run_produces_result_per_output() { - // Dry-run with multiple output formats: the parallel rendering loop must - // process every configured output and produce a result for each one. - // This verifies that par_iter covers all outputs, not just the first. - let (f, _dir) = multi_output_config_file(); - let result = run(f.path().to_str().unwrap(), true, None); - assert!( - result.is_ok(), - "dry-run with multiple outputs should succeed: {:?}", - result - ); - } - - #[test] - #[ignore = "requires pandoc to be installed"] - fn test_parallel_failure_isolation_and_aggregation() { - // Verify that when multiple output formats are configured and one format - // fails, the other formats are still attempted and their failures are - // collected independently. The final error must mention every failing - // format so the caller can identify which outputs need attention. - // - // We configure two unsupported formats. Each fails independently inside - // the rayon parallel loop; the outer run() collects all failures and - // returns a single aggregated error. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.md"); - fs::write(&input_path, "# Test\n").expect("failed to write input file"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: epub\n - type: rst\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - let result = run(f.path().to_str().unwrap(), false, None); - assert!( - result.is_err(), - "build with only unsupported formats should fail" - ); - let err_msg = format!("{:#}", result.unwrap_err()); - // Both format names must appear in the aggregated error message. - assert!( - err_msg.contains("epub"), - "aggregated error should mention 'epub': {err_msg}" - ); - assert!( - err_msg.contains("rst"), - "aggregated error should mention 'rst': {err_msg}" - ); - } - - #[test] - #[ignore = "requires pandoc to be installed"] - fn test_parallel_renders_all_formats_independently() { - // With html, pdf, and docx configured the parallel loop must produce one - // successful result per format. This exercises the full rayon path for - // each output strategy running concurrently. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.md"); - fs::write(&input_path, "# Hello\n\nThis is a test.\n").expect("failed to write input file"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\n - type: pdf\n - type: docx\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - let result = run(f.path().to_str().unwrap(), false, None); - assert!( - result.is_ok(), - "parallel build with html+pdf+docx should succeed: {:?}", - result - ); - assert!( - output_dir.join("input.html").exists(), - "html output must exist" - ); - assert!( - output_dir.join("input.pdf").exists(), - "pdf output must exist" - ); - assert!( - output_dir.join("input.docx").exists(), - "docx output must exist" - ); - } - - // ── Image build tests ───────────────────────────────────────────────────── - - #[test] - fn test_audio_build_end_to_end_with_ffmpeg_when_available() { - if crate::deps::check_ffmpeg().is_err() { - return; - } - - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.wav"); - let output_dir = dir.path().join("dist"); - let status = Command::new("ffmpeg") - .args(["-y", "-f", "lavfi", "-i", "sine=frequency=880:duration=0.2"]) - .arg(&input_path) - .status() - .expect("failed to generate wav fixture"); - assert!(status.success(), "ffmpeg should generate wav fixture"); - - let config_content = format!( - "outputs:\n - type: mp3\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - let result = run(f.path().to_str().unwrap(), false, None); - assert!( - result.is_ok(), - "audio build should succeed with ffmpeg: {:?}", - result - ); - - let output_path = output_dir.join("input.mp3"); - assert!(output_path.exists(), "audio output must exist"); - assert!( - fs::metadata(output_path) - .expect("missing audio output metadata") - .len() - > 0, - "audio output must be non-empty" - ); - } - - fn valid_image_config_file(ext: &str, output_type: &str) -> (NamedTempFile, tempfile::TempDir) { - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join(format!("input.{}", ext)); - // Write a minimal placeholder binary (real conversion requires ffmpeg). - fs::write(&input_path, b"\xff\xd8\xff").expect("failed to write input image"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: {}\ninput: \"{}\"\noutput_dir: \"{}\"\n", - output_type, - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write temp file"); - (f, dir) - } - - #[test] - fn test_image_build_dry_run_succeeds() { - // A dry-run image build must succeed without requiring ffmpeg or writing - // any output files. - let (f, _dir) = valid_image_config_file("jpg", "png"); - let result = run(f.path().to_str().unwrap(), true, None); - assert!( - result.is_ok(), - "image dry-run must succeed without ffmpeg: {:?}", - result - ); - } - - #[test] - fn test_image_build_missing_config_returns_error() { - let result = run("/nonexistent/renderflow.yaml", false, None); - assert!(result.is_err(), "missing config must return error"); - } - - #[test] - fn test_image_build_missing_input_returns_error() { - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: png\ninput: \"/nonexistent/photo.jpg\"\noutput_dir: \"{}\"\n", - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - let result = run(f.path().to_str().unwrap(), false, None); - assert!(result.is_err(), "missing input file must return error"); - let msg = format!("{}", result.unwrap_err()); - assert!( - msg.contains("Input file not found"), - "unexpected error: {}", - msg - ); - } - - #[test] - fn test_image_build_unsupported_encoding_dry_run_still_enqueues() { - // Even a format that does not support encoding (e.g. SVG output) - // should pass the dry-run path (no actual conversion is attempted). - let (f, _dir) = valid_image_config_file("jpg", "svg"); - let result = run(f.path().to_str().unwrap(), true, None); - assert!( - result.is_ok(), - "image dry-run for unsupported encoding format must succeed: {:?}", - result - ); - } - - #[test] - fn test_image_build_non_image_output_type_returns_error() { - // When the input is an image but the output is a document type, the config - // validation must reject the combination. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("photo.jpg"); - fs::write(&input_path, b"\xff\xd8\xff").expect("failed to write input image"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - let result = run(f.path().to_str().unwrap(), false, None); - assert!( - result.is_err(), - "image input with document output type must fail validation" - ); - let msg = format!("{}", result.unwrap_err()); - assert!( - msg.contains("not an image format"), - "unexpected error: {}", - msg - ); - } - - #[test] - fn test_image_build_with_profile_dry_run_succeeds() { - // A dry-run with a named profile must succeed; the profile is parsed - // in FfmpegImageArgs but no conversion is actually run. - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("photo.jpg"); - fs::write(&input_path, b"\xff\xd8\xff").expect("failed to write input image"); - let output_dir = dir.path().join("dist"); - let config_content = format!( - "outputs:\n - type: png\n profile: png_max\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - let result = run(f.path().to_str().unwrap(), true, None); - assert!( - result.is_ok(), - "image dry-run with profile must succeed: {:?}", - result - ); - } - - #[test] - fn test_image_build_end_to_end_with_ffmpeg_when_available() { - if crate::deps::check_ffmpeg().is_err() { - return; - } - - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let input_path = dir.path().join("input.png"); - let output_dir = dir.path().join("dist"); - let status = Command::new("ffmpeg") - .args([ - "-y", - "-f", - "lavfi", - "-i", - "color=c=red:s=4x4:d=0.1", - "-frames:v", - "1", - ]) - .arg(&input_path) - .status() - .expect("failed to generate png fixture"); - assert!(status.success(), "ffmpeg should generate png fixture"); - - let config_content = format!( - "outputs:\n - type: webp\ninput: \"{}\"\noutput_dir: \"{}\"\n", - input_path.display(), - output_dir.display() - ); - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(config_content.as_bytes()) - .expect("failed to write config"); - - let result = run(f.path().to_str().unwrap(), false, None); - assert!( - result.is_ok(), - "image build should succeed with ffmpeg: {:?}", - result - ); - - let output_path = output_dir.join("input.webp"); - assert!(output_path.exists(), "image output must exist"); - assert!( - fs::metadata(output_path) - .expect("missing image output metadata") - .len() - > 0, - "image output must be non-empty" - ); - } -} diff --git a/crates/renderflow-core/src/commands/graph.rs b/crates/renderflow-core/src/commands/graph.rs index c2fa6de..a4b6064 100644 --- a/crates/renderflow-core/src/commands/graph.rs +++ b/crates/renderflow-core/src/commands/graph.rs @@ -3,84 +3,31 @@ use std::fs; use anyhow::{Context, Result}; use tracing::info; -use crate::config::load_config_for_graph; use crate::graph::execution_plan::ExecutionPlan; use crate::graph::renderers::renderer_for; -use crate::graph::{Format, MultiTargetDag}; +use crate::graph::Format; use crate::optimization::OptimizationMode; -use crate::toolchain::filter_graph_for_current_toolchain; -use crate::transforms::yaml_loader::build_graph_executor_and_tools_from_yaml; +use crate::planning::{resolve, PlanningRequest}; // ── helpers ───────────────────────────────────────────────────────────────── -/// Load the config, build the transform graph, compute the DAG, and return an -/// [`ExecutionPlan`]. +/// Resolve the exact same canonical plan consumed by build execution. pub(crate) fn load_plan( config_path: &str, target: Option<&str>, optimization: Option, ) -> Result<(ExecutionPlan, Vec)> { - let config = load_config_for_graph(config_path)?; - info!("Loaded config from '{}'", config_path); - - let transforms_path = config.transforms.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "This subcommand requires a 'transforms' key in the config file \ - pointing to a YAML transform configuration" - ) - })?; - - let (raw_graph, _executor, tool_registry) = - build_graph_executor_and_tools_from_yaml(transforms_path)?; - let (graph, tool_inventory, tool_context) = - filter_graph_for_current_toolchain(&raw_graph, &tool_registry); - info!( - "Loaded tool-aware transform graph from '{}'", - transforms_path - ); - - let opt_mode = optimization.unwrap_or(config.optimization); - - let source_format: Format = config.input_format().to_string().parse().with_context(|| { - format!( - "Could not map input format '{}' to a known graph format", - config.input_format() - ) - })?; - - let targets: Vec = if let Some(t) = target { - vec![t - .parse::() - .with_context(|| format!("'{}' is not a valid target format", t))?] - } else { - let reachable = graph.reachable_from(source_format); - if reachable.is_empty() { - anyhow::bail!( - "No output formats are reachable from '{}' in the transform graph", - source_format - ); - } - reachable - }; - - let dag: MultiTargetDag = graph - .build_multi_target_dag_with_mode(source_format, &targets, opt_mode) - .ok_or_else(|| { - anyhow::anyhow!( - "Could not build an execution plan: one or more target formats \ - are not reachable from '{}' after provider availability filtering. Blocked providers: {}", - source_format, - tool_inventory.blocked_summaries().join("; ") - ) - })?; - - let mut plan = ExecutionPlan::from_dag(&dag, source_format, &targets, opt_mode); - let toolchain = tool_registry.fingerprint_for_dag(&tool_inventory, &dag, &tool_context)?; - plan.attach_toolchain(toolchain); - for diagnostic in tool_inventory.blocked_summaries() { - plan.add_tool_diagnostic(format!("Provider excluded from planning: {diagnostic}")); + let mut request = PlanningRequest::from_path(config_path); + if let Some(optimization) = optimization { + request = request.with_optimization(optimization); } - Ok((plan, targets)) + if let Some(target) = target { + request = request.with_target(target); + } + + let resolved = resolve(request)?; + info!("Resolved canonical execution plan from '{}'", config_path); + Ok((resolved.plan().clone(), resolved.target_formats())) } /// Emit `output` to `export` path (if provided) or to stdout. diff --git a/crates/renderflow-core/src/commands/graph_build.rs b/crates/renderflow-core/src/commands/graph_build.rs deleted file mode 100644 index b4ad2b9..0000000 --- a/crates/renderflow-core/src/commands/graph_build.rs +++ /dev/null @@ -1,204 +0,0 @@ -use std::{fs, path::Path}; - -use anyhow::{Context, Result}; -use tracing::{debug, info}; - -use crate::artifact::{ArtifactDescriptor, ArtifactStorageClass, ArtifactStore}; -use crate::config::load_config_for_graph; -use crate::files::ensure_output_dir; -use crate::graph::Format; -use crate::optimization::OptimizationMode; -use crate::toolchain::filter_graph_for_current_toolchain; -use crate::transforms::yaml_loader::build_graph_executor_and_tools_from_yaml; - -/// Run graph-based execution targeting a single output format. -pub fn run_target( - config_path: &str, - target: &str, - dry_run: bool, - optimization: Option, -) -> Result<()> { - let target_format = target - .parse::() - .with_context(|| format!("'{}' is not a valid target format", target))?; - - run_impl( - config_path, - Some(vec![target_format]), - dry_run, - optimization, - ) -} - -/// Run graph-based execution targeting all formats reachable from the source. -pub fn run_all( - config_path: &str, - dry_run: bool, - optimization: Option, -) -> Result<()> { - run_impl(config_path, None, dry_run, optimization) -} - -/// Shared implementation for `run_target` and `run_all`. -fn run_impl( - config_path: &str, - explicit_targets: Option>, - dry_run: bool, - optimization: Option, -) -> Result<()> { - if dry_run { - info!("Dry-run mode enabled — no files or transform commands will be produced; bounded tool probes may run for planning"); - } - info!("Running graph-based build pipeline"); - - let config = load_config_for_graph(config_path)?; - info!("Loaded config successfully"); - - let transforms_path = config.transforms.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "Graph-based execution requires a 'transforms' key in the config file \ - pointing to a YAML transform configuration" - ) - })?; - - let (raw_graph, executor, tool_registry) = - build_graph_executor_and_tools_from_yaml(transforms_path)?; - let (graph, tool_inventory, tool_context) = - filter_graph_for_current_toolchain(&raw_graph, &tool_registry); - info!( - "Loaded tool-aware transform graph from '{}'", - transforms_path - ); - - let opt_mode = optimization.unwrap_or(config.optimization); - info!(optimization = %opt_mode, "Using optimization mode"); - - let source_format: Format = config.input_format().to_string().parse().with_context(|| { - format!( - "Could not map input format '{}' to a known graph format", - config.input_format() - ) - })?; - - let targets: Vec = match explicit_targets { - Some(targets) => targets, - None => { - let reachable = graph.reachable_from(source_format); - if reachable.is_empty() { - anyhow::bail!( - "No output formats are reachable from '{}' in the transform graph", - source_format - ); - } - info!( - "Discovered {} reachable output format(s): {}", - reachable.len(), - reachable - .iter() - .map(|format| format.to_string()) - .collect::>() - .join(", ") - ); - reachable - } - }; - - let dag = graph - .build_multi_target_dag_with_mode(source_format, &targets, opt_mode) - .ok_or_else(|| { - anyhow::anyhow!( - "Could not build an execution plan: one or more target formats \ - are not reachable from '{}' after provider availability filtering. Blocked providers: {}", - source_format, - tool_inventory.blocked_summaries().join("; ") - ) - })?; - - let toolchain = tool_registry.fingerprint_for_dag(&tool_inventory, &dag, &tool_context)?; - info!(fingerprint = %toolchain.fingerprint, providers = toolchain.selected_tools.len(), "Resolved execution toolchain"); - - debug!("Execution plan (DAG tree):\n{}", dag.to_tree(source_format)); - - let input_stem = Path::new(&config.input) - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or("document"); - - let output_dir = if dry_run { - let path = std::path::PathBuf::from(&config.output_dir); - info!( - "[DRY RUN] Would create output directory: {}", - path.display() - ); - for target in &targets { - let output_path = path.join(format!("{}.{}", input_stem, target)); - info!( - "[DRY RUN] Would write '{}' output to: {}", - target, - output_path.display() - ); - } - return Ok(()); - } else { - ensure_output_dir(&config.output_dir)? - }; - - // Keep intermediate/cache state outside the final output directory itself. - let state_parent = output_dir - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let state_dir = state_parent.join(".renderflow"); - let artifact_store = ArtifactStore::new(state_dir.join("artifacts"))?; - fs::create_dir_all(&state_dir)?; - fs::write( - state_dir.join("toolchain.json"), - serde_json::to_vec_pretty(&toolchain)?, - )?; - let executor = executor - .with_cache(state_dir.join("dag-cache.json")) - .with_toolchain_fingerprint(toolchain.fingerprint.clone()); - - let source_artifact = artifact_store.import_path( - &config.input, - ArtifactDescriptor::for_format(source_format, ArtifactStorageClass::Source), - )?; - - info!( - artifact = %source_artifact.id(), - digest = %source_artifact.digest(), - bytes = source_artifact.size_bytes(), - "Executing graph-based pipeline from binary-safe source artifact" - ); - let results = executor - .execute_artifact(&dag, source_format, source_artifact, &artifact_store) - .context("Graph execution failed")?; - - for (format, artifact) in &results { - if *format == source_format { - continue; - } - let output_path = output_dir.join(format!("{}.{}", input_stem, format)); - let terminal_artifact = artifact - .clone() - .with_storage_class(ArtifactStorageClass::Terminal); - artifact_store - .materialize(&terminal_artifact, &output_path) - .with_context(|| { - format!( - "Failed to materialize '{}' output to '{}'", - format, - output_path.display() - ) - })?; - info!( - artifact = %terminal_artifact.id(), - digest = %terminal_artifact.digest(), - bytes = terminal_artifact.size_bytes(), - "✔ Output written to: {}", - output_path.display() - ); - } - - Ok(()) -} diff --git a/crates/renderflow-core/src/commands/inspect.rs b/crates/renderflow-core/src/commands/inspect.rs index e3741ce..83fff30 100644 --- a/crates/renderflow-core/src/commands/inspect.rs +++ b/crates/renderflow-core/src/commands/inspect.rs @@ -3,83 +3,32 @@ use std::fs; use anyhow::{Context, Result}; use tracing::info; -use crate::config::load_config_for_graph; -use crate::graph::Format; use crate::optimization::OptimizationMode; -use crate::transforms::yaml_loader::build_graph_and_executor_from_yaml; +use crate::planning::{resolve, PlanningRequest}; -/// Run the `inspect` subcommand: visualize the transformation DAG. -/// -/// Supports two output formats: -/// * `"tree"` – human-readable CLI tree view (default) -/// * `"dot"` – Graphviz DOT language, suitable for `dot -Tsvg` -/// -/// When `export` is `Some(path)` the output is written to that file; -/// otherwise it is printed to stdout. -/// -/// The `all` parameter is accepted for consistency with the `build` subcommand -/// but does not change behaviour: when no explicit `target` is given all -/// reachable formats are shown regardless. +/// Run the `inspect` subcommand against the same resolved DAG used by execution. pub fn run( config_path: &str, output_format: &str, target: Option<&str>, - _all: bool, + all: bool, export: Option<&str>, optimization: Option, ) -> Result<()> { - let config = load_config_for_graph(config_path)?; - info!("Loaded config from '{}'", config_path); - - let transforms_path = config.transforms.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "DAG inspection requires a 'transforms' key in the config file \ - pointing to a YAML transform configuration" - ) - })?; - - let (graph, _executor) = build_graph_and_executor_from_yaml(transforms_path)?; - info!("Loaded transform graph from '{}'", transforms_path); - - let opt_mode = optimization.unwrap_or(config.optimization); - - let source_format: Format = config.input_format().to_string().parse().with_context(|| { - format!( - "Could not map input format '{}' to a known graph format", - config.input_format() - ) - })?; - - // Determine targets. - let targets: Vec = if let Some(t) = target { - vec![t - .parse::() - .with_context(|| format!("'{}' is not a valid target format", t))?] - } else { - // --all (or default): discover every format reachable from the source. - let reachable = graph.reachable_from(source_format); - if reachable.is_empty() { - anyhow::bail!( - "No output formats are reachable from '{}' in the transform graph", - source_format - ); - } - reachable - }; - - let dag = graph - .build_multi_target_dag_with_mode(source_format, &targets, opt_mode) - .ok_or_else(|| { - anyhow::anyhow!( - "Could not build an execution plan: one or more target formats \ - are not reachable from '{}' in the transform graph", - source_format - ) - })?; + 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 all { + request = request.with_all_reachable(); + } + let resolved = resolve(request)?; let output = match output_format.to_lowercase().as_str() { - "dot" | "graphviz" => dag.to_dot(source_format), - _ => dag.to_tree(source_format), + "dot" | "graphviz" => resolved.dag().to_dot(resolved.source_format()), + _ => resolved.dag().to_tree(resolved.source_format()), }; if let Some(path) = export { diff --git a/crates/renderflow-core/src/commands/mod.rs b/crates/renderflow-core/src/commands/mod.rs index 73babad..b6fade6 100644 --- a/crates/renderflow-core/src/commands/mod.rs +++ b/crates/renderflow-core/src/commands/mod.rs @@ -2,7 +2,6 @@ pub mod ai; pub mod audit; pub mod build; pub mod graph; -pub mod graph_build; pub mod inspect; pub mod plugin; pub mod spec; diff --git a/crates/renderflow-core/src/commands/watch.rs b/crates/renderflow-core/src/commands/watch.rs index c57d8cd..dc5d796 100644 --- a/crates/renderflow-core/src/commands/watch.rs +++ b/crates/renderflow-core/src/commands/watch.rs @@ -3,10 +3,9 @@ use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode}; use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::time::Duration; -use tracing::{debug, error, info, warn}; +use tracing::{error, info, warn}; use crate::config::load_config; -use crate::incremental::{hash_file, load_dependency_map}; use super::build; @@ -48,12 +47,6 @@ pub fn run(config_path: &str, debounce_ms: u64) -> Result<()> { Ok(events) => { for event in &events { info!("File changed → rebuilding... ({})", event.path.display()); - - // Use the dependency map to log which outputs are affected by - // this specific file change. This gives the user (and - // developers) visibility into the incremental build decisions - // without changing the current full-rebuild strategy. - log_affected_outputs(config_path, &event.path); } if let Err(e) = build::run_resilient(config_path) { error!("Build failed: {:#}", e); @@ -68,39 +61,6 @@ pub fn run(config_path: &str, debounce_ms: u64) -> Result<()> { Ok(()) } -/// Log which outputs in the dependency map are affected by a change to `changed_path`. -/// -/// This is best-effort: if the config cannot be loaded or the dependency map -/// cannot be found, the function silently returns. -fn log_affected_outputs(config_path: &str, changed_path: &Path) { - let Ok(config) = load_config(config_path) else { - return; - }; - let output_dir = PathBuf::from(&config.output_dir); - let dep_map_path = output_dir.join(".renderflow-deps.json"); - let dep_map = load_dependency_map(&dep_map_path); - - let changed_str = changed_path.to_string_lossy(); - // Hash the changed file to compare with recorded hashes. If the file - // cannot be read (e.g. it was deleted) we use an empty string so that - // every recorded hash will differ, correctly marking all dependents stale. - let current_hash = hash_file(changed_path).unwrap_or_default(); - let affected = dep_map.outputs_affected_by(&changed_str, ¤t_hash); - - if affected.is_empty() { - debug!( - changed = %changed_str, - "No tracked outputs depend on this file (or dependency map is empty)" - ); - } else { - debug!( - changed = %changed_str, - affected_outputs = ?affected, - "Outputs affected by this file change (per dependency map)" - ); - } -} - /// Collect extra paths to watch beyond the config file itself. /// /// Tries to load the config so that the actual input file is watched. @@ -210,25 +170,4 @@ mod tests { ); } } - - // ── log_affected_outputs (smoke test — best-effort, no panic) ───────────── - - #[test] - fn test_log_affected_outputs_does_not_panic_for_missing_config() { - // Should return silently without panicking. - log_affected_outputs("/nonexistent/renderflow.yaml", Path::new("/some/file.md")); - } - - #[test] - fn test_log_affected_outputs_does_not_panic_with_valid_config_no_dep_map() { - let dir = tempfile::tempdir().expect("tempdir failed"); - let input_path = dir.path().join("doc.md"); - fs::write(&input_path, "# Hello\n").expect("write failed"); - let output_dir = dir.path().join("dist"); - let config = - config_with_input(&input_path.to_string_lossy(), &output_dir.to_string_lossy()); - - // No .renderflow-deps.json exists — the function should handle this gracefully. - log_affected_outputs(config.path().to_str().unwrap(), &input_path); - } } diff --git a/crates/renderflow-core/src/config.rs b/crates/renderflow-core/src/config.rs index faa3bf0..a418775 100644 --- a/crates/renderflow-core/src/config.rs +++ b/crates/renderflow-core/src/config.rs @@ -137,14 +137,13 @@ impl Config { } InputFormat::from_extension(&self.input).unwrap_or_default() } - pub fn validate(&self) -> Result<()> { + pub(crate) fn validate_structure(&self) -> Result<()> { if self.input.trim().is_empty() { anyhow::bail!("Config validation failed: 'input' must not be empty"); } if self.outputs.is_empty() { anyhow::bail!("Config validation failed: 'outputs' must contain at least one entry"); } - // Collect all unsupported types so the user sees every problem at once. let bad: Vec = self .outputs .iter() @@ -159,6 +158,11 @@ impl Config { if !bad.is_empty() { anyhow::bail!("{}", bad.join("\n")); } + Ok(()) + } + + pub fn validate(&self) -> Result<()> { + self.validate_structure()?; // When the input file is audio, validate that all outputs are also audio. // Skip the pandoc-based compatibility check for audio pipelines entirely. @@ -261,19 +265,6 @@ pub fn load_config(path: &str) -> Result { Ok(config) } -/// Load a config file without requiring the `outputs` key to be present. -/// -/// Unlike [`load_config`], this function skips the full -/// [`Config::validate`] call. It is intended for graph-based execution -/// modes (`--target`, `--all`) where output formats are resolved from the -/// transform graph rather than from a static `outputs` list. -pub fn load_config_for_graph(path: &str) -> Result { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read config file: {}", path))?; - serde_yaml_ng::from_str(&content) - .with_context(|| format!("Failed to parse YAML config: {}", path)) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/renderflow-core/src/deps.rs b/crates/renderflow-core/src/deps.rs deleted file mode 100644 index d12985e..0000000 --- a/crates/renderflow-core/src/deps.rs +++ /dev/null @@ -1,220 +0,0 @@ -use anyhow::Result; - -use crate::error::RenderError; -use crate::toolchain::{CapabilityId, ToolRegistry}; - -/// Check whether a tool is available in the system PATH using the canonical -/// bounded version-probe path. -fn tool_available(name: &str) -> bool { - let mut registry = ToolRegistry::builtins(); - let id = registry.canonical_id_for_executable(name); - if !registry.contains(&id) { - let capability = - CapabilityId::new("probe.availability").expect("static capability identifier is valid"); - if registry - .ensure_command_provider(id.clone(), name.to_string(), capability) - .is_err() - { - return false; - } - } - registry - .assess_ids_current([id.as_str()]) - .get(id.as_str()) - .is_some_and(|tool| tool.is_available()) -} - -/// Verify that `pandoc` is installed and available in PATH. -/// -/// Returns a [`RenderError::PandocNotFound`] error with a clear install -/// instruction if it is not found. -pub fn check_pandoc() -> Result<()> { - if !tool_available("pandoc") { - return Err(RenderError::PandocNotFound.into()); - } - Ok(()) -} - -/// Verify that `tectonic` is installed and available in PATH. -/// -/// Returns a [`RenderError::TectonicNotFound`] error with a clear install -/// instruction if it is not found. -pub fn check_tectonic() -> Result<()> { - if !tool_available("tectonic") { - return Err(RenderError::TectonicNotFound.into()); - } - Ok(()) -} - -/// Verify that `ffmpeg` is installed and available in PATH. -/// -/// Returns a [`RenderError::FfmpegNotFound`] error with clear install -/// instructions if it is not found. -pub fn check_ffmpeg() -> Result<()> { - if !tool_available("ffmpeg") { - return Err(RenderError::FfmpegNotFound.into()); - } - Ok(()) -} - -/// Validate all required system dependencies before the pipeline runs. -/// -/// * `pandoc` is always required for document builds. -/// * `tectonic` is required only when PDF output is requested. -/// * `ffmpeg` is required when any audio output is requested. -pub fn validate_dependencies(pdf_requested: bool) -> Result<()> { - check_pandoc()?; - if pdf_requested { - check_tectonic()?; - } - Ok(()) -} - -/// Validate dependencies required for an audio-only build. -/// -/// Only `ffmpeg` is checked; pandoc and tectonic are not required. -pub fn validate_audio_dependencies() -> Result<()> { - check_ffmpeg() -} - -/// Validate dependencies required for an image-only build. -/// -/// Only `ffmpeg` is checked; pandoc and tectonic are not required. -pub fn validate_image_dependencies() -> Result<()> { - check_ffmpeg() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn pandoc_available() -> bool { - tool_available("pandoc") - } - - fn tectonic_is_available() -> bool { - tool_available("tectonic") - } - - #[test] - fn test_check_pandoc_result_matches_availability() { - let result = check_pandoc(); - if pandoc_available() { - assert!( - result.is_ok(), - "check_pandoc should succeed when pandoc is installed" - ); - } else { - assert!( - result.is_err(), - "check_pandoc should fail when pandoc is not installed" - ); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Pandoc is not installed"), - "error should say 'Pandoc is not installed', got: {msg}" - ); - assert!( - msg.contains("pandoc"), - "error should mention pandoc, got: {msg}" - ); - } - } - - #[test] - fn test_check_pandoc_error_contains_install_hint() { - let result = check_pandoc(); - if pandoc_available() { - return; - } - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("install") || msg.contains("https://"), - "error should contain an install hint, got: {msg}" - ); - } - - #[test] - fn test_check_tectonic_result_matches_availability() { - let result = check_tectonic(); - if tectonic_is_available() { - assert!( - result.is_ok(), - "check_tectonic should succeed when tectonic is installed" - ); - } else { - assert!( - result.is_err(), - "check_tectonic should fail when tectonic is not installed" - ); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Tectonic not found"), - "error should say 'Tectonic not found', got: {msg}" - ); - } - } - - #[test] - fn test_check_tectonic_error_contains_install_hint() { - let result = check_tectonic(); - if tectonic_is_available() { - return; - } - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("install") || msg.contains("https://"), - "error should contain an install hint, got: {msg}" - ); - } - - #[test] - fn test_validate_dependencies_without_pdf_only_checks_pandoc() { - let result = validate_dependencies(false); - if pandoc_available() { - assert!( - result.is_ok(), - "validation without PDF should succeed when pandoc is present" - ); - } else { - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Pandoc is not installed"), - "error should be about pandoc: {msg}" - ); - } - } - - #[test] - fn test_validate_dependencies_with_pdf_checks_both() { - let result = validate_dependencies(true); - if pandoc_available() && tectonic_is_available() { - assert!( - result.is_ok(), - "validation with PDF should succeed when both tools are present" - ); - } else { - assert!( - result.is_err(), - "validation with PDF should fail when a tool is missing" - ); - } - } - - #[test] - fn test_tool_available_with_known_tool() { - assert!( - tool_available("cargo"), - "cargo should always be available in a Rust build environment" - ); - } - - #[test] - fn test_tool_available_with_nonexistent_tool() { - assert!( - !tool_available("__renderflow_nonexistent_tool__"), - "a made-up tool should not be reported as available" - ); - } -} diff --git a/crates/renderflow-core/src/files.rs b/crates/renderflow-core/src/files.rs deleted file mode 100644 index dc80060..0000000 --- a/crates/renderflow-core/src/files.rs +++ /dev/null @@ -1,90 +0,0 @@ -use anyhow::{Context, Result}; -use std::fs; -use std::path::{Path, PathBuf}; -use tracing::info; - -/// Validates that the input file exists and returns its canonical path. -pub fn validate_input(path: impl AsRef) -> Result { - let p = path.as_ref(); - if !p.exists() { - anyhow::bail!("Input file not found: {}", p.display()); - } - let canonical = fs::canonicalize(p) - .with_context(|| format!("Failed to resolve input path: {}", p.display()))?; - canonical - .to_str() - .ok_or_else(|| anyhow::anyhow!("Input path is not valid UTF-8: {}", canonical.display()))?; - info!(input = %canonical.display(), "Validated input file"); - Ok(canonical) -} - -/// Ensures the output directory exists, creating it if necessary, and returns its canonical path. -pub fn ensure_output_dir(path: &str) -> Result { - let p = Path::new(path); - fs::create_dir_all(p) - .with_context(|| format!("Failed to create output directory: {}", path))?; - let canonical = fs::canonicalize(p) - .with_context(|| format!("Failed to resolve output directory path: {}", path))?; - info!(output_dir = %canonical.display(), "Ensured output directory exists"); - Ok(canonical) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::{NamedTempFile, TempDir}; - - #[test] - fn test_validate_input_success() { - let mut f = NamedTempFile::new().expect("failed to create temp file"); - f.write_all(b"hello").expect("failed to write"); - let result = validate_input(f.path()); - assert!(result.is_ok(), "expected Ok for existing file"); - let path = result.unwrap(); - assert!(path.is_absolute(), "canonical path should be absolute"); - assert!( - path.exists(), - "canonical path should point to an existing file" - ); - } - - #[test] - fn test_validate_input_missing_file() { - let result = validate_input("/nonexistent/path/input.md"); - assert!(result.is_err(), "expected error for missing file"); - let msg = format!("{}", result.unwrap_err()); - assert!( - msg.contains("Input file not found"), - "unexpected error: {}", - msg - ); - } - - #[test] - fn test_ensure_output_dir_creates_directory() { - let base = TempDir::new().expect("failed to create temp dir"); - let output = base.path().join("nested").join("output"); - let result = ensure_output_dir(output.to_str().unwrap()); - assert!(result.is_ok(), "expected Ok when creating nested directory"); - assert!(output.exists(), "output directory should have been created"); - assert!(output.is_dir(), "output path should be a directory"); - } - - #[test] - fn test_ensure_output_dir_existing_directory() { - let dir = TempDir::new().expect("failed to create temp dir"); - let result = ensure_output_dir(dir.path().to_str().unwrap()); - assert!(result.is_ok(), "expected Ok for already-existing directory"); - } - - #[test] - fn test_ensure_output_dir_returns_canonical_path() { - let base = TempDir::new().expect("failed to create temp dir"); - let output = base.path().join("dist"); - let result = ensure_output_dir(output.to_str().unwrap()); - assert!(result.is_ok()); - let canonical = result.unwrap(); - assert!(canonical.is_absolute(), "returned path should be absolute"); - } -} diff --git a/crates/renderflow-core/src/graph/dag_executor.rs b/crates/renderflow-core/src/graph/dag_executor.rs index 6b9d847..7d2927a 100644 --- a/crates/renderflow-core/src/graph/dag_executor.rs +++ b/crates/renderflow-core/src/graph/dag_executor.rs @@ -29,6 +29,8 @@ pub struct DagExecutor { cache_path: Option, /// Selected-provider fingerprint used to reject incompatible cache entries. toolchain_fingerprint: Option, + /// Optional per-execution parallelism bound from the canonical execution policy. + max_parallel: Option, } impl DagExecutor { @@ -39,6 +41,7 @@ impl DagExecutor { aggregation_transforms: HashMap::new(), cache_path: None, toolchain_fingerprint: None, + max_parallel: None, } } @@ -57,6 +60,12 @@ impl DagExecutor { self } + /// Bound parallel transform execution for this executor instance. + pub fn with_max_parallel(mut self, max_parallel: usize) -> Self { + self.max_parallel = Some(max_parallel.max(1)); + self + } + /// Register an existing UTF-8 text transform through the compatibility adapter. pub fn register_single( &mut self, @@ -209,6 +218,16 @@ impl DagExecutor { .as_deref() .map(|path| Mutex::new(load_artifact_cache(path))); + let thread_pool = self + .max_parallel + .map(|threads| { + rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .context("Failed to create bounded DAG execution thread pool") + }) + .transpose()?; + let mut available: HashMap = HashMap::new(); available.insert(source_format, initial_artifacts); let mut remaining: Vec<&TransformEdge> = dag.execution_order(); @@ -229,10 +248,16 @@ impl DagExecutor { } debug!(wave_size = wave.len(), "Executing artifact DAG wave"); - let wave_results: Result> = wave - .into_par_iter() - .map(|edge| self.execute_edge(edge, &available, store, cache.as_ref())) - .collect(); + let execute_wave = || { + wave.into_par_iter() + .map(|edge| self.execute_edge(edge, &available, store, cache.as_ref())) + .collect::>>() + }; + let wave_results = if let Some(pool) = &thread_pool { + pool.install(execute_wave) + } else { + execute_wave() + }; for (format, artifacts) in wave_results? { available.insert(format, artifacts); diff --git a/crates/renderflow-core/src/graph/execution_plan.rs b/crates/renderflow-core/src/graph/execution_plan.rs index d62dec7..c4908cb 100644 --- a/crates/renderflow-core/src/graph/execution_plan.rs +++ b/crates/renderflow-core/src/graph/execution_plan.rs @@ -67,6 +67,9 @@ pub struct PlanEdge { /// Stable capability identifier implemented by the selected provider. #[serde(default, skip_serializing_if = "Option::is_none")] pub capability_id: Option, + /// Secondary providers required by the selected transform. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub required_provider_ids: Vec, /// Stable provider-specific transform/model variant identity. #[serde(default, skip_serializing_if = "Option::is_none")] pub variant_id: Option, @@ -97,6 +100,7 @@ impl PlanEdge { edge_type, provider_id: e.provider_id.clone(), capability_id: e.capability_id.clone(), + required_provider_ids: e.required_provider_ids.clone(), variant_id: e.variant_id.clone(), evidence: e.evidence.clone(), } @@ -254,7 +258,10 @@ impl ExecutionPlan { let mut node_labels: Vec = dag.graph.node_weights().map(|f| f.to_string()).collect(); + node_labels.push(source.to_string()); + node_labels.extend(targets.iter().map(|target| target.to_string())); node_labels.sort(); + node_labels.dedup(); let nodes: Vec = node_labels .iter() diff --git a/crates/renderflow-core/src/graph/mod.rs b/crates/renderflow-core/src/graph/mod.rs index 4a30ec5..b1fa0bf 100644 --- a/crates/renderflow-core/src/graph/mod.rs +++ b/crates/renderflow-core/src/graph/mod.rs @@ -123,13 +123,37 @@ impl TransformGraph { .collect() } - /// Return stable provider IDs referenced by graph edges. - pub fn provider_ids(&self) -> Vec { - let mut ids: Vec = self - .graph + /// Return every transform edge in registration order. + pub fn all_edges(&self) -> Vec<&TransformEdge> { + self.graph .edge_references() - .filter_map(|edge| edge.weight().provider_id.clone()) - .collect(); + .map(|edge| edge.weight()) + .collect() + } + + /// Clone the graph while retaining only edges accepted by `predicate`. + pub fn filtered_by(&self, predicate: F) -> Self + where + F: Fn(&TransformEdge) -> bool, + { + let mut filtered = Self::new(); + for edge in self.graph.edge_references().map(|edge| edge.weight()) { + if predicate(edge) { + filtered.add_transform(edge.clone()); + } + } + filtered + } + + /// Return stable provider IDs referenced by graph edges, including secondary requirements. + pub fn provider_ids(&self) -> Vec { + let mut ids = Vec::new(); + for edge in self.graph.edge_references().map(|edge| edge.weight()) { + if let Some(provider) = &edge.provider_id { + ids.push(provider.clone()); + } + ids.extend(edge.required_provider_ids.iter().cloned()); + } ids.sort(); ids.dedup(); ids @@ -140,11 +164,15 @@ impl TransformGraph { pub fn filtered_by_available_providers(&self, available: &HashSet) -> Self { let mut filtered = Self::new(); for edge in self.graph.edge_references().map(|edge| edge.weight()) { - if edge + let primary_available = edge .provider_id .as_ref() - .is_none_or(|provider| available.contains(provider)) - { + .is_none_or(|provider| available.contains(provider)); + let requirements_available = edge + .required_provider_ids + .iter() + .all(|provider| available.contains(provider)); + if primary_available && requirements_available { filtered.add_transform(edge.clone()); } } diff --git a/crates/renderflow-core/src/graph/transform_edge.rs b/crates/renderflow-core/src/graph/transform_edge.rs index 574d43c..8ae72e2 100644 --- a/crates/renderflow-core/src/graph/transform_edge.rs +++ b/crates/renderflow-core/src/graph/transform_edge.rs @@ -28,6 +28,8 @@ pub struct TransformEdge { pub provider_id: Option, /// Stable machine-readable capability identifier for this edge, when known. pub capability_id: Option, + /// Additional provider/tool identifiers required by this transform. + pub required_provider_ids: Vec, /// Stable provider-specific transform/model variant identity, when known. pub variant_id: Option, /// Deterministically ordered evidence that affects this variant's reproducibility. @@ -53,6 +55,7 @@ impl TransformEdge { input_kind: InputKind::Single, provider_id: None, capability_id: None, + required_provider_ids: Vec::new(), variant_id: None, evidence: BTreeMap::new(), } @@ -84,6 +87,7 @@ impl TransformEdge { input_kind, provider_id: None, capability_id: None, + required_provider_ids: Vec::new(), variant_id: None, evidence: BTreeMap::new(), } @@ -100,6 +104,15 @@ impl TransformEdge { self } + pub fn with_required_provider(mut self, provider_id: impl Into) -> Self { + let provider_id = provider_id.into(); + if !self.required_provider_ids.contains(&provider_id) { + self.required_provider_ids.push(provider_id); + self.required_provider_ids.sort(); + } + self + } + pub fn with_variant(mut self, variant_id: impl Into) -> Self { self.variant_id = Some(variant_id.into()); self diff --git a/crates/renderflow-core/src/incremental.rs b/crates/renderflow-core/src/incremental.rs deleted file mode 100644 index d389022..0000000 --- a/crates/renderflow-core/src/incremental.rs +++ /dev/null @@ -1,503 +0,0 @@ -use std::collections::HashMap; -use std::fs; -use std::path::Path; - -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use tracing::warn; - -// ── FileDependency ──────────────────────────────────────────────────────────── - -/// A single file dependency: the path to the file and a hex-encoded SHA-256 -/// hash of its contents at the time the dependent output was last built. -/// -/// When the file's current content hash differs from the recorded hash the -/// dependency is considered stale, and every output that listed it as a -/// dependency must be rebuilt. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct FileDependency { - /// Canonical path to the dependency file. - pub path: String, - /// Hex-encoded SHA-256 hash of the file's contents when the output was built. - pub hash: String, -} - -// ── DependencyMap ───────────────────────────────────────────────────────────── - -/// Persistent map from output file paths to the set of file dependencies that -/// produced them. -/// -/// This is the core data structure of the incremental build system. By -/// recording exactly which input files contributed to each output, the build -/// system can: -/// -/// * answer "is this output up-to-date?" by comparing each dependency's -/// current content hash with the stored hash; -/// * answer "which outputs are affected if file F changes?" by scanning every -/// output's dependency list for an entry matching F. -/// -/// The map is persisted to disk as compact JSON in the output directory -/// (`.renderflow-deps.json`) and loaded at the start of each build. -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct DependencyMap(HashMap>); - -impl DependencyMap { - /// Record that `output_path` was produced from the given file dependencies. - /// - /// Any previous entry for `output_path` is replaced. - pub fn record(&mut self, output_path: String, deps: Vec) { - self.0.insert(output_path, deps); - } - - /// Return the recorded file dependencies for `output_path`, or `None` if - /// the output has never been built (or was built without dependency tracking). - pub fn dependencies_for(&self, output_path: &str) -> Option<&[FileDependency]> { - self.0.get(output_path).map(Vec::as_slice) - } - - /// Return `true` when every dependency recorded for `output_path` still - /// has the same content hash as when the output was last built. - /// - /// Returns `false` in any of these situations: - /// * `output_path` has no recorded dependencies (never tracked). - /// * Any recorded dependency's current hash differs from the stored hash. - /// * `current_deps` is empty (no dependencies provided by the caller). - pub fn is_output_up_to_date(&self, output_path: &str, current_deps: &[FileDependency]) -> bool { - if current_deps.is_empty() { - return false; - } - let Some(recorded) = self.dependencies_for(output_path) else { - return false; - }; - // Build a lookup from path → hash for the recorded state. - let recorded_map: HashMap<&str, &str> = recorded - .iter() - .map(|d| (d.path.as_str(), d.hash.as_str())) - .collect(); - - // Every dependency provided by the caller must match the recorded hash. - current_deps.iter().all(|dep| { - recorded_map - .get(dep.path.as_str()) - .is_some_and(|&stored_hash| stored_hash == dep.hash) - }) - } - - /// Return the paths of all outputs that have `changed_path` listed as a - /// dependency **and** whose recorded hash for that dependency differs from - /// `changed_hash`. - /// - /// This can be used to determine which outputs must be rebuilt when a - /// specific file is modified. - pub fn outputs_affected_by(&self, changed_path: &str, changed_hash: &str) -> Vec { - self.0 - .iter() - .filter_map(|(output, deps)| { - let affected = deps - .iter() - .any(|dep| dep.path == changed_path && dep.hash != changed_hash); - if affected { - Some(output.clone()) - } else { - None - } - }) - .collect() - } -} - -// ── File hashing ────────────────────────────────────────────────────────────── - -/// Compute a hex-encoded SHA-256 hash of the contents of the file at `path`. -/// -/// Returns an error if the file cannot be read. -pub fn hash_file(path: &Path) -> Result { - let contents = fs::read(path)?; - let mut hasher = Sha256::new(); - hasher.update(&contents); - Ok(format!("{:x}", hasher.finalize())) -} - -// ── Dependency construction ─────────────────────────────────────────────────── - -/// Collect the file-level dependencies for a single output. -/// -/// The dependency list always includes: -/// * the primary input file (`input_path`) -/// * the configuration file (`config_path`) -/// -/// When a Pandoc template file is specified and can be read, its path and hash -/// are appended as well. If the template file cannot be read, a warning is -/// logged and the dependency is omitted (a subsequent build will treat the -/// output as stale, which is the safe default). -pub fn build_output_dependencies( - input_path: &Path, - config_path: &Path, - template_path: Option<&Path>, -) -> Vec { - let mut deps = Vec::new(); - - for file in [input_path, config_path] { - match hash_file(file) { - Ok(h) => deps.push(FileDependency { - path: file.to_string_lossy().into_owned(), - hash: h, - }), - Err(e) => warn!( - path = %file.display(), - error = %e, - "Could not hash dependency file; output will be treated as stale" - ), - } - } - - if let Some(tmpl) = template_path { - match hash_file(tmpl) { - Ok(h) => deps.push(FileDependency { - path: tmpl.to_string_lossy().into_owned(), - hash: h, - }), - Err(e) => warn!( - path = %tmpl.display(), - error = %e, - "Could not hash template dependency; output will be treated as stale" - ), - } - } - - deps -} - -// ── Persistence ─────────────────────────────────────────────────────────────── - -/// Load the dependency map from disk. -/// -/// Returns an empty map if the file does not exist or cannot be parsed. -/// Non-fatal errors are logged at `WARN` level so a corrupt or missing map -/// never aborts the build. -pub fn load_dependency_map(cache_path: &Path) -> DependencyMap { - if !cache_path.exists() { - return DependencyMap::default(); - } - - match fs::read_to_string(cache_path) { - Err(e) => { - warn!( - path = %cache_path.display(), - error = %e, - "Failed to read dependency map file; starting with empty map" - ); - DependencyMap::default() - } - Ok(content) => match serde_json::from_str(&content) { - Ok(map) => map, - Err(e) => { - warn!( - path = %cache_path.display(), - error = %e, - "Failed to parse dependency map file; starting with empty map" - ); - DependencyMap::default() - } - }, - } -} - -/// Persist the dependency map to disk as compact JSON. -/// -/// Errors are propagated to the caller. -pub fn save_dependency_map(map: &DependencyMap, cache_path: &Path) -> Result<()> { - let json = serde_json::to_string(map)?; - fs::write(cache_path, json)?; - Ok(()) -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::NamedTempFile; - - fn dep(path: &str, hash: &str) -> FileDependency { - FileDependency { - path: path.to_string(), - hash: hash.to_string(), - } - } - - // ── DependencyMap::record / dependencies_for ────────────────────────────── - - #[test] - fn test_record_and_retrieve_dependencies() { - let mut map = DependencyMap::default(); - let deps = vec![dep("/in/doc.md", "aaa"), dep("/cfg/build.yaml", "bbb")]; - map.record("/out/doc.html".to_string(), deps.clone()); - assert_eq!(map.dependencies_for("/out/doc.html"), Some(deps.as_slice())); - } - - #[test] - fn test_missing_output_returns_none() { - let map = DependencyMap::default(); - assert!(map.dependencies_for("/out/missing.html").is_none()); - } - - #[test] - fn test_record_overwrites_previous_entry() { - let mut map = DependencyMap::default(); - map.record("/out/doc.html".to_string(), vec![dep("/in/doc.md", "old")]); - map.record("/out/doc.html".to_string(), vec![dep("/in/doc.md", "new")]); - let deps = map.dependencies_for("/out/doc.html").unwrap(); - assert_eq!(deps[0].hash, "new"); - } - - // ── DependencyMap::is_output_up_to_date ─────────────────────────────────── - - #[test] - fn test_up_to_date_when_all_deps_match() { - let mut map = DependencyMap::default(); - let deps = vec![dep("/in/doc.md", "hash1"), dep("/cfg/build.yaml", "hash2")]; - map.record("/out/doc.html".to_string(), deps.clone()); - assert!(map.is_output_up_to_date("/out/doc.html", &deps)); - } - - #[test] - fn test_stale_when_dep_hash_changed() { - let mut map = DependencyMap::default(); - map.record( - "/out/doc.html".to_string(), - vec![dep("/in/doc.md", "old_hash")], - ); - let current = vec![dep("/in/doc.md", "new_hash")]; - assert!(!map.is_output_up_to_date("/out/doc.html", ¤t)); - } - - #[test] - fn test_stale_when_output_not_recorded() { - let map = DependencyMap::default(); - let current = vec![dep("/in/doc.md", "hash1")]; - assert!(!map.is_output_up_to_date("/out/doc.html", ¤t)); - } - - #[test] - fn test_stale_when_current_deps_empty() { - let mut map = DependencyMap::default(); - map.record( - "/out/doc.html".to_string(), - vec![dep("/in/doc.md", "hash1")], - ); - assert!(!map.is_output_up_to_date("/out/doc.html", &[])); - } - - #[test] - fn test_stale_when_new_dep_not_in_recorded() { - let mut map = DependencyMap::default(); - map.record( - "/out/doc.html".to_string(), - vec![dep("/in/doc.md", "hash1")], - ); - // Current state has an extra dep the recorded map doesn't know about. - let current = vec![ - dep("/in/doc.md", "hash1"), - dep("/templates/tmpl.html", "tmpl_hash"), - ]; - assert!(!map.is_output_up_to_date("/out/doc.html", ¤t)); - } - - // ── DependencyMap::outputs_affected_by ─────────────────────────────────── - - #[test] - fn test_outputs_affected_by_detects_changed_dep() { - let mut map = DependencyMap::default(); - map.record( - "/out/doc.html".to_string(), - vec![dep("/templates/a.html", "old")], - ); - map.record("/out/doc.pdf".to_string(), vec![dep("/in/doc.md", "hash1")]); - - let affected = map.outputs_affected_by("/templates/a.html", "new"); - assert_eq!(affected, vec!["/out/doc.html".to_string()]); - } - - #[test] - fn test_outputs_affected_by_returns_empty_when_dep_unchanged() { - let mut map = DependencyMap::default(); - map.record( - "/out/doc.html".to_string(), - vec![dep("/templates/a.html", "same")], - ); - - let affected = map.outputs_affected_by("/templates/a.html", "same"); - assert!(affected.is_empty()); - } - - #[test] - fn test_outputs_affected_by_returns_empty_when_file_not_tracked() { - let mut map = DependencyMap::default(); - map.record( - "/out/doc.html".to_string(), - vec![dep("/in/doc.md", "hash1")], - ); - - let affected = map.outputs_affected_by("/unrelated/file.txt", "some_hash"); - assert!(affected.is_empty()); - } - - #[test] - fn test_multiple_outputs_affected_by_shared_template() { - let mut map = DependencyMap::default(); - map.record( - "/out/doc.html".to_string(), - vec![dep("/templates/shared.html", "old")], - ); - map.record( - "/out/report.html".to_string(), - vec![dep("/templates/shared.html", "old")], - ); - map.record( - "/out/other.pdf".to_string(), - vec![dep("/in/doc.md", "hash1")], - ); - - let mut affected = map.outputs_affected_by("/templates/shared.html", "new"); - affected.sort(); - assert_eq!( - affected, - vec!["/out/doc.html".to_string(), "/out/report.html".to_string()] - ); - } - - // ── hash_file ───────────────────────────────────────────────────────────── - - #[test] - fn test_hash_file_returns_hex_sha256() { - let mut f = NamedTempFile::new().unwrap(); - f.write_all(b"hello world").unwrap(); - let hash = hash_file(f.path()).expect("hash_file should succeed"); - assert_eq!(hash.len(), 64); - assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn test_hash_file_same_content_stable() { - let mut f = NamedTempFile::new().unwrap(); - f.write_all(b"stable content").unwrap(); - let h1 = hash_file(f.path()).unwrap(); - let h2 = hash_file(f.path()).unwrap(); - assert_eq!(h1, h2); - } - - #[test] - fn test_hash_file_different_content_differs() { - let mut f1 = NamedTempFile::new().unwrap(); - f1.write_all(b"content A").unwrap(); - let mut f2 = NamedTempFile::new().unwrap(); - f2.write_all(b"content B").unwrap(); - assert_ne!(hash_file(f1.path()).unwrap(), hash_file(f2.path()).unwrap()); - } - - #[test] - fn test_hash_file_missing_returns_error() { - let result = hash_file(Path::new("/nonexistent/file.txt")); - assert!(result.is_err()); - } - - // ── build_output_dependencies ───────────────────────────────────────────── - - #[test] - fn test_build_output_dependencies_includes_input_and_config() { - let mut input = NamedTempFile::new().unwrap(); - input.write_all(b"# Hello").unwrap(); - let mut cfg = NamedTempFile::new().unwrap(); - cfg.write_all(b"outputs:\n - type: html\n").unwrap(); - - let deps = build_output_dependencies(input.path(), cfg.path(), None); - assert_eq!(deps.len(), 2); - assert_eq!(deps[0].path, input.path().to_string_lossy()); - assert_eq!(deps[1].path, cfg.path().to_string_lossy()); - } - - #[test] - fn test_build_output_dependencies_includes_template_when_provided() { - let mut input = NamedTempFile::new().unwrap(); - input.write_all(b"# Hello").unwrap(); - let mut cfg = NamedTempFile::new().unwrap(); - cfg.write_all(b"outputs:\n - type: html\n").unwrap(); - let mut tmpl = NamedTempFile::new().unwrap(); - tmpl.write_all(b"{{body}}").unwrap(); - - let deps = build_output_dependencies(input.path(), cfg.path(), Some(tmpl.path())); - assert_eq!(deps.len(), 3); - assert_eq!(deps[2].path, tmpl.path().to_string_lossy()); - } - - #[test] - fn test_build_output_dependencies_skips_missing_template_gracefully() { - let mut input = NamedTempFile::new().unwrap(); - input.write_all(b"# Hello").unwrap(); - let mut cfg = NamedTempFile::new().unwrap(); - cfg.write_all(b"outputs:\n - type: html\n").unwrap(); - let missing = Path::new("/nonexistent/template.html"); - - // Should not panic; the missing template is silently omitted. - let deps = build_output_dependencies(input.path(), cfg.path(), Some(missing)); - assert_eq!(deps.len(), 2); - } - - // ── load_dependency_map / save_dependency_map ──────────────────────────── - - #[test] - fn test_load_dependency_map_missing_file_returns_empty() { - let path = Path::new("/nonexistent/.renderflow-deps.json"); - let map = load_dependency_map(path); - assert!(map.dependencies_for("/any/output").is_none()); - } - - #[test] - fn test_save_and_reload_dependency_map_round_trips() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".renderflow-deps.json"); - - let mut map = DependencyMap::default(); - map.record( - "/out/doc.html".to_string(), - vec![dep("/in/doc.md", "h1"), dep("/cfg/build.yaml", "h2")], - ); - map.record("/out/doc.pdf".to_string(), vec![dep("/in/doc.md", "h1")]); - - save_dependency_map(&map, &path).expect("save should succeed"); - - let reloaded = load_dependency_map(&path); - let html_deps = reloaded.dependencies_for("/out/doc.html").unwrap(); - assert_eq!(html_deps.len(), 2); - assert_eq!(html_deps[0].hash, "h1"); - - let pdf_deps = reloaded.dependencies_for("/out/doc.pdf").unwrap(); - assert_eq!(pdf_deps.len(), 1); - } - - #[test] - fn test_load_dependency_map_invalid_json_returns_empty() { - let mut f = NamedTempFile::new().unwrap(); - f.write_all(b"not valid json {{").unwrap(); - let map = load_dependency_map(f.path()); - assert!(map.dependencies_for("/any/output").is_none()); - } - - #[test] - fn test_save_dependency_map_writes_valid_json() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".renderflow-deps.json"); - - let mut map = DependencyMap::default(); - map.record("/out/doc.html".to_string(), vec![dep("/in/doc.md", "abc")]); - save_dependency_map(&map, &path).expect("save should succeed"); - - let raw = fs::read_to_string(&path).expect("read failed"); - let parsed: serde_json::Value = serde_json::from_str(&raw).expect("must be valid JSON"); - // The JSON should contain the output path as a key. - assert!(parsed.get("/out/doc.html").is_some()); - } -} diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index ba8be7e..6df9676 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -14,22 +14,19 @@ pub mod cli; mod commands; mod compat; mod config; -mod deps; pub mod detect; pub mod error; -mod files; pub mod graph; mod image; -mod incremental; mod input_format; pub mod optimization; mod pipeline; +pub mod planning; pub mod process; mod sdk; pub mod spec; pub mod strategies; pub mod super_resolution; -mod template; pub mod toolchain; pub mod transforms; diff --git a/crates/renderflow-core/src/pipeline/mod.rs b/crates/renderflow-core/src/pipeline/mod.rs index f7f2499..1003bd1 100644 --- a/crates/renderflow-core/src/pipeline/mod.rs +++ b/crates/renderflow-core/src/pipeline/mod.rs @@ -1,7 +1,4 @@ #[allow(clippy::module_inception)] pub mod pipeline; -pub mod step; -pub mod strategy_step; pub use pipeline::Pipeline; -pub use strategy_step::StrategyStep; diff --git a/crates/renderflow-core/src/pipeline/pipeline.rs b/crates/renderflow-core/src/pipeline/pipeline.rs index 13a1158..6bea9a5 100644 --- a/crates/renderflow-core/src/pipeline/pipeline.rs +++ b/crates/renderflow-core/src/pipeline/pipeline.rs @@ -1,70 +1,29 @@ use std::collections::HashMap; use anyhow::Result; -use tracing::debug; -use super::step::PipelineStep; use crate::config::OutputType; -use crate::transforms::{ - register_transforms, EmojiTransform, FailureMode, SyntaxHighlightTransform, Transform, - TransformRegistry, VariableSubstitutionTransform, -}; +use crate::transforms::{register_transforms, TransformRegistry}; -/// An ordered sequence of transforms and output-format steps. +/// Pure in-memory document transform pipeline used by the canonical artifact adapter. /// -/// The pipeline separates document processing into two distinct phases: -/// -/// 1. **Transform phase** – pure, in-memory text mutations (emoji replacement, -/// variable substitution, syntax normalisation, …) that are format-agnostic. -/// Transforms are owned and managed by an internal [`TransformRegistry`]; -/// call [`Pipeline::run_transforms`] to execute them. -/// -/// 2. **Step phase** – format-specific rendering steps (HTML, PDF, …) that -/// consume the transformed text and write output files. Call -/// [`Pipeline::run_steps`] after transforms have been applied. -/// -/// Use [`Pipeline::with_registry`] to attach a pre-configured -/// [`TransformRegistry`] (e.g. the standard one returned by -/// [`crate::transforms::register_transforms`]) instead of adding transforms -/// one-by-one with [`Pipeline::add_transform`]. +/// Format rendering is intentionally not represented as a second pipeline phase anymore; +/// rendering is a graph `ArtifactTransform` executed by `DagExecutor`. pub struct Pipeline { registry: TransformRegistry, - steps: Vec>, } impl Pipeline { - /// Create an empty pipeline with an empty transform registry. pub fn new() -> Self { Self { registry: TransformRegistry::new(), - steps: Vec::new(), } } - /// Create a pipeline pre-loaded with an existing [`TransformRegistry`]. pub fn with_registry(registry: TransformRegistry) -> Self { - Self { - registry, - steps: Vec::new(), - } + Self { registry } } - /// Create a pipeline pre-loaded with the standard set of document transforms - /// for the given output format. - /// - /// This is the preferred constructor for document processing; it internalises - /// the transform setup so callers never need to interact with - /// [`TransformRegistry`] or individual transform types directly. - /// - /// The `output_type` parameter controls format-specific transform behaviour. - /// In particular, emoji replacement is skipped for `OutputType::Html` because - /// HTML renders emoji natively. - /// - /// ```ignore - /// let mut pipeline = Pipeline::with_standard_transforms(&variables, &OutputType::Pdf); - /// pipeline.add_step(Box::new(my_step)); - /// let output = pipeline.run(input)?; - /// ``` pub fn with_standard_transforms( variables: &HashMap, output_type: &OutputType, @@ -72,101 +31,9 @@ impl Pipeline { Self::with_registry(register_transforms(variables, output_type)) } - /// Create a pipeline pre-loaded with the standard set of document transforms - /// in [`FailureMode::ContinueOnError`] mode. - /// - /// Transform failures are logged and skipped rather than aborting the pipeline. - /// This is appropriate for long-running or watch-mode scenarios where a single - /// transform error should not stop the overall process. - /// - /// ```ignore - /// let mut pipeline = Pipeline::with_standard_transforms_resilient(&variables, &OutputType::Pdf); - /// pipeline.add_step(Box::new(my_step)); - /// let output = pipeline.run(input)?; - /// ``` - pub fn with_standard_transforms_resilient( - variables: &HashMap, - output_type: &OutputType, - ) -> Self { - let registry = TransformRegistry::new().with_failure_mode(FailureMode::ContinueOnError); - let mut pipeline = Self::with_registry(registry); - pipeline - .add_transform(Box::new(EmojiTransform::new_for_format(output_type))) - .add_transform(Box::new(VariableSubstitutionTransform::new( - variables.clone(), - ))) - .add_transform(Box::new(SyntaxHighlightTransform::new())); - pipeline - } - - /// Append a transform to the internal registry. - /// - /// Transforms run in registration order during [`Pipeline::run_transforms`]. - pub fn add_transform(&mut self, transform: Box) -> &mut Self { - self.registry.register(transform); - self - } - - /// Append an output-format step. - pub fn add_step(&mut self, step: Box) -> &mut Self { - self.steps.push(step); - self - } - - /// Execute all registered transforms in order by delegating to the - /// internal [`TransformRegistry`]. - /// - /// The output of each transform is fed as input to the next. Returns the - /// final transformed string, or an error that identifies the failing - /// transform. pub fn run_transforms(&self, input: String) -> Result { self.registry.apply_all(input) } - - /// Execute all registered steps in order. - pub fn run_steps(&self, input: String) -> Result { - let mut current = input; - for step in &self.steps { - let name = step.name(); - debug!(step = %name, "Starting pipeline step"); - let start = std::time::Instant::now(); - current = step.execute(current)?; - let elapsed = start.elapsed(); - debug!(step = %name, duration_ms = elapsed.as_millis(), "Pipeline step completed"); - } - Ok(current) - } - - /// Execute the full pipeline: transforms first, then steps. - /// - /// This is the primary entry point for the unified execution model: - /// `input → transforms → steps`. It is equivalent to calling - /// [`Pipeline::run_transforms`] followed by [`Pipeline::run_steps`]. - pub fn run(&self, input: String) -> Result { - let transform_start = std::time::Instant::now(); - let transformed = self.run_transforms(input)?; - let transform_elapsed = transform_start.elapsed(); - debug!( - duration_ms = transform_elapsed.as_millis(), - "Transform phase completed" - ); - - let steps_start = std::time::Instant::now(); - let result = self.run_steps(transformed)?; - let steps_elapsed = steps_start.elapsed(); - debug!( - duration_ms = steps_elapsed.as_millis(), - "Step phase completed" - ); - - let total_elapsed = transform_start.elapsed(); - debug!( - duration_ms = total_elapsed.as_millis(), - "Pipeline execution completed" - ); - - Ok(result) - } } impl Default for Pipeline { @@ -178,27 +45,9 @@ impl Default for Pipeline { #[cfg(test)] mod tests { use super::*; - use crate::config::OutputType; - use crate::transforms::Transform; - use anyhow::bail; + use crate::transforms::{Transform, TransformRegistry}; - struct AppendStep(String); - - impl PipelineStep for AppendStep { - fn execute(&self, input: String) -> Result { - Ok(format!("{}{}", input, self.0)) - } - } - - struct FailingStep; - - impl PipelineStep for FailingStep { - fn execute(&self, _input: String) -> Result { - bail!("step failed") - } - } - - struct AppendTransform(String); + struct AppendTransform(&'static str); impl Transform for AppendTransform { fn apply(&self, input: String) -> Result { @@ -206,311 +55,20 @@ mod tests { } } - struct FailingTransform; - - impl Transform for FailingTransform { - fn name(&self) -> &'static str { - "FailingTransform" - } - fn apply(&self, _input: String) -> Result { - bail!("transform failed") - } - } - - #[test] - fn test_pipeline_empty_returns_input() { - let pipeline = Pipeline::new(); - let transformed = pipeline.run_transforms("hello".to_string()).unwrap(); - let result = pipeline.run_steps(transformed).unwrap(); - assert_eq!(result, "hello"); - } - - #[test] - fn test_pipeline_single_step() { - let mut pipeline = Pipeline::new(); - pipeline.add_step(Box::new(AppendStep(" world".to_string()))); - let transformed = pipeline.run_transforms("hello".to_string()).unwrap(); - let result = pipeline.run_steps(transformed).unwrap(); - assert_eq!(result, "hello world"); - } - - #[test] - fn test_pipeline_multiple_steps_sequential() { - let mut pipeline = Pipeline::new(); - pipeline - .add_step(Box::new(AppendStep(" step1".to_string()))) - .add_step(Box::new(AppendStep(" step2".to_string()))) - .add_step(Box::new(AppendStep(" step3".to_string()))); - - let transformed = pipeline.run_transforms("input".to_string()).unwrap(); - let result = pipeline.run_steps(transformed).unwrap(); - assert_eq!(result, "input step1 step2 step3"); - } - - #[test] - fn test_pipeline_output_of_one_step_is_input_of_next() { - struct UppercaseStep; - impl PipelineStep for UppercaseStep { - fn execute(&self, input: String) -> Result { - Ok(input.to_uppercase()) - } - } - - struct AppendExclamation; - impl PipelineStep for AppendExclamation { - fn execute(&self, input: String) -> Result { - Ok(format!("{}!", input)) - } - } - - let mut pipeline = Pipeline::new(); - pipeline - .add_step(Box::new(UppercaseStep)) - .add_step(Box::new(AppendExclamation)); - - let transformed = pipeline.run_transforms("hello".to_string()).unwrap(); - let result = pipeline.run_steps(transformed).unwrap(); - assert_eq!(result, "HELLO!"); - } - - #[test] - fn test_pipeline_error_propagates() { - let mut pipeline = Pipeline::new(); - pipeline - .add_step(Box::new(AppendStep(" ok".to_string()))) - .add_step(Box::new(FailingStep)) - .add_step(Box::new(AppendStep(" never".to_string()))); - - let transformed = pipeline.run_transforms("input".to_string()).unwrap(); - let result = pipeline.run_steps(transformed); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("step failed")); - } - - #[test] - fn test_transforms_apply_in_order() { - let mut pipeline = Pipeline::new(); - pipeline - .add_transform(Box::new(AppendTransform(" t1".to_string()))) - .add_transform(Box::new(AppendTransform(" t2".to_string()))) - .add_transform(Box::new(AppendTransform(" t3".to_string()))); - - let result = pipeline.run_transforms("input".to_string()).unwrap(); - assert_eq!(result, "input t1 t2 t3"); - } - - #[test] - fn test_transforms_run_before_steps() { - let mut pipeline = Pipeline::new(); - pipeline - .add_transform(Box::new(AppendTransform(" transformed".to_string()))) - .add_step(Box::new(AppendStep(" rendered".to_string()))); - - let transformed = pipeline.run_transforms("input".to_string()).unwrap(); - let result = pipeline.run_steps(transformed).unwrap(); - assert_eq!(result, "input transformed rendered"); - } - - #[test] - fn test_transform_error_propagates() { - let mut pipeline = Pipeline::new(); - pipeline - .add_transform(Box::new(AppendTransform(" ok".to_string()))) - .add_transform(Box::new(FailingTransform)) - .add_transform(Box::new(AppendTransform(" never".to_string()))); - - let result = pipeline.run_transforms("input".to_string()); - assert!(result.is_err()); - // The error context must identify the failing transform. - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Transform failed: FailingTransform"), - "expected context message, got: {msg}" - ); - } - - #[test] - fn test_transform_output_chaining() { - struct UppercaseTransform; - impl Transform for UppercaseTransform { - fn apply(&self, input: String) -> Result { - Ok(input.to_uppercase()) - } - } - - let mut pipeline = Pipeline::new(); - pipeline - .add_transform(Box::new(UppercaseTransform)) - .add_transform(Box::new(AppendTransform("!".to_string()))); - - let result = pipeline.run_transforms("hello".to_string()).unwrap(); - assert_eq!(result, "HELLO!"); - } - - #[test] - fn test_run_combines_transforms_and_steps() { - let mut pipeline = Pipeline::new(); - pipeline - .add_transform(Box::new(AppendTransform(" transformed".to_string()))) - .add_step(Box::new(AppendStep(" rendered".to_string()))); - - let result = pipeline.run("input".to_string()).unwrap(); - assert_eq!(result, "input transformed rendered"); - } - - #[test] - fn test_run_transform_error_short_circuits() { - let mut pipeline = Pipeline::new(); - pipeline - .add_transform(Box::new(FailingTransform)) - .add_step(Box::new(AppendStep(" should not run".to_string()))); - - let result = pipeline.run("input".to_string()); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Transform failed: FailingTransform")); - } - - #[test] - fn test_run_step_error_propagates() { - let mut pipeline = Pipeline::new(); - pipeline - .add_transform(Box::new(AppendTransform(" t".to_string()))) - .add_step(Box::new(FailingStep)); - - let result = pipeline.run("input".to_string()); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("step failed")); - } - #[test] - fn test_with_standard_transforms_applies_emoji() { - use std::collections::HashMap; - let pipeline = Pipeline::with_standard_transforms(&HashMap::new(), &OutputType::Pdf); - let result = pipeline.run_transforms("Hello 😀".to_string()).unwrap(); - assert_eq!(result, "Hello [emoji]"); - } - - #[test] - fn test_with_standard_transforms_preserves_emoji_for_html() { - use std::collections::HashMap; - let pipeline = Pipeline::with_standard_transforms(&HashMap::new(), &OutputType::Html); - let result = pipeline.run_transforms("Hello 😀".to_string()).unwrap(); - assert_eq!(result, "Hello 😀"); - } - - #[test] - fn test_with_standard_transforms_substitutes_variables() { - use std::collections::HashMap; - let mut vars = HashMap::new(); - vars.insert("name".to_string(), "World".to_string()); - let pipeline = Pipeline::with_standard_transforms(&vars, &OutputType::Pdf); - let result = pipeline - .run_transforms("Hello {{name}}".to_string()) - .unwrap(); - assert_eq!(result, "Hello World"); - } - - #[test] - fn test_with_standard_transforms_and_step_unified_run() { - use std::collections::HashMap; - let mut vars = HashMap::new(); - vars.insert("name".to_string(), "World".to_string()); - let mut pipeline = Pipeline::with_standard_transforms(&vars, &OutputType::Pdf); - pipeline.add_step(Box::new(AppendStep("!".to_string()))); - let result = pipeline.run("Hello {{name}}".to_string()).unwrap(); - assert_eq!(result, "Hello World!"); - } - - #[test] - fn test_with_standard_transforms_resilient_applies_transforms() { - use std::collections::HashMap; - let mut vars = HashMap::new(); - vars.insert("greeting".to_string(), "World".to_string()); - let pipeline = Pipeline::with_standard_transforms_resilient(&vars, &OutputType::Pdf); - // Emoji should be replaced and variables substituted in resilient mode too. - let result = pipeline - .run_transforms("Hello 😀 {{greeting}}".to_string()) - .unwrap(); - assert_eq!(result, "Hello [emoji] World"); - } - - #[test] - fn test_with_standard_transforms_resilient_continues_on_transform_error() { - use anyhow::bail; - use std::collections::HashMap; - - struct AlwaysFails; - impl Transform for AlwaysFails { - fn name(&self) -> &'static str { - "AlwaysFails" - } - fn apply(&self, _input: String) -> Result { - bail!("intentional failure") - } - } - - // Build a resilient pipeline and add an always-failing transform. - // ContinueOnError should skip the failure and let the pipeline succeed. - let mut pipeline = - Pipeline::with_standard_transforms_resilient(&HashMap::new(), &OutputType::Pdf); - pipeline.add_transform(Box::new(AlwaysFails)); - - let result = pipeline.run_transforms("plain text".to_string()); - assert!( - result.is_ok(), - "resilient pipeline should skip failing transforms and succeed: {:?}", - result + fn empty_pipeline_preserves_input() { + assert_eq!( + Pipeline::new().run_transforms("hello".to_string()).unwrap(), + "hello" ); - // The standard transforms (emoji, variable substitution, syntax) still run; - // AlwaysFails is skipped and its input is passed through unchanged. - assert_eq!(result.unwrap(), "plain text"); - } - - // ── Timing and performance tracing tests ───────────────────────────────── - - #[test] - fn test_step_default_name_is_pipeline_step() { - let step = AppendStep(" x".to_string()); - assert_eq!(step.name(), "PipelineStep"); - } - - #[test] - fn test_step_custom_name() { - struct NamedStep; - impl PipelineStep for NamedStep { - fn name(&self) -> &str { - "NamedStep" - } - fn execute(&self, input: String) -> Result { - Ok(input) - } - } - let step = NamedStep; - assert_eq!(step.name(), "NamedStep"); - } - - #[test] - fn test_run_steps_completes_and_returns_correct_output() { - // Verifies that adding timing instrumentation didn't break run_steps correctness. - let mut pipeline = Pipeline::new(); - pipeline - .add_step(Box::new(AppendStep(" a".to_string()))) - .add_step(Box::new(AppendStep(" b".to_string()))); - let result = pipeline.run_steps("start".to_string()).unwrap(); - assert_eq!(result, "start a b"); } #[test] - fn test_run_pipeline_timing_does_not_affect_output() { - // Verifies that the timing wrappers in run() don't alter correctness. - let mut pipeline = Pipeline::new(); - pipeline - .add_transform(Box::new(AppendTransform(" t".to_string()))) - .add_step(Box::new(AppendStep(" s".to_string()))); - let result = pipeline.run("input".to_string()).unwrap(); - assert_eq!(result, "input t s"); + fn registry_transforms_execute_in_order() { + let mut registry = TransformRegistry::new(); + registry.register(Box::new(AppendTransform("-a"))); + registry.register(Box::new(AppendTransform("-b"))); + let pipeline = Pipeline::with_registry(registry); + assert_eq!(pipeline.run_transforms("x".to_string()).unwrap(), "x-a-b"); } } diff --git a/crates/renderflow-core/src/pipeline/step.rs b/crates/renderflow-core/src/pipeline/step.rs deleted file mode 100644 index 35aa395..0000000 --- a/crates/renderflow-core/src/pipeline/step.rs +++ /dev/null @@ -1,13 +0,0 @@ -use anyhow::Result; - -pub trait PipelineStep { - /// Human-readable name for this step, used in log messages and performance traces. - /// - /// Override this in concrete step types to make timing diagnostics more - /// actionable (e.g. `"HtmlStep"` instead of the generic `"PipelineStep"`). - fn name(&self) -> &str { - "PipelineStep" - } - - fn execute(&self, input: String) -> Result; -} diff --git a/crates/renderflow-core/src/pipeline/strategy_step.rs b/crates/renderflow-core/src/pipeline/strategy_step.rs deleted file mode 100644 index 4e669ba..0000000 --- a/crates/renderflow-core/src/pipeline/strategy_step.rs +++ /dev/null @@ -1,310 +0,0 @@ -use anyhow::{Context, Result}; -use std::collections::HashMap; -use std::io::Write; -use std::sync::atomic::{AtomicU64, Ordering}; -use tracing::info; - -use crate::input_format::InputFormat; -use crate::pipeline::step::PipelineStep; -use crate::strategies::{OutputStrategy, RenderContext}; - -/// A temporary file that is automatically deleted when dropped. -/// -/// This is a minimal stdlib-only alternative to `tempfile::NamedTempFile` -/// for use in production code, keeping `tempfile` a dev-only dependency. -struct TempFile { - path: std::path::PathBuf, -} - -impl TempFile { - /// Create a new temporary file containing `content` and return its handle. - fn with_content(content: &[u8]) -> Result { - static COUNTER: AtomicU64 = AtomicU64::new(0); - let count = COUNTER.fetch_add(1, Ordering::Relaxed); - let mut path = std::env::temp_dir(); - path.push(format!("renderflow-{}-{}.tmp", std::process::id(), count)); - let mut file = std::fs::File::create_new(&path) - .context("Failed to create temporary file for strategy input")?; - file.write_all(content) - .context("Failed to write content to temporary file")?; - Ok(Self { path }) - } - - fn path(&self) -> &std::path::Path { - &self.path - } -} - -impl Drop for TempFile { - fn drop(&mut self) { - if let Err(e) = std::fs::remove_file(&self.path) { - tracing::warn!(path = %self.path.display(), error = %e, "Failed to remove temporary file"); - } - } -} - -/// A pipeline step that delegates rendering to an [`OutputStrategy`]. -/// -/// Wrapping a strategy as a pipeline step allows the pipeline to execute -/// format-specific rendering without being coupled to any particular output type. -/// -/// The step receives document content (as a string), writes it to a temporary -/// file, and passes that file path to the strategy via a [`RenderContext`]. -/// This ensures transforms applied earlier in the pipeline affect the content -/// that the strategy renders. -pub struct StrategyStep { - strategy: Box, - output_path: String, - input_format: InputFormat, - variables: HashMap, - dry_run: bool, -} - -impl StrategyStep { - pub fn new( - strategy: Box, - output_path: &str, - input_format: InputFormat, - variables: HashMap, - dry_run: bool, - ) -> Self { - Self { - strategy, - output_path: output_path.to_string(), - input_format, - variables, - dry_run, - } - } -} - -impl PipelineStep for StrategyStep { - fn name(&self) -> &str { - "StrategyStep" - } - - fn execute(&self, input: String) -> Result { - info!(output = %self.output_path, "Executing strategy step"); - let temp_file = TempFile::with_content(input.as_bytes())?; - let temp_path = temp_file - .path() - .to_str() - .ok_or_else(|| anyhow::anyhow!("Temporary file path is not valid UTF-8"))? - .to_string(); - info!(temp = %temp_path, output = %self.output_path, "Strategy rendering from temporary content file"); - let ctx = RenderContext { - input_path: &temp_path, - input_format: self.input_format.clone(), - output_path: &self.output_path, - variables: &self.variables, - dry_run: self.dry_run, - }; - self.strategy.render(&ctx)?; - Ok(self.output_path.clone()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::bail; - - struct AlwaysOkStrategy; - - impl OutputStrategy for AlwaysOkStrategy { - fn render(&self, _ctx: &RenderContext) -> Result<()> { - Ok(()) - } - } - - struct AlwaysFailStrategy; - - impl OutputStrategy for AlwaysFailStrategy { - fn render(&self, _ctx: &RenderContext) -> Result<()> { - bail!("strategy failed") - } - } - - fn make_step(strategy: Box, output: &str) -> StrategyStep { - StrategyStep::new( - strategy, - output, - InputFormat::Markdown, - HashMap::new(), - false, - ) - } - - #[test] - fn test_strategy_step_returns_output_path_on_success() { - let step = make_step(Box::new(AlwaysOkStrategy), "/tmp/output.html"); - let result = step.execute("input.md".to_string()).unwrap(); - assert_eq!(result, "/tmp/output.html"); - } - - #[test] - fn test_strategy_step_propagates_strategy_error() { - let step = make_step(Box::new(AlwaysFailStrategy), "/tmp/output.html"); - let result = step.execute("input.md".to_string()); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("strategy failed")); - } - - #[test] - fn test_strategy_step_stores_output_path() { - let step = make_step(Box::new(AlwaysOkStrategy), "/custom/path/out.pdf"); - let result = step.execute("input.md".to_string()).unwrap(); - assert_eq!(result, "/custom/path/out.pdf"); - } - - /// Verifies that `StrategyStep` writes the input content to a temporary file - /// and passes that file's path to the strategy (not the raw content string). - #[test] - fn test_strategy_step_passes_content_via_temp_file() { - use std::sync::{Arc, Mutex}; - use tempfile::NamedTempFile; - - struct CapturingStrategy { - captured: Arc>, - } - - impl OutputStrategy for CapturingStrategy { - fn render(&self, ctx: &RenderContext) -> Result<()> { - let content = std::fs::read_to_string(ctx.input_path) - .expect("strategy should receive a valid temp file path"); - *self.captured.lock().unwrap() = content; - Ok(()) - } - } - - let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap().to_string(); - - let captured = Arc::new(Mutex::new(String::new())); - let strategy = CapturingStrategy { - captured: captured.clone(), - }; - let step = make_step(Box::new(strategy), &output_path); - - let content = "# Hello World\n\nThis is rendered content.".to_string(); - step.execute(content.clone()).unwrap(); - - assert_eq!(*captured.lock().unwrap(), content); - } - - /// Verifies that the [`RenderContext`] built by `StrategyStep` carries the - /// correct `input_format`, `variables`, and `dry_run` values. - #[test] - fn test_strategy_step_context_fields_are_propagated() { - use std::sync::{Arc, Mutex}; - use tempfile::NamedTempFile; - - #[derive(Default)] - struct ContextCapture { - input_format: Option, - dry_run: Option, - variables: Option>, - } - - struct CapturingStrategy { - captured: Arc>, - } - - impl OutputStrategy for CapturingStrategy { - fn render(&self, ctx: &RenderContext) -> Result<()> { - let mut guard = self.captured.lock().unwrap(); - guard.input_format = Some(ctx.input_format.clone()); - guard.dry_run = Some(ctx.dry_run); - guard.variables = Some(ctx.variables.clone()); - Ok(()) - } - } - - let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap().to_string(); - - let mut vars = HashMap::new(); - vars.insert("key".to_string(), "value".to_string()); - - let captured = Arc::new(Mutex::new(ContextCapture::default())); - let strategy = CapturingStrategy { - captured: captured.clone(), - }; - let step = StrategyStep::new( - Box::new(strategy), - &output_path, - InputFormat::Html, - vars.clone(), - true, - ); - - step.execute("content".to_string()).unwrap(); - - let guard = captured.lock().unwrap(); - assert_eq!(guard.input_format, Some(InputFormat::Html)); - assert_eq!(guard.dry_run, Some(true)); - assert_eq!( - guard - .variables - .as_ref() - .unwrap() - .get("key") - .map(String::as_str), - Some("value") - ); - } - - /// End-to-end test: verifies that transforms applied before `StrategyStep` - /// affect the content received by the strategy. - #[test] - fn test_transforms_affect_strategy_input() { - use crate::pipeline::Pipeline; - use crate::transforms::EmojiTransform; - use std::sync::{Arc, Mutex}; - use tempfile::NamedTempFile; - - struct CapturingStrategy { - captured: Arc>, - } - - impl OutputStrategy for CapturingStrategy { - fn render(&self, ctx: &RenderContext) -> Result<()> { - let content = std::fs::read_to_string(ctx.input_path) - .expect("strategy should receive a valid temp file path"); - *self.captured.lock().unwrap() = content; - Ok(()) - } - } - - let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap().to_string(); - - let captured = Arc::new(Mutex::new(String::new())); - let strategy = CapturingStrategy { - captured: captured.clone(), - }; - - let mut pipeline = Pipeline::new(); - pipeline.add_transform(Box::new(EmojiTransform::new())); - pipeline.add_step(Box::new(make_step(Box::new(strategy), &output_path))); - - // Input has an emoji; after EmojiTransform it should become "[emoji]" - let transformed = pipeline - .run_transforms("Hello 😀 World".to_string()) - .unwrap(); - pipeline.run_steps(transformed).unwrap(); - - let result = captured.lock().unwrap().clone(); - assert_eq!(result, "Hello [emoji] World"); - assert!( - !result.contains('😀'), - "emoji should have been replaced by the transform" - ); - } - - #[test] - fn test_strategy_step_name() { - let step = make_step(Box::new(AlwaysOkStrategy), "/tmp/out.html"); - assert_eq!(step.name(), "StrategyStep"); - } -} diff --git a/crates/renderflow-core/src/planning.rs b/crates/renderflow-core/src/planning.rs new file mode 100644 index 0000000..c8f69c1 --- /dev/null +++ b/crates/renderflow-core/src/planning.rs @@ -0,0 +1,1231 @@ +//! Canonical application-layer planning and execution lifecycle. +//! +//! All CLI and SDK build modes normalize v1/v2 intent here before execution. +//! Execution consumes a previously resolved [`ResolvedExecution`] and never +//! performs implicit target/path re-planning. + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +use crate::adapters::strategy::{ + document_input_format, output_type_for_format, StrategyArtifactTransform, +}; +use crate::artifact::{ArtifactDescriptor, ArtifactStorageClass, ArtifactStore}; +use crate::graph::capability::{FormatCapabilityRegistry, FormatFamily}; +use crate::graph::{ + DagExecutor, ExecutionPlan, Format, MultiTargetDag, TransformEdge, TransformGraph, +}; +use crate::optimization::OptimizationMode; +use crate::spec::{ + load_spec, AiPolicy, CollisionPolicy, SelectorSet, SourceKind, SourceSpec, SourceSpecVersion, + SpecV2, TargetSelection, TargetSpec, +}; +use crate::super_resolution::{select_upscayl_variants, UpscaylModelCatalog}; +use crate::toolchain::{ + transform_capability_id, ToolDeterminism, ToolId, ToolLocality, ToolRegistry, + ToolRuntimeContext, ToolchainSnapshot, +}; +use crate::transforms::yaml_loader::build_graph_executor_and_tools_from_yaml; + +const BUILTIN_ADAPTER_EVIDENCE: &str = "builtin.strategy"; + +#[derive(Debug, Clone)] +pub struct PlanningRequest { + pub config_path: PathBuf, + pub target: Option, + pub all_reachable: bool, + pub optimization: Option, +} + +impl PlanningRequest { + pub fn from_path(path: impl AsRef) -> Self { + Self { + config_path: path.as_ref().to_path_buf(), + target: None, + all_reachable: false, + optimization: None, + } + } + + pub fn with_target(mut self, target: impl Into) -> Self { + self.target = Some(target.into()); + self.all_reachable = false; + self + } + + pub fn with_all_reachable(mut self) -> Self { + self.target = None; + self.all_reachable = true; + self + } + + pub fn with_optimization(mut self, optimization: OptimizationMode) -> Self { + self.optimization = Some(optimization); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedTarget { + pub format: Format, + pub id: Option, + pub role: Option, + pub preset: Option, + pub template: Option, + pub variant: Option, +} + +impl ResolvedTarget { + fn generated(format: Format) -> Self { + Self { + format, + id: None, + role: Some(format.to_string()), + preset: None, + template: None, + variant: None, + } + } + + fn from_spec(format: Format, target: &TargetSpec) -> Self { + Self { + format, + id: target.id.clone(), + role: target.role.clone().or_else(|| Some(format.to_string())), + preset: target.preset.clone(), + template: target.template.clone(), + variant: target.variant.clone(), + } + } +} + +pub struct ResolvedExecution { + plan: ExecutionPlan, + spec: SpecV2, + source_version: SourceSpecVersion, + source: SourceSpec, + source_path: PathBuf, + source_format: Format, + targets: Vec, + dag: MultiTargetDag, + executor: DagExecutor, + tool_registry: ToolRegistry, +} + +impl ResolvedExecution { + pub fn plan(&self) -> &ExecutionPlan { + &self.plan + } + + pub fn spec(&self) -> &SpecV2 { + &self.spec + } + + pub fn source_version(&self) -> SourceSpecVersion { + self.source_version + } + + pub fn source_path(&self) -> &Path { + &self.source_path + } + + pub fn source_format(&self) -> Format { + self.source_format + } + + pub fn target_formats(&self) -> Vec { + self.targets.iter().map(|target| target.format).collect() + } + + pub fn targets(&self) -> &[ResolvedTarget] { + &self.targets + } + + pub fn dag(&self) -> &MultiTargetDag { + &self.dag + } + + pub fn predicted_output_paths(&self) -> Result> { + render_output_paths(self) + } +} + +#[derive(Debug, Clone)] +pub struct CanonicalExecutionResult { + /// Exact frozen plan resolved before execution. + pub plan: ExecutionPlan, + pub output_dir: String, + pub outputs: Vec, + pub diagnostics: Vec, + pub toolchain: Option, +} + +pub fn resolve(request: PlanningRequest) -> Result { + let config_path = request + .config_path + .to_str() + .context("Config path contains non-UTF8 characters")?; + let loaded = load_spec(config_path)?; + let source_version = loaded.source_version; + let mut spec = loaded.spec; + apply_request_overrides(&mut spec, &request)?; + + let source = select_primary_source(&spec)?; + let source_path = resolve_path_relative_to_config( + &request.config_path, + source.path.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "canonical execution currently requires a local source path for '{}'", + source.id + ) + })?, + ); + if !source_path.is_file() { + anyhow::bail!( + "source artifact '{}' does not exist or is not a file", + source_path.display() + ); + } + let source_format = resolve_source_format(&source, &source_path)?; + + let (mut graph, mut executor, mut tool_registry) = + if let Some(transforms_path) = &spec.transforms { + let transforms_path = + resolve_path_relative_to_config(&request.config_path, transforms_path); + let transforms_path_string = transforms_path + .to_str() + .context("transform registry path contains non-UTF8 characters")?; + build_graph_executor_and_tools_from_yaml(transforms_path_string).with_context(|| { + format!( + "failed to load transform registry '{}'", + transforms_path.display() + ) + })? + } else { + ( + TransformGraph::new(), + DagExecutor::new(), + ToolRegistry::builtins(), + ) + }; + + register_builtin_strategy_edges(&mut graph, &mut tool_registry)?; + let policy_graph = apply_execution_policy(&graph, &tool_registry, &spec); + let targets = resolve_target_intent(&spec, &policy_graph, source_format)?; + if targets.is_empty() { + anyhow::bail!("target selection resolved to no executable artifact formats"); + } + let target_formats: Vec = targets.iter().map(|target| target.format).collect(); + + let provider_inventory = tool_registry.assess_ids_current(policy_graph.provider_ids()); + let available_graph = + policy_graph.filtered_by_available_providers(&provider_inventory.available_ids()); + let optimization = spec.execution.optimization; + let (dag, used_blocked_provider_fallback) = match available_graph + .build_multi_target_dag_with_mode(source_format, &target_formats, optimization) + { + Some(dag) => (dag, false), + None => { + let dag = policy_graph + .build_multi_target_dag_with_mode(source_format, &target_formats, optimization) + .ok_or_else(|| { + unsupported_targets_error(&policy_graph, source_format, &target_formats) + })?; + (dag, true) + } + }; + + register_builtin_strategy_executors( + &mut executor, + &dag, + &spec, + &targets, + source_format, + &source_path, + )?; + + let mut plan = ExecutionPlan::from_dag(&dag, source_format, &target_formats, optimization); + if source_version == SourceSpecVersion::V1 { + plan.add_tool_diagnostic( + "v1 configuration normalized into renderflow/v2 before canonical planning", + ); + } + if used_blocked_provider_fallback { + plan.add_tool_diagnostic( + "one or more selected paths require providers unavailable on this host; dry-run remains inspectable but execution preflight will fail until dependencies are available", + ); + } + + let selected_ids = selected_provider_ids(&dag); + let selected_inventory = tool_registry.assess_ids_current(selected_ids.iter()); + for blocked in selected_inventory + .tools + .iter() + .filter(|availability| !availability.is_available()) + { + plan.add_tool_diagnostic(format!( + "selected provider unavailable: {}", + blocked.summary() + )); + } + if selected_inventory + .tools + .iter() + .all(|tool| tool.is_available()) + { + let context = ToolRuntimeContext::current(); + let snapshot = tool_registry.fingerprint_for_dag(&selected_inventory, &dag, &context)?; + plan.attach_toolchain(snapshot); + } + + let upscayl = select_upscayl_variants(&spec, &UpscaylModelCatalog::builtins()); + for diagnostic in upscayl.diagnostics { + plan.add_tool_diagnostic(format!("{}: {}", diagnostic.code, diagnostic.message)); + } + if !upscayl.variants.is_empty() { + let names = upscayl + .variants + .iter() + .map(|model| model.variant_id.as_str()) + .collect::>() + .join(", "); + plan.add_tool_diagnostic(format!( + "resolved provider variants for {}: {}. Same-format derivative execution remains gated on the Transform v2 node-identity contract (#357).", + upscayl.capability_id, names + )); + } + + Ok(ResolvedExecution { + plan, + spec, + source_version, + source, + source_path, + source_format, + targets, + dag, + executor, + tool_registry, + }) +} + +pub fn execute(mut resolved: ResolvedExecution, dry_run: bool) -> Result { + let predicted = resolved.predicted_output_paths()?; + if dry_run { + return Ok(CanonicalExecutionResult { + plan: resolved.plan.clone(), + output_dir: resolved.spec.output.bundle_root.clone(), + outputs: predicted + .iter() + .map(|path| path.display().to_string()) + .collect(), + diagnostics: resolved + .plan + .diagnostics + .iter() + .map(|diagnostic| diagnostic.message.clone()) + .collect(), + toolchain: resolved.plan.toolchain.clone(), + }); + } + + preflight_selected_providers(&resolved)?; + validate_pre_execution_budgets(&resolved)?; + + let output_root = PathBuf::from(&resolved.spec.output.bundle_root); + fs::create_dir_all(&output_root).with_context(|| { + format!( + "failed to create output directory '{}'", + output_root.display() + ) + })?; + let state_parent = output_root + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let state_dir = state_parent.join(".renderflow"); + fs::create_dir_all(&state_dir) + .with_context(|| format!("failed to create state directory '{}'", state_dir.display()))?; + let store = ArtifactStore::new(state_dir.join("artifacts"))?; + let source_artifact = store.import_path( + &resolved.source_path, + ArtifactDescriptor::for_format(resolved.source_format, ArtifactStorageClass::Source) + .with_metadata("renderflow.source_id", resolved.source.id.clone()), + )?; + + let executor = std::mem::take(&mut resolved.executor); + let mut executor = executor + .with_cache(state_dir.join("canonical-cache.json")) + .with_max_parallel(resolved.spec.execution.max_parallel); + if let Some(snapshot) = &resolved.plan.toolchain { + executor = executor.with_toolchain_fingerprint(snapshot.fingerprint.clone()); + fs::write( + state_dir.join("toolchain.json"), + serde_json::to_vec_pretty(snapshot)?, + )?; + } + let artifacts = executor.execute_artifact( + &resolved.dag, + resolved.source_format, + source_artifact, + &store, + )?; + + validate_post_execution_budgets(&resolved, &artifacts)?; + for (target, destination) in resolved.targets.iter().zip(predicted.iter()) { + let artifact = artifacts.get(&target.format).ok_or_else(|| { + anyhow::anyhow!( + "execution plan completed without producing selected target '{}'", + target.format + ) + })?; + if resolved.spec.execution.validation.required && artifact.size_bytes() == 0 { + anyhow::bail!( + "validation failed: target '{}' produced an empty artifact", + target.format + ); + } + store.materialize(artifact, destination)?; + } + + Ok(CanonicalExecutionResult { + plan: resolved.plan.clone(), + output_dir: resolved.spec.output.bundle_root.clone(), + outputs: predicted + .iter() + .map(|path| path.display().to_string()) + .collect(), + diagnostics: resolved + .plan + .diagnostics + .iter() + .map(|diagnostic| diagnostic.message.clone()) + .collect(), + toolchain: resolved.plan.toolchain.clone(), + }) +} + +fn apply_request_overrides(spec: &mut SpecV2, request: &PlanningRequest) -> Result<()> { + if let Some(optimization) = request.optimization { + spec.execution.optimization = optimization; + } + if let Some(target) = &request.target { + let format: Format = target + .parse() + .with_context(|| format!("Unknown target format '{target}'"))?; + spec.targets = TargetSelection { + exact: vec![TargetSpec { + id: Some(format!("cli.target.{}", format)), + role: Some(format.to_string()), + format: Some(format.to_string()), + family: None, + capability: None, + transform: None, + variant: None, + preset: None, + template: None, + }], + intermediates: spec.targets.intermediates, + ..TargetSelection::default() + }; + } else if request.all_reachable { + let include = spec.targets.include.clone(); + let exclude = spec.targets.exclude.clone(); + spec.targets = TargetSelection { + all_reachable: true, + include, + exclude, + intermediates: spec.targets.intermediates, + ..TargetSelection::default() + }; + } + Ok(()) +} + +fn select_primary_source(spec: &SpecV2) -> Result { + let artifacts: Vec<&SourceSpec> = spec + .sources + .iter() + .filter(|source| source.kind == SourceKind::Artifact) + .collect(); + if artifacts.len() != 1 { + anyhow::bail!( + "canonical format-DAG execution currently requires exactly one artifact source; found {}. Multi-root/collection source identity is reserved for the Transform v2 execution graph (#357).", + artifacts.len() + ); + } + Ok(artifacts[0].clone()) +} + +fn resolve_path_relative_to_config(config_path: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else { + config_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .join(path) + } +} + +fn resolve_source_format(source: &SourceSpec, path: &Path) -> Result { + if let Some(format) = &source.format { + return format + .parse() + .with_context(|| format!("unknown source format '{format}' for '{}'", source.id)); + } + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .ok_or_else(|| { + anyhow::anyhow!( + "source '{}' has no format and no detectable extension", + source.id + ) + })?; + extension + .parse() + .with_context(|| format!("cannot infer a Renderflow format from extension '.{extension}'")) +} + +fn register_builtin_strategy_edges( + graph: &mut TransformGraph, + tools: &mut ToolRegistry, +) -> Result<()> { + let document_inputs = [ + Format::Markdown, + Format::Html, + Format::Docx, + Format::Epub, + Format::Rst, + Format::Latex, + ]; + let document_outputs = [Format::Html, Format::Pdf, Format::Docx]; + for from in document_inputs { + for to in document_outputs { + if from == to { + continue; + } + let capability = transform_capability_id(from, to); + let provider = ToolId::new("tool.pandoc")?; + tools.add_capability(&provider, capability.clone())?; + let mut edge = TransformEdge::new(from, to, 1.0, 0.97) + .with_provider(provider.to_string(), capability.to_string()) + .with_evidence("adapter", BUILTIN_ADAPTER_EVIDENCE) + .with_evidence("transform_id", format!("builtin.{from}.{to}")); + if to == Format::Pdf { + let tectonic = ToolId::new("tool.tectonic")?; + tools.add_capability(&tectonic, capability)?; + edge = edge.with_required_provider(tectonic.to_string()); + } + graph.add_transform(edge); + } + } + + let image_formats = [ + Format::Jpeg, + Format::Png, + Format::Tiff, + Format::Webp, + Format::Gif, + Format::Bmp, + Format::Avif, + ]; + for from in image_formats { + for to in image_formats { + if from == to || output_type_for_format(to).is_none() { + continue; + } + add_ffmpeg_edge(graph, tools, from, to, "image")?; + } + } + + let audio_formats = [ + Format::Wav, + Format::Aiff, + Format::Bwf, + Format::Pcm, + Format::Flac, + Format::M4aAlac, + Format::Wv, + Format::Ape, + Format::Tta, + Format::Dsf, + Format::Dff, + Format::Shn, + Format::Mp3, + Format::M4aAac, + Format::Aac, + Format::Ogg, + Format::Opus, + Format::Wma, + Format::Amr, + Format::Mp2, + Format::Ra, + Format::Oma, + Format::Ac3, + Format::Ec3, + Format::Thd, + Format::Dts, + Format::DtsHd, + Format::Midi, + Format::Mod, + ]; + for from in audio_formats { + for to in audio_formats { + if from == to || output_type_for_format(to).is_none() { + continue; + } + add_ffmpeg_edge(graph, tools, from, to, "audio")?; + } + } + Ok(()) +} + +fn add_ffmpeg_edge( + graph: &mut TransformGraph, + tools: &mut ToolRegistry, + from: Format, + to: Format, + family: &str, +) -> Result<()> { + let capability = transform_capability_id(from, to); + let provider = ToolId::new("tool.ffmpeg")?; + tools.add_capability(&provider, capability.clone())?; + graph.add_transform( + TransformEdge::new(from, to, 1.0, 0.92) + .with_provider(provider.to_string(), capability.to_string()) + .with_evidence("adapter", BUILTIN_ADAPTER_EVIDENCE) + .with_evidence("family", family) + .with_evidence("transform_id", format!("builtin.{from}.{to}")), + ); + Ok(()) +} + +fn apply_execution_policy( + graph: &TransformGraph, + tools: &ToolRegistry, + spec: &SpecV2, +) -> TransformGraph { + graph.filtered_by(|edge| edge_allowed(edge, tools, spec)) +} + +fn edge_allowed(edge: &TransformEdge, tools: &ToolRegistry, spec: &SpecV2) -> bool { + if spec + .execution + .minimum_fidelity + .is_some_and(|minimum| edge.quality < minimum) + { + return false; + } + let transform_id = edge.evidence.get("transform_id"); + if let Some(transform_id) = transform_id { + if spec + .execution + .transforms + .deny + .iter() + .any(|denied| denied == transform_id) + { + return false; + } + if !spec.execution.transforms.allow.is_empty() + && !spec + .execution + .transforms + .allow + .iter() + .any(|allowed| allowed == transform_id) + { + return false; + } + } else if !spec.execution.transforms.allow.is_empty() { + return false; + } + + for provider in edge_provider_ids(edge) { + if spec + .execution + .tools + .deny + .iter() + .any(|denied| denied == provider) + { + return false; + } + if !spec.execution.tools.allow.is_empty() + && !spec + .execution + .tools + .allow + .iter() + .any(|allowed| allowed == provider) + { + return false; + } + let Some(descriptor) = tools.get(provider) else { + return false; + }; + if spec.execution.requirements.deterministic + && descriptor.determinism != ToolDeterminism::Deterministic + { + return false; + } + if spec.execution.requirements.local_only + && !matches!( + descriptor.locality, + ToolLocality::Local | ToolLocality::LocalService + ) + { + return false; + } + if spec.execution.requirements.offline + && !matches!( + descriptor.locality, + ToolLocality::Local | ToolLocality::LocalService + ) + { + return false; + } + if matches!(spec.execution.network, crate::spec::NetworkPolicy::Deny) + && descriptor.locality == ToolLocality::NetworkRequired + { + return false; + } + if provider.starts_with("tool.ai.") { + match spec.execution.ai { + AiPolicy::Deny => return false, + AiPolicy::LocalOnly + if !matches!( + descriptor.locality, + ToolLocality::Local | ToolLocality::LocalService + ) => + { + return false; + } + AiPolicy::LocalOnly | AiPolicy::Allow => {} + } + } + } + true +} + +fn edge_provider_ids(edge: &TransformEdge) -> Vec<&str> { + edge.provider_id + .iter() + .map(String::as_str) + .chain(edge.required_provider_ids.iter().map(String::as_str)) + .collect() +} + +fn resolve_target_intent( + spec: &SpecV2, + graph: &TransformGraph, + source: Format, +) -> Result> { + let reachable = graph.reachable_from(source); + let mut selected = Vec::new(); + + for target in &spec.targets.exact { + extend_target_spec(&mut selected, target, graph, source, &reachable)?; + } + for profile_name in &spec.targets.profiles { + let profile = spec + .profiles + .get(profile_name) + .ok_or_else(|| anyhow::anyhow!("target profile '{profile_name}' is not defined"))?; + for target in &profile.targets { + extend_target_spec(&mut selected, target, graph, source, &reachable)?; + } + extend_selector(&mut selected, &profile.include, graph, source, &reachable)?; + apply_exclusions(&mut selected, &profile.exclude, graph); + } + if spec.targets.all_reachable { + for format in &reachable { + insert_target(&mut selected, ResolvedTarget::generated(*format))?; + } + } + if !spec.targets.include.is_empty() { + selected.retain(|target| selector_matches(target.format, &spec.targets.include, graph)); + } + apply_exclusions(&mut selected, &spec.targets.exclude, graph); + selected.sort_by(|left, right| left.format.to_string().cmp(&right.format.to_string())); + Ok(selected) +} + +fn extend_target_spec( + selected: &mut Vec, + target: &TargetSpec, + graph: &TransformGraph, + source: Format, + reachable: &[Format], +) -> Result<()> { + let mut candidates: Vec = if let Some(format) = &target.format { + vec![format + .parse() + .with_context(|| format!("unknown target format '{format}'"))?] + } else { + reachable.to_vec() + }; + if let Some(family) = &target.family { + candidates.retain(|format| format_in_family(*format, family)); + } + if let Some(capability) = &target.capability { + candidates.retain(|format| { + graph + .transforms_to(*format) + .iter() + .any(|edge| edge.capability_id.as_deref() == Some(capability.as_str())) + }); + if candidates.is_empty() + && capability == crate::super_resolution::SUPER_RESOLUTION_CAPABILITY_ID + { + candidates.push(source); + } + } + if let Some(transform) = &target.transform { + candidates.retain(|format| { + graph.transforms_to(*format).iter().any(|edge| { + edge.evidence.get("transform_id").map(String::as_str) == Some(transform.as_str()) + }) + }); + } + for format in candidates { + insert_target(selected, ResolvedTarget::from_spec(format, target))?; + } + Ok(()) +} + +fn extend_selector( + selected: &mut Vec, + selector: &SelectorSet, + graph: &TransformGraph, + _source: Format, + reachable: &[Format], +) -> Result<()> { + for format in reachable { + if selector_matches(*format, selector, graph) { + insert_target(selected, ResolvedTarget::generated(*format))?; + } + } + Ok(()) +} + +fn insert_target(selected: &mut Vec, candidate: ResolvedTarget) -> Result<()> { + if let Some(existing) = selected + .iter_mut() + .find(|target| target.format == candidate.format) + { + if *existing == candidate || is_generated(existing) { + if is_generated(existing) { + *existing = candidate; + } + return Ok(()); + } + if is_generated(&candidate) { + return Ok(()); + } + anyhow::bail!( + "multiple distinct target configurations resolve to format '{}'; format-only DAG nodes cannot represent parallel same-format variants until Transform v2 (#357)", + candidate.format + ); + } + selected.push(candidate); + Ok(()) +} + +fn is_generated(target: &ResolvedTarget) -> bool { + target.id.is_none() + && target.preset.is_none() + && target.template.is_none() + && target.variant.is_none() +} + +fn apply_exclusions( + selected: &mut Vec, + selector: &SelectorSet, + graph: &TransformGraph, +) { + if selector.is_empty() { + return; + } + selected.retain(|target| !selector_matches(target.format, selector, graph)); +} + +fn selector_matches(format: Format, selector: &SelectorSet, graph: &TransformGraph) -> bool { + let mut has_format_selector = false; + let mut matches = false; + if !selector.formats.is_empty() { + has_format_selector = true; + matches |= selector + .formats + .iter() + .any(|value| value.parse::().ok() == Some(format)); + } + if !selector.families.is_empty() { + has_format_selector = true; + matches |= selector + .families + .iter() + .any(|family| format_in_family(format, family)); + } + if !selector.capabilities.is_empty() { + has_format_selector = true; + matches |= graph.transforms_to(format).iter().any(|edge| { + edge.capability_id + .as_ref() + .is_some_and(|capability| selector.capabilities.contains(capability)) + }); + } + if !selector.transforms.is_empty() { + has_format_selector = true; + matches |= graph.transforms_to(format).iter().any(|edge| { + edge.evidence + .get("transform_id") + .is_some_and(|transform| selector.transforms.contains(transform)) + }); + } + if !selector.profiles.is_empty() { + has_format_selector = true; + } + if !has_format_selector && !selector.variants.is_empty() { + return true; + } + matches +} + +fn format_in_family(format: Format, family: &str) -> bool { + let family = match family.to_ascii_lowercase().as_str() { + "document" => FormatFamily::Document, + "image" => FormatFamily::Image, + "audio" => FormatFamily::Audio, + "video" => FormatFamily::Video, + "archive" => FormatFamily::Archive, + "data" => FormatFamily::Data, + "subtitle" => FormatFamily::Subtitle, + "presentation" => FormatFamily::Presentation, + "spreadsheet" => FormatFamily::Spreadsheet, + _ => return false, + }; + FormatCapabilityRegistry::global() + .get(format) + .is_some_and(|descriptor| descriptor.is_in_family(family)) +} + +fn unsupported_targets_error( + graph: &TransformGraph, + source: Format, + targets: &[Format], +) -> anyhow::Error { + let unreachable = targets + .iter() + .filter(|target| **target != source && graph.find_path(source, **target).is_none()) + .map(ToString::to_string) + .collect::>() + .join(", "); + anyhow::anyhow!( + "no policy-allowed transformation path from '{}' to requested target(s): {}", + source, + unreachable + ) +} + +fn register_builtin_strategy_executors( + executor: &mut DagExecutor, + dag: &MultiTargetDag, + spec: &SpecV2, + targets: &[ResolvedTarget], + source_format: Format, + source_path: &Path, +) -> Result<()> { + let source_root = source_path.parent().map(Path::to_path_buf); + for edge in dag.all_edges() { + if edge.evidence.get("adapter").map(String::as_str) != Some(BUILTIN_ADAPTER_EVIDENCE) { + continue; + } + let target = targets.iter().find(|target| target.format == edge.to); + let template = target.and_then(|target| target.template.clone()); + let profile = target.and_then(|target| target.preset.clone()); + let asset_root = if edge.from == source_format && document_input_format(edge.from).is_some() + { + source_root.clone() + } else { + None + }; + let transform = StrategyArtifactTransform::new( + edge.from, + edge.to, + template, + profile, + spec.variables.clone(), + asset_root, + )?; + executor.register_artifact(edge.from, edge.to, Arc::new(transform)); + } + Ok(()) +} + +fn selected_provider_ids(dag: &MultiTargetDag) -> BTreeSet { + dag.all_edges() + .iter() + .flat_map(|edge| { + edge.provider_id + .iter() + .cloned() + .chain(edge.required_provider_ids.iter().cloned()) + }) + .collect() +} + +fn preflight_selected_providers(resolved: &ResolvedExecution) -> Result<()> { + let ids = selected_provider_ids(&resolved.dag); + let inventory = resolved.tool_registry.assess_ids_current(ids.iter()); + let blocked: Vec = inventory + .tools + .iter() + .filter(|tool| !tool.is_available()) + .map(|tool| tool.summary()) + .collect(); + if !blocked.is_empty() { + anyhow::bail!( + "execution preflight failed before any transform ran:\n{}", + blocked.join("\n") + ); + } + Ok(()) +} + +fn validate_pre_execution_budgets(resolved: &ResolvedExecution) -> Result<()> { + let budgets = &resolved.spec.execution.budgets; + if let Some(max_depth) = budgets.max_depth { + if resolved.plan.metadata.execution_depth as u32 > max_depth { + anyhow::bail!( + "execution plan depth {} exceeds max_depth budget {}", + resolved.plan.metadata.execution_depth, + max_depth + ); + } + } + if let Some(max_artifacts) = budgets.max_artifacts { + if resolved.plan.metadata.total_nodes as u64 > max_artifacts { + anyhow::bail!( + "execution plan artifact estimate {} exceeds max_artifacts budget {}", + resolved.plan.metadata.total_nodes, + max_artifacts + ); + } + } + Ok(()) +} + +fn validate_post_execution_budgets( + resolved: &ResolvedExecution, + artifacts: &HashMap, +) -> Result<()> { + let budgets = &resolved.spec.execution.budgets; + let target_formats: HashSet = resolved + .targets + .iter() + .map(|target| target.format) + .collect(); + if let Some(max_output) = budgets.max_output_bytes { + let total: u64 = artifacts + .iter() + .filter(|(format, _)| target_formats.contains(format)) + .map(|(_, artifact)| artifact.size_bytes()) + .sum(); + if total > max_output { + anyhow::bail!( + "produced output bytes {total} exceed max_output_bytes budget {max_output}" + ); + } + } + if let Some(max_storage) = budgets.max_storage_bytes { + let total: u64 = artifacts + .values() + .map(|artifact| artifact.size_bytes()) + .sum(); + if total > max_storage { + anyhow::bail!( + "execution artifact bytes {total} exceed max_storage_bytes budget {max_storage}" + ); + } + } + Ok(()) +} + +fn render_output_paths(resolved: &ResolvedExecution) -> Result> { + let root = PathBuf::from(&resolved.spec.output.bundle_root); + let source_stem = resolved + .source_path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("artifact"); + let mut paths = Vec::new(); + let mut seen = HashMap::::new(); + for target in &resolved.targets { + let relative = if resolved.source_version == SourceSpecVersion::V1 { + PathBuf::from(format!("{source_stem}.{}", target.format)) + } else { + let format_string = target.format.to_string(); + let target_role = target.role.as_deref().unwrap_or(format_string.as_str()); + let target_id = target.id.as_deref().unwrap_or(target_role); + let source_role = resolved + .source + .role + .as_deref() + .unwrap_or(resolved.source.id.as_str()); + let mut rendered = resolved.spec.output.naming_template.clone(); + rendered = rendered.replace("{source.id}", &resolved.source.id); + rendered = rendered.replace("{source.role}", source_role); + rendered = rendered.replace("{target.id}", target_id); + rendered = rendered.replace("{target.role}", target_role); + rendered = rendered.replace("{target.format}", &format_string); + rendered = rendered.replace("{ext}", &format_string); + let path = PathBuf::from(rendered); + validate_relative_output_path(&path)?; + path + }; + let mut destination = root.join(&relative); + let count = seen.entry(destination.clone()).or_insert(0); + if *count > 0 { + match resolved.spec.output.collision { + CollisionPolicy::Error => anyhow::bail!( + "multiple selected targets resolve to output path '{}'", + destination.display() + ), + CollisionPolicy::Replace => {} + CollisionPolicy::Dedupe => { + destination = dedupe_path(&destination, *count + 1); + } + } + } + *count += 1; + paths.push(destination); + } + Ok(paths) +} + +fn validate_relative_output_path(path: &Path) -> Result<()> { + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + anyhow::bail!( + "output naming template resolved outside bundle root: '{}'", + path.display() + ); + } + Ok(()) +} + +fn dedupe_path(path: &Path, index: usize) -> PathBuf { + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("artifact"); + let extension = path.extension().and_then(|value| value.to_str()); + let name = match extension { + Some(extension) => format!("{stem}-{index}.{extension}"), + None => format!("{stem}-{index}"), + }; + path.with_file_name(name) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::spec::{ExecutionPolicy, OutputLayout, SPEC_V2_ID}; + + fn minimal_spec(source_format: &str) -> SpecV2 { + SpecV2 { + schema: SPEC_V2_ID.to_string(), + sources: vec![SourceSpec { + id: "source.main".to_string(), + role: None, + kind: SourceKind::Artifact, + path: Some(format!("input.{source_format}")), + uri: None, + members: Vec::new(), + media_type: None, + format: Some(source_format.to_string()), + detect: false, + immutable: true, + }], + profiles: BTreeMap::new(), + targets: TargetSelection::default(), + execution: ExecutionPolicy::default(), + output: OutputLayout::default(), + variables: BTreeMap::new(), + transforms: None, + } + } + + #[test] + fn exact_and_profile_targets_share_resolver() { + let mut spec = minimal_spec("markdown"); + spec.targets.exact.push(TargetSpec { + id: Some("target.html".to_string()), + role: Some("web".to_string()), + format: Some("html".to_string()), + family: None, + capability: None, + transform: None, + variant: None, + preset: None, + template: None, + }); + let mut graph = TransformGraph::new(); + graph.add_transform(TransformEdge::new(Format::Markdown, Format::Html, 1.0, 1.0)); + let targets = resolve_target_intent(&spec, &graph, Format::Markdown).unwrap(); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].format, Format::Html); + assert_eq!(targets[0].role.as_deref(), Some("web")); + } + + #[test] + fn all_reachable_include_and_exclude_use_same_selector_model() { + let mut spec = minimal_spec("markdown"); + spec.targets.all_reachable = true; + spec.targets.include.families.push("document".to_string()); + spec.targets.exclude.formats.push("pdf".to_string()); + let mut graph = TransformGraph::new(); + graph.add_transform(TransformEdge::new(Format::Markdown, Format::Html, 1.0, 1.0)); + graph.add_transform(TransformEdge::new(Format::Markdown, Format::Pdf, 1.0, 0.9)); + let targets = resolve_target_intent(&spec, &graph, Format::Markdown).unwrap(); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].format, Format::Html); + } + + #[test] + fn policy_filters_denied_provider_before_pathfinding() { + let spec = minimal_spec("markdown"); + let mut graph = TransformGraph::new(); + graph.add_transform( + TransformEdge::new(Format::Markdown, Format::Html, 1.0, 1.0) + .with_provider("tool.pandoc", "document.convert"), + ); + let mut denied = spec.clone(); + denied.execution.tools.deny.push("tool.pandoc".to_string()); + let filtered = apply_execution_policy(&graph, &ToolRegistry::builtins(), &denied); + assert!(filtered.transforms_from(Format::Markdown).is_empty()); + } + + #[test] + fn output_template_rejects_parent_traversal() { + assert!(validate_relative_output_path(Path::new("../escape.pdf")).is_err()); + assert!(validate_relative_output_path(Path::new("safe/output.pdf")).is_ok()); + } +} diff --git a/crates/renderflow-core/src/sdk.rs b/crates/renderflow-core/src/sdk.rs index 90f0bd3..c3578ed 100644 --- a/crates/renderflow-core/src/sdk.rs +++ b/crates/renderflow-core/src/sdk.rs @@ -7,10 +7,12 @@ use std::sync::{ use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::commands; -use crate::config::load_config; use crate::graph::ExecutionPlan; use crate::optimization::OptimizationMode; +use crate::planning::{ + execute as execute_resolved_plan, resolve as resolve_planning_request, PlanningRequest, + ResolvedExecution, +}; use crate::toolchain::ToolchainSnapshot; #[derive(Debug, Error)] @@ -130,6 +132,7 @@ impl ExecutionRequest { pub fn with_target(mut self, target: impl Into) -> Self { self.target = Some(target.into()); + self.all_targets = false; self } @@ -237,110 +240,100 @@ impl Engine { pub fn inspect(&self, request: InspectionRequest) -> Result { self.ensure_not_cancelled()?; - self.emit(ProgressStage::Inspecting, "Loading configuration"); - - let config = load_config(request.config_path.to_str().ok_or_else(|| { - RenderflowError::Configuration(anyhow::anyhow!( - "Config path contains non-UTF8 characters" - )) - })?) - .map_err(RenderflowError::Configuration)?; - - self.emit(ProgressStage::Completed, "Inspection complete"); - - Ok(ArtifactProfile { - input_path: config.input.clone(), - input_format: config.input_format().to_string(), - output_dir: config.output_dir.clone(), - targets: config - .outputs + self.emit( + ProgressStage::Inspecting, + "Resolving canonical execution context", + ); + let resolved = resolve_planning_request(PlanningRequest::from_path(&request.config_path)) + .map_err(RenderflowError::Configuration)?; + let profile = ArtifactProfile { + input_path: resolved.source_path().display().to_string(), + input_format: resolved.source_format().to_string(), + output_dir: resolved.spec().output.bundle_root.clone(), + targets: resolved + .target_formats() .iter() - .map(|output| output.output_type.to_string()) + .map(ToString::to_string) .collect(), - transforms_path: config.transforms.clone(), - }) + transforms_path: resolved.spec().transforms.clone(), + }; + self.emit(ProgressStage::Completed, "Inspection complete"); + Ok(profile) } pub fn plan(&self, request: PlanRequest) -> Result { self.ensure_not_cancelled()?; - self.emit(ProgressStage::Planning, "Constructing execution plan"); - let config_path = request.config_path.to_str().ok_or_else(|| { - RenderflowError::Planning(anyhow::anyhow!("Config path contains non-UTF8 characters")) - })?; - let (plan, _targets) = commands::graph::load_plan( - config_path, - request.target.as_deref(), - request.optimization, - ) - .map_err(RenderflowError::Planning)?; + self.emit( + ProgressStage::Planning, + "Constructing canonical execution plan", + ); + let mut planning = PlanningRequest::from_path(&request.config_path); + if let Some(target) = request.target { + planning = planning.with_target(target); + } + if let Some(optimization) = request.optimization { + planning = planning.with_optimization(optimization); + } + let resolved = resolve_planning_request(planning).map_err(RenderflowError::Planning)?; + let plan = resolved.plan().clone(); self.emit(ProgressStage::Completed, "Planning complete"); Ok(plan) } - pub fn execute(&self, request: ExecutionRequest) -> Result { + /// Resolve an execution request into the frozen plan/runtime object that + /// [`Engine::execute_resolved`] consumes without re-planning. + pub fn resolve_execution( + &self, + request: ExecutionRequest, + ) -> Result { self.ensure_not_cancelled()?; - self.emit(ProgressStage::Executing, "Executing renderflow pipeline"); - - let config_path = request.config_path.to_str().ok_or_else(|| { - RenderflowError::Execution(anyhow::anyhow!("Config path contains non-UTF8 characters")) - })?; - - let config = load_config(config_path).map_err(RenderflowError::Execution)?; - - let toolchain = if request.target.is_some() || request.all_targets { - let (plan, _targets) = commands::graph::load_plan( - config_path, - request.target.as_deref(), - request.optimization, - ) - .map_err(RenderflowError::Execution)?; - plan.toolchain - } else { - None - }; - - if let Some(target) = request.target.as_deref() { - commands::graph_build::run_target( - config_path, - target, - request.dry_run, - request.optimization, - ) - .map_err(RenderflowError::Execution)?; + self.emit(ProgressStage::Planning, "Resolving execution request"); + let mut planning = PlanningRequest::from_path(&request.config_path); + if let Some(target) = request.target { + planning = planning.with_target(target); } else if request.all_targets { - commands::graph_build::run_all(config_path, request.dry_run, request.optimization) - .map_err(RenderflowError::Execution)?; - } else { - commands::build::run(config_path, request.dry_run, request.optimization) - .map_err(RenderflowError::Execution)?; + planning = planning.with_all_reachable(); + } + if let Some(optimization) = request.optimization { + planning = planning.with_optimization(optimization); } + resolve_planning_request(planning).map_err(RenderflowError::Planning) + } + /// Execute an already-resolved plan without implicit re-planning. + pub fn execute_resolved( + &self, + resolved: ResolvedExecution, + dry_run: bool, + ) -> Result { + self.ensure_not_cancelled()?; + self.emit( + ProgressStage::Executing, + "Executing resolved renderflow plan", + ); + let result = + execute_resolved_plan(resolved, dry_run).map_err(RenderflowError::Execution)?; self.emit(ProgressStage::Completed, "Execution complete"); - - let outputs = if let Some(target) = request.target { - vec![target] - } else { - config - .outputs - .iter() - .map(|output| output.output_type.to_string()) - .collect() - }; - Ok(ExecutionResult { manifest: ArtifactManifest { - output_dir: config.output_dir, - outputs, + output_dir: result.output_dir, + outputs: result.outputs, }, reused_cached_outputs: Vec::new(), skipped_transforms: Vec::new(), diagnostics: DiagnosticReport { - warnings: Vec::new(), + warnings: result.diagnostics, recoverable_failures: Vec::new(), }, - toolchain, + toolchain: result.toolchain, }) } + + pub fn execute(&self, request: ExecutionRequest) -> Result { + let dry_run = request.dry_run; + let resolved = self.resolve_execution(request)?; + self.execute_resolved(resolved, dry_run) + } } #[cfg(test)] @@ -364,4 +357,14 @@ mod tests { assert!(request.target.is_none()); assert!(request.all_targets); } + + #[test] + fn execution_request_with_target_clears_all_targets() { + let request = ExecutionRequest::from_path("renderflow.yaml") + .with_all_targets() + .with_target("html"); + + assert_eq!(request.target.as_deref(), Some("html")); + assert!(!request.all_targets); + } } diff --git a/crates/renderflow-core/src/spec.rs b/crates/renderflow-core/src/spec.rs index e622069..bcf5452 100644 --- a/crates/renderflow-core/src/spec.rs +++ b/crates/renderflow-core/src/spec.rs @@ -762,7 +762,7 @@ pub fn validate_spec_str(content: &str) -> SpecValidationReport { fn validate_v1_compat(content: &str) -> SpecValidationReport { match serde_yaml_ng::from_str::(content) { - Ok(config) => match config.validate() { + Ok(config) => match config.validate_structure() { Ok(()) => { let migrated = migrate_v1_config(&config); let diagnostics = migrated.validate(); @@ -866,7 +866,7 @@ pub fn migrate_v1_str(content: &str) -> Result { } let config: Config = serde_yaml_ng::from_str(content).context("failed to parse v1 config")?; - config.validate()?; + config.validate_structure()?; let migrated = migrate_v1_config(&config); let diagnostics = migrated.validate(); if !diagnostics.is_empty() { @@ -881,6 +881,14 @@ pub fn migrate_v1_str(content: &str) -> Result { Ok(migrated) } +fn legacy_source_format(config: &Config) -> String { + std::path::Path::new(&config.input) + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .unwrap_or_else(|| config.input_format().to_string()) +} + pub(crate) fn migrate_v1_config(config: &Config) -> SpecV2 { let exact = config .outputs @@ -912,7 +920,7 @@ pub(crate) fn migrate_v1_config(config: &Config) -> SpecV2 { uri: None, members: Vec::new(), media_type: None, - format: Some(config.input_format().to_string()), + format: Some(legacy_source_format(config)), detect: config.input_format.is_none(), immutable: true, }], diff --git a/crates/renderflow-core/src/template.rs b/crates/renderflow-core/src/template.rs deleted file mode 100644 index c275844..0000000 --- a/crates/renderflow-core/src/template.rs +++ /dev/null @@ -1,394 +0,0 @@ -use anyhow::{Context, Result}; -use std::path::{Path, PathBuf}; -use tera::Tera; - -use crate::config::OutputConfig; - -fn resolve_template_dir(template_dir: &str) -> PathBuf { - let direct = Path::new(template_dir); - if direct.exists() { - return direct.to_path_buf(); - } - - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let crate_relative = manifest_dir.join(template_dir); - if crate_relative.exists() { - return crate_relative; - } - - let workspace_relative = manifest_dir.join("../..").join(template_dir); - if workspace_relative.exists() { - return workspace_relative; - } - - direct.to_path_buf() -} - -/// Initialise a Tera template engine and load all `*.html` files found under -/// `template_dir`. -/// -/// If the directory does not exist or contains no matching files, the function -/// still succeeds and returns an empty Tera instance so that the rest of the -/// pipeline can continue without templates. An error is only returned when -/// Tera encounters an invalid glob pattern or a template that fails to parse. -pub fn init_tera(template_dir: &str) -> Result { - let template_dir = resolve_template_dir(template_dir); - let glob = format!("{}/**/*.html", template_dir.display()); - let tera = Tera::new(&glob).with_context(|| { - format!( - "Failed to initialise Tera from template directory: {}", - template_dir.display() - ) - })?; - Ok(tera) -} - -/// Validate that every configured template file exists in `template_dir`. -/// -/// This covers all output types (HTML, PDF, DOCX). All missing-template -/// errors are collected and reported together so that users see every problem -/// at once instead of discovering them one at a time during rendering. -/// -/// Returns `Ok(())` when no templates are configured or all configured -/// templates are present on disk. -pub fn validate_templates(outputs: &[OutputConfig], template_dir: &str) -> Result<()> { - let errors: Vec = outputs - .iter() - .filter_map(|output| { - let name = output.template.as_ref()?; - let path = resolve_template_dir(template_dir).join(name); - if !path.exists() { - Some(format!( - " - {} template '{}' not found at '{}'", - output.output_type, - name, - path.display() - )) - } else { - None - } - }) - .collect(); - - if !errors.is_empty() { - anyhow::bail!( - "Template validation failed — the following templates were not found:\n{}", - errors.join("\n") - ); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{OutputConfig, OutputType}; - use std::fs; - use tempfile::TempDir; - - fn write_template(dir: &TempDir, name: &str, content: &str) { - let path = dir.path().join(name); - fs::write(path, content).expect("failed to write template"); - } - - #[test] - fn test_init_tera_with_valid_template_dir() { - let dir = TempDir::new().unwrap(); - write_template(&dir, "default.html", "{{ body }}"); - let tera = init_tera(dir.path().to_str().unwrap()); - assert!(tera.is_ok(), "expected Tera to initialise successfully"); - let tera = tera.unwrap(); - assert!( - tera.get_template_names() - .any(|n| n.contains("default.html")), - "expected 'default.html' to be loaded" - ); - } - - #[test] - fn test_init_tera_with_empty_template_dir() { - let dir = TempDir::new().unwrap(); - let tera = init_tera(dir.path().to_str().unwrap()); - assert!( - tera.is_ok(), - "expected Tera to initialise with no templates" - ); - } - - #[test] - fn test_init_tera_with_nonexistent_dir() { - let tera = init_tera("/nonexistent/template/dir"); - assert!( - tera.is_ok(), - "expected Tera to handle missing directory gracefully" - ); - } - - // ── validate_templates ──────────────────────────────────────────────────── - - #[test] - fn test_validate_templates_passes_when_no_templates_configured() { - // Outputs without a template field must always pass, even when the - // template directory does not exist. - let outputs = vec![ - OutputConfig { - output_type: OutputType::Html, - template: None, - profile: None, - }, - OutputConfig { - output_type: OutputType::Pdf, - template: None, - profile: None, - }, - OutputConfig { - output_type: OutputType::Docx, - template: None, - profile: None, - }, - ]; - assert!( - validate_templates(&outputs, "/nonexistent/dir").is_ok(), - "validation should pass when no templates are configured" - ); - } - - #[test] - fn test_validate_templates_passes_when_all_templates_exist() { - let dir = TempDir::new().unwrap(); - fs::write(dir.path().join("custom.html"), "").unwrap(); - fs::write(dir.path().join("template.tex"), "").unwrap(); - fs::write(dir.path().join("reference.docx"), "").unwrap(); - - let outputs = vec![ - OutputConfig { - output_type: OutputType::Html, - template: Some("custom.html".to_string()), - profile: None, - }, - OutputConfig { - output_type: OutputType::Pdf, - template: Some("template.tex".to_string()), - profile: None, - }, - OutputConfig { - output_type: OutputType::Docx, - template: Some("reference.docx".to_string()), - profile: None, - }, - ]; - assert!( - validate_templates(&outputs, dir.path().to_str().unwrap()).is_ok(), - "validation should pass when all templates exist" - ); - } - - #[test] - fn test_validate_templates_fails_on_missing_pdf_template() { - let dir = TempDir::new().unwrap(); - let outputs = vec![OutputConfig { - output_type: OutputType::Pdf, - template: Some("missing.tex".to_string()), - profile: None, - }]; - let result = validate_templates(&outputs, dir.path().to_str().unwrap()); - assert!(result.is_err(), "expected error for missing PDF template"); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("missing.tex"), - "error should mention the missing template file: {}", - msg - ); - } - - #[test] - fn test_validate_templates_fails_on_missing_docx_template() { - let dir = TempDir::new().unwrap(); - let outputs = vec![OutputConfig { - output_type: OutputType::Docx, - template: Some("missing.docx".to_string()), - profile: None, - }]; - let result = validate_templates(&outputs, dir.path().to_str().unwrap()); - assert!(result.is_err(), "expected error for missing DOCX template"); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("missing.docx"), - "error should mention the missing template file: {}", - msg - ); - } - - #[test] - fn test_validate_templates_fails_on_missing_html_template() { - let dir = TempDir::new().unwrap(); - let outputs = vec![OutputConfig { - output_type: OutputType::Html, - template: Some("missing.html".to_string()), - profile: None, - }]; - let result = validate_templates(&outputs, dir.path().to_str().unwrap()); - assert!(result.is_err(), "expected error for missing HTML template"); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("missing.html"), - "error should mention the missing template file: {}", - msg - ); - } - - #[test] - fn test_validate_templates_reports_all_missing_templates() { - // All missing templates should be listed in a single error so users - // can fix all problems at once without repeated build-fail cycles. - let dir = TempDir::new().unwrap(); - let outputs = vec![ - OutputConfig { - output_type: OutputType::Pdf, - template: Some("missing.tex".to_string()), - profile: None, - }, - OutputConfig { - output_type: OutputType::Docx, - template: Some("missing.docx".to_string()), - profile: None, - }, - ]; - let result = validate_templates(&outputs, dir.path().to_str().unwrap()); - assert!( - result.is_err(), - "expected error when multiple templates are missing" - ); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("missing.tex"), - "error should mention missing.tex: {}", - msg - ); - assert!( - msg.contains("missing.docx"), - "error should mention missing.docx: {}", - msg - ); - } - - #[test] - fn test_validate_templates_error_message_includes_output_type() { - let dir = TempDir::new().unwrap(); - let outputs = vec![OutputConfig { - output_type: OutputType::Pdf, - template: Some("my.tex".to_string()), - profile: None, - }]; - let result = validate_templates(&outputs, dir.path().to_str().unwrap()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("pdf"), - "error should mention the output type so users know which output is affected: {}", - msg - ); - } - - // ── research template ───────────────────────────────────────────────────── - - #[test] - fn test_research_tex_template_exists() { - let path = resolve_template_dir("templates").join("research/research.tex"); - assert!( - path.exists(), - "research LaTeX template must exist at templates/research/research.tex" - ); - } - - #[test] - fn test_research_html_template_exists() { - let path = resolve_template_dir("templates").join("research/research.html"); - assert!( - path.exists(), - "research HTML template must exist at templates/research/research.html" - ); - } - - #[test] - fn test_research_tex_template_contains_pandoc_variables() { - let content = - fs::read_to_string(resolve_template_dir("templates").join("research/research.tex")) - .expect("failed to read research.tex template"); - assert!( - content.contains("$title$"), - "research.tex should interpolate $$title$$" - ); - assert!( - content.contains("$author$"), - "research.tex should interpolate $$author$$" - ); - assert!( - content.contains("$date$"), - "research.tex should interpolate $$date$$" - ); - assert!( - content.contains("$abstract$"), - "research.tex should interpolate $$abstract$$" - ); - assert!( - content.contains("$body$"), - "research.tex should contain $$body$$ for document content" - ); - assert!( - content.contains("$toc$") || content.contains("$if(toc)$"), - "research.tex should support optional table of contents" - ); - } - - #[test] - fn test_research_tex_template_is_valid_latex_document() { - let content = - fs::read_to_string(resolve_template_dir("templates").join("research/research.tex")) - .expect("failed to read research.tex template"); - assert!( - content.contains("\\documentclass"), - "research.tex should begin with a \\documentclass declaration" - ); - assert!( - content.contains("\\begin{document}"), - "research.tex must have \\begin{{document}}" - ); - assert!( - content.contains("\\end{document}"), - "research.tex must have \\end{{document}}" - ); - } - - #[test] - fn test_validate_templates_accepts_research_tex() { - let outputs = vec![OutputConfig { - output_type: OutputType::Pdf, - template: Some("research/research.tex".to_string()), - profile: None, - }]; - // The template_dir is the workspace-relative "templates" folder. - let result = validate_templates(&outputs, "templates"); - assert!( - result.is_ok(), - "validate_templates should accept the research LaTeX template: {:?}", - result.err() - ); - } - - #[test] - fn test_validate_templates_accepts_research_html() { - let outputs = vec![OutputConfig { - output_type: OutputType::Html, - template: Some("research/research.html".to_string()), - profile: None, - }]; - let result = validate_templates(&outputs, "templates"); - assert!( - result.is_ok(), - "validate_templates should accept the research HTML template: {:?}", - result.err() - ); - } -} diff --git a/crates/renderflow-core/src/toolchain.rs b/crates/renderflow-core/src/toolchain.rs index 8485d5d..4601f70 100644 --- a/crates/renderflow-core/src/toolchain.rs +++ b/crates/renderflow-core/src/toolchain.rs @@ -871,9 +871,12 @@ impl ToolRegistry { } self.fingerprint_selected_with_variants( inventory, - dag.all_edges() - .iter() - .filter_map(|edge| edge.provider_id.as_deref()), + dag.all_edges().iter().flat_map(|edge| { + edge.provider_id + .iter() + .map(String::as_str) + .chain(edge.required_provider_ids.iter().map(String::as_str)) + }), &variants, context, ) diff --git a/crates/renderflow-core/src/transforms/yaml_loader.rs b/crates/renderflow-core/src/transforms/yaml_loader.rs index 7938efd..a33d3cd 100644 --- a/crates/renderflow-core/src/transforms/yaml_loader.rs +++ b/crates/renderflow-core/src/transforms/yaml_loader.rs @@ -666,7 +666,8 @@ pub fn build_graph_executor_and_tools_from_str( graph.add_transform( TransformEdge::with_input_kind(from, to, def.cost, def.quality, input_kind) - .with_provider(provider.to_string(), capability.to_string()), + .with_provider(provider.to_string(), capability.to_string()) + .with_evidence("transform_id", def.name.clone()), ); if def.is_collection() { diff --git a/crates/renderflow-core/tests/canonical_planner.rs b/crates/renderflow-core/tests/canonical_planner.rs new file mode 100644 index 0000000..ec14010 --- /dev/null +++ b/crates/renderflow-core/tests/canonical_planner.rs @@ -0,0 +1,91 @@ +use std::fs; +use std::path::PathBuf; + +use renderflow::planning::{execute, resolve, PlanningRequest}; +use renderflow::spec::SourceSpecVersion; + +struct EquivalentConfigs { + _temp_dir: tempfile::TempDir, + v1: PathBuf, + v2: PathBuf, + v1_output: PathBuf, + v2_output: PathBuf, +} + +fn equivalent_configs() -> EquivalentConfigs { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + let input = temp_dir.path().join("input.md"); + fs::write(&input, "# Canonical planner\n").expect("failed to write source"); + + let v1_output = temp_dir.path().join("dist-v1"); + let v1 = temp_dir.path().join("renderflow-v1.yaml"); + fs::write( + &v1, + format!( + "input: \"{}\"\noutput_dir: \"{}\"\noutputs:\n - type: html\n", + input.display(), + v1_output.display() + ), + ) + .expect("failed to write v1 spec"); + + let v2_output = temp_dir.path().join("dist-v2"); + let v2 = temp_dir.path().join("renderflow-v2.yaml"); + fs::write( + &v2, + format!( + "schema: renderflow/v2\nsources:\n - id: source.main\n role: manuscript\n path: \"{}\"\n format: markdown\ntargets:\n exact:\n - id: target.html\n role: web\n format: html\noutput:\n bundle_root: \"{}\"\n naming_template: \"{{target.role}}.{{ext}}\"\n collision: error\n", + input.display(), + v2_output.display() + ), + ) + .expect("failed to write v2 spec"); + + EquivalentConfigs { + _temp_dir: temp_dir, + v1, + v2, + v1_output, + v2_output, + } +} + +#[test] +fn v1_and_v2_resolve_through_the_same_canonical_planner() { + let configs = equivalent_configs(); + let v1 = resolve(PlanningRequest::from_path(&configs.v1)).expect("v1 should resolve"); + let v2 = resolve(PlanningRequest::from_path(&configs.v2)).expect("v2 should resolve"); + + assert_eq!(v1.source_version(), SourceSpecVersion::V1); + assert_eq!(v2.source_version(), SourceSpecVersion::V2); + assert_eq!(v1.source_format(), v2.source_format()); + 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); +} + +#[test] +fn dry_run_returns_the_exact_frozen_plan_without_writing_outputs() { + let configs = equivalent_configs(); + let resolved = resolve(PlanningRequest::from_path(&configs.v2)).expect("v2 should resolve"); + 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"); + 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"); +} + +#[test] +fn v1_dry_run_is_also_side_effect_free() { + let configs = equivalent_configs(); + 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"); +} diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index bd9df97..b7d9ae2 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -349,7 +349,11 @@ fn test_dry_run_output_labeled() { .expect("failed to execute renderflow"); assert!(output.status.success(), "dry-run should exit with code 0"); - // Log messages (including [DRY RUN] prefixes) go to stderr. + let plan: serde_json::Value = serde_json::from_slice(&output.stdout) + .expect("dry-run stdout should be the canonical ExecutionPlan JSON"); + assert_eq!(plan["source"], "markdown"); + assert_eq!(plan["targets"], serde_json::json!(["html"])); + // Human-readable plan/output labels remain on stderr. let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("[DRY RUN]"), @@ -357,6 +361,29 @@ fn test_dry_run_output_labeled() { ); } + +#[test] +fn test_v2_dry_run_serializes_canonical_plan() { + let (config, _dir) = common::v2_config_file(); + let output = Command::new(env!("CARGO_BIN_EXE_renderflow")) + .arg("build") + .arg("--dry-run") + .arg("--config") + .arg(config.path()) + .output() + .expect("failed to execute renderflow"); + + assert!( + output.status.success(), + "v2 dry-run should succeed, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let plan: serde_json::Value = serde_json::from_slice(&output.stdout) + .expect("v2 dry-run stdout should be canonical ExecutionPlan JSON"); + assert_eq!(plan["source"], "markdown"); + assert_eq!(plan["targets"], serde_json::json!(["html"])); +} + #[test] fn test_no_input_provided_exits_with_error() { let output = Command::new(env!("CARGO_BIN_EXE_renderflow")) @@ -455,52 +482,49 @@ fn test_all_with_missing_config_exits_with_error() { } #[test] -fn test_target_without_transforms_exits_with_error() { - // A valid config with no 'transforms' key should cause graph-based execution to fail - // with a descriptive error when --target is used. +fn test_target_without_transforms_uses_builtin_capability_registry() { let (f, _dir) = common::valid_config_file(); let output = Command::new(env!("CARGO_BIN_EXE_renderflow")) .arg("build") .arg("--config") .arg(f.path()) .arg("--target") - .arg("pdf") + .arg("html") + .arg("--dry-run") .output() .expect("failed to execute renderflow"); assert!( - !output.status.success(), - "--target without a 'transforms' key in config should fail" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("transforms"), - "error should mention 'transforms', got: {stderr}" + output.status.success(), + "--target should use built-in capabilities without a transforms file: {}", + String::from_utf8_lossy(&output.stderr) ); + let plan: serde_json::Value = serde_json::from_slice(&output.stdout) + .expect("target dry-run should emit canonical plan JSON"); + assert_eq!(plan["targets"], serde_json::json!(["html"])); } #[test] -fn test_all_without_transforms_exits_with_error() { - // A valid config with no 'transforms' key should cause graph-based execution to fail - // with a descriptive error when --all is used. +fn test_all_without_transforms_uses_builtin_capability_registry() { let (f, _dir) = common::valid_config_file(); let output = Command::new(env!("CARGO_BIN_EXE_renderflow")) .arg("build") .arg("--config") .arg(f.path()) .arg("--all") + .arg("--dry-run") .output() .expect("failed to execute renderflow"); assert!( - !output.status.success(), - "--all without a 'transforms' key in config should fail" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("transforms"), - "error should mention 'transforms', got: {stderr}" + output.status.success(), + "--all should use built-in capabilities without a transforms file: {}", + String::from_utf8_lossy(&output.stderr) ); + 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())); } #[test] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index b64de11..997cffc 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -55,3 +55,22 @@ transforms:\n \ .expect("failed to write config"); (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) { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let input_path = dir.path().join("input.md"); + fs::write(&input_path, "# Test\n").expect("failed to write input file"); + let output_dir = dir.path().join("dist-v2"); + let config_content = format!( + "schema: renderflow/v2\nsources:\n - id: source.main\n role: manuscript\n path: \"{}\"\n format: markdown\ntargets:\n exact:\n - id: target.html\n role: web\n format: html\noutput:\n bundle_root: \"{}\"\n naming_template: \"{{target.role}}.{{ext}}\"\n collision: error\n", + input_path.display(), + output_dir.display() + ); + let mut file = NamedTempFile::new_in(dir.path()).expect("failed to create v2 temp config"); + file.write_all(config_content.as_bytes()) + .expect("failed to write v2 temp config"); + (file, dir) +} From d572f0674a44ca0e3e3191d7a09e3d2c42011ce8 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Tue, 1 Sep 2026 10:58:57 -0400 Subject: [PATCH 2/8] chore: stage canonical CLI regression fixes --- scripts/canonical_planner_cli_test_fix_tmp.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 scripts/canonical_planner_cli_test_fix_tmp.py diff --git a/scripts/canonical_planner_cli_test_fix_tmp.py b/scripts/canonical_planner_cli_test_fix_tmp.py new file mode 100644 index 0000000..a1d03b7 --- /dev/null +++ b/scripts/canonical_planner_cli_test_fix_tmp.py @@ -0,0 +1,35 @@ +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f"expected exactly one {label} anchor, found {count}") + return text.replace(old, new, 1) + + +common_path = Path("tests/common/mod.rs") +common = common_path.read_text(encoding="utf-8") +common = replace_once( + common, + ''' let config_content = format!(\n "input: \\\"{}\\\"\\noutput_dir: \\\"{}\\\"\\ntransforms: \\\"{}\\\"\\n",\n input_path.display(),\n output_dir.display(),\n transforms_path.display(),\n );''', + ''' // Keep this as an explicit v1 compatibility fixture, but make it valid under the\n // canonical loader. Graph/inspect commands now share the same spec validation path as build.\n let config_content = format!(\n "outputs:\\n - type: html\\ninput: \\\"{}\\\"\\noutput_dir: \\\"{}\\\"\\ntransforms: \\\"{}\\\"\\n",\n input_path.display(),\n output_dir.display(),\n transforms_path.display(),\n );''', + "graph_config_file v1 fixture", +) +common_path.write_text(common, encoding="utf-8") + +cli_path = Path("tests/cli_tests.rs") +cli = cli_path.read_text(encoding="utf-8") +cli = replace_once( + cli, + '''#[test]\nfn test_inspect_without_transforms_exits_with_error() {\n let (f, _dir) = common::valid_config_file();\n let output = Command::new(env!("CARGO_BIN_EXE_renderflow"))\n .args(["inspect", "--config"])\n .arg(f.path())\n .output()\n .expect("failed to execute renderflow");\n\n assert!(\n !output.status.success(),\n "inspect without a 'transforms' key in config should fail"\n );\n let stderr = String::from_utf8_lossy(&output.stderr);\n assert!(\n stderr.contains("transforms"),\n "error should mention 'transforms', got: {stderr}"\n );\n}''', + '''#[test]\nfn test_inspect_without_transforms_uses_builtin_capability_registry() {\n let (f, _dir) = common::valid_config_file();\n let output = Command::new(env!("CARGO_BIN_EXE_renderflow"))\n .args(["inspect", "--config"])\n .arg(f.path())\n .output()\n .expect("failed to execute renderflow");\n\n assert!(\n output.status.success(),\n "inspect should resolve built-in capabilities without a transforms file: {}",\n String::from_utf8_lossy(&output.stderr)\n );\n let stdout = String::from_utf8_lossy(&output.stdout);\n assert!(\n stdout.contains("DAG Execution Plan"),\n "inspect should render the canonical plan, got: {stdout}"\n );\n}''', + "inspect without transforms test", +) +cli = replace_once( + cli, + '''#[test]\nfn test_graph_plan_without_transforms_exits_with_error() {\n let (config_file, _dir) = common::valid_config_file();\n let output = Command::new(env!("CARGO_BIN_EXE_renderflow"))\n .args([\n "graph",\n "plan",\n "--config",\n config_file.path().to_str().unwrap(),\n ])\n .output()\n .expect("failed to execute renderflow");\n\n assert!(\n !output.status.success(),\n "graph plan without transforms should exit with error"\n );\n}''', + '''#[test]\nfn test_graph_plan_without_transforms_uses_builtin_capability_registry() {\n let (config_file, _dir) = common::valid_config_file();\n let output = Command::new(env!("CARGO_BIN_EXE_renderflow"))\n .args([\n "graph",\n "plan",\n "--config",\n config_file.path().to_str().unwrap(),\n ])\n .output()\n .expect("failed to execute renderflow");\n\n assert!(\n output.status.success(),\n "graph plan should resolve built-in capabilities without a transforms file: {}",\n String::from_utf8_lossy(&output.stderr)\n );\n let stdout = String::from_utf8_lossy(&output.stdout);\n assert!(\n stdout.contains("Execution Plan"),\n "graph plan should render the canonical execution plan, got: {stdout}"\n );\n}''', + "graph plan without transforms test", +) +cli_path.write_text(cli, encoding="utf-8") From f3627acad4d7c774e2014c8692429e08fa48c5ae Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Tue, 1 Sep 2026 10:59:11 -0400 Subject: [PATCH 3/8] chore: validate canonical CLI regression fixes --- .github/workflows/canonical-planner-fix.yml | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/canonical-planner-fix.yml diff --git a/.github/workflows/canonical-planner-fix.yml b/.github/workflows/canonical-planner-fix.yml new file mode 100644 index 0000000..5432d86 --- /dev/null +++ b/.github/workflows/canonical-planner-fix.yml @@ -0,0 +1,44 @@ +name: canonical planner final fix + +on: + push: + branches: + - feat/canonical-planner-354 + +permissions: + contents: write + +jobs: + validate-and-persist: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: feat/canonical-planner-354 + fetch-depth: 0 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.94" + components: rustfmt,clippy + + - name: Apply CLI regression fixes + run: python scripts/canonical_planner_cli_test_fix_tmp.py + + - name: Validate exact repository test surface + run: | + cargo fmt --all -- --check + cargo clippy --workspace --all-targets -- -D warnings + cargo test --workspace --all-targets + + - name: Persist validated tests + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add tests/common/mod.rs tests/cli_tests.rs + git diff --cached --quiet && exit 0 + git commit --message "test(planner): align CLI coverage with canonical specs" + git push origin HEAD:feat/canonical-planner-354 From e396c670c4deb339f3f0222d043218fdf38b090a Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Tue, 1 Sep 2026 11:00:08 -0400 Subject: [PATCH 4/8] chore: format and rerun canonical CLI validation --- .github/workflows/canonical-planner-fix.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/canonical-planner-fix.yml b/.github/workflows/canonical-planner-fix.yml index 5432d86..5c2e11e 100644 --- a/.github/workflows/canonical-planner-fix.yml +++ b/.github/workflows/canonical-planner-fix.yml @@ -28,8 +28,9 @@ jobs: - name: Apply CLI regression fixes run: python scripts/canonical_planner_cli_test_fix_tmp.py - - name: Validate exact repository test surface + - name: Format and validate exact repository test surface run: | + cargo fmt --all cargo fmt --all -- --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace --all-targets @@ -38,7 +39,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add tests/common/mod.rs tests/cli_tests.rs + git add crates/renderflow-core/tests/canonical_planner.rs tests/common/mod.rs tests/cli_tests.rs git diff --cached --quiet && exit 0 git commit --message "test(planner): align CLI coverage with canonical specs" git push origin HEAD:feat/canonical-planner-354 From 91adfc7c2972a88f604bf427d613ce43a1a15ece Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Tue, 1 Sep 2026 11:01:53 -0400 Subject: [PATCH 5/8] chore: include cache benchmark lint fix --- .github/workflows/canonical-planner-fix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/canonical-planner-fix.yml b/.github/workflows/canonical-planner-fix.yml index 5c2e11e..a105a96 100644 --- a/.github/workflows/canonical-planner-fix.yml +++ b/.github/workflows/canonical-planner-fix.yml @@ -39,7 +39,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add crates/renderflow-core/tests/canonical_planner.rs tests/common/mod.rs tests/cli_tests.rs + git add crates/renderflow-core/benches/cache.rs crates/renderflow-core/tests/canonical_planner.rs tests/common/mod.rs tests/cli_tests.rs git diff --cached --quiet && exit 0 git commit --message "test(planner): align CLI coverage with canonical specs" git push origin HEAD:feat/canonical-planner-354 From e1581e0f4e6441306c96a788fc8e6965c4bcb244 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Tue, 1 Sep 2026 11:02:15 -0400 Subject: [PATCH 6/8] chore: stage cache benchmark lint fix --- scripts/canonical_planner_cli_test_fix_tmp.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/canonical_planner_cli_test_fix_tmp.py b/scripts/canonical_planner_cli_test_fix_tmp.py index a1d03b7..8cbd8d2 100644 --- a/scripts/canonical_planner_cli_test_fix_tmp.py +++ b/scripts/canonical_planner_cli_test_fix_tmp.py @@ -33,3 +33,13 @@ def replace_once(text: str, old: str, new: str, label: str) -> str: "graph plan without transforms test", ) cli_path.write_text(cli, encoding="utf-8") + +bench_path = Path("crates/renderflow-core/benches/cache.rs") +bench = bench_path.read_text(encoding="utf-8") +bench = replace_once( + bench, + " || TransformCache::default(),", + " TransformCache::default,", + "TransformCache benchmark constructor", +) +bench_path.write_text(bench, encoding="utf-8") From d96ba634d96facca18a9aa59263db079efc14a7f Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Tue, 1 Sep 2026 11:05:28 -0400 Subject: [PATCH 7/8] chore: align graph integration fixtures with canonical specs --- scripts/canonical_planner_cli_test_fix_tmp.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/canonical_planner_cli_test_fix_tmp.py b/scripts/canonical_planner_cli_test_fix_tmp.py index 8cbd8d2..2582133 100644 --- a/scripts/canonical_planner_cli_test_fix_tmp.py +++ b/scripts/canonical_planner_cli_test_fix_tmp.py @@ -34,6 +34,16 @@ def replace_once(text: str, old: str, new: str, label: str) -> str: ) cli_path.write_text(cli, encoding="utf-8") +integration_path = Path("tests/graph_integration_test.rs") +integration = integration_path.read_text(encoding="utf-8") +integration = replace_once( + integration, + ''' let config = format!(\n "input: \\\"{}\\\"\\noutput_dir: \\\"{}\\\"\\ntransforms: \\\"{}\\\"\\n",\n input_path.display(),\n output_dir.display(),\n transforms_path.display(),\n );''', + ''' // The integration suite intentionally exercises the explicit v1 compatibility path.\n // Keep the fixture valid under the canonical loader; CLI target overrides still choose\n // the exact/all-reachable execution set used by each test.\n let config = format!(\n "outputs:\\n - type: html\\ninput: \\\"{}\\\"\\noutput_dir: \\\"{}\\\"\\ntransforms: \\\"{}\\\"\\n",\n input_path.display(),\n output_dir.display(),\n transforms_path.display(),\n );''', + "graph integration v1 fixture", +) +integration_path.write_text(integration, encoding="utf-8") + bench_path = Path("crates/renderflow-core/benches/cache.rs") bench = bench_path.read_text(encoding="utf-8") bench = replace_once( From 3b4e7a81acd31d522821766e979f97d04aed93b3 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Tue, 1 Sep 2026 11:05:43 -0400 Subject: [PATCH 8/8] chore: validate graph integration fixtures --- .github/workflows/canonical-planner-fix.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/canonical-planner-fix.yml b/.github/workflows/canonical-planner-fix.yml index a105a96..d0e6c65 100644 --- a/.github/workflows/canonical-planner-fix.yml +++ b/.github/workflows/canonical-planner-fix.yml @@ -39,7 +39,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add crates/renderflow-core/benches/cache.rs crates/renderflow-core/tests/canonical_planner.rs tests/common/mod.rs tests/cli_tests.rs + git add crates/renderflow-core/benches/cache.rs crates/renderflow-core/tests/canonical_planner.rs tests/common/mod.rs tests/cli_tests.rs tests/graph_integration_test.rs git diff --cached --quiet && exit 0 - git commit --message "test(planner): align CLI coverage with canonical specs" + git commit --message "test(planner): align integration coverage with canonical specs" git push origin HEAD:feat/canonical-planner-354