Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions crates/renderflow-core/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,25 @@ pub fn run_cli(cli: Cli) -> Result<()> {
profile,
exclude,
all,
}) => commands::build::run_selection(
&config,
dry_run,
resume,
optimization,
target.as_deref(),
profile.as_deref(),
&exclude,
all,
)?,
}) => {
let target_override = if let Some(target) = target.as_deref() {
commands::build::BuildTargetOverride::Target(target)
} else if let Some(profile) = profile.as_deref() {
commands::build::BuildTargetOverride::Profile(profile)
} else if all {
commands::build::BuildTargetOverride::AllReachable
} else {
commands::build::BuildTargetOverride::Configured
};
commands::build::run_selection(commands::build::BuildOptions {
config_path: &config,
dry_run,
resume,
optimization,
target_override,
exclude: &exclude,
})?;
}
Some(Commands::Watch { config, debounce }) => commands::watch::run(&config, debounce)?,
Some(Commands::Audit) => commands::audit::run()?,
Some(Commands::Inspect {
Expand Down
136 changes: 99 additions & 37 deletions crates/renderflow-core/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,56 @@ use tracing::info;
use crate::optimization::OptimizationMode;
use crate::planning::{execute, resolve, PlanningRequest};

/// Explicit CLI override for the target intent declared by the configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BuildTargetOverride<'a> {
Configured,
Target(&'a str),
Profile(&'a str),
AllReachable,
}

/// Typed options for one canonical build selection.
#[derive(Debug)]
pub(crate) struct BuildOptions<'a> {
pub config_path: &'a str,
pub dry_run: bool,
pub resume: bool,
pub optimization: Option<OptimizationMode>,
pub target_override: BuildTargetOverride<'a>,
pub exclude: &'a [String],
}

impl BuildOptions<'_> {
fn planning_request(&self) -> Result<PlanningRequest> {
let mut request = PlanningRequest::from_path(self.config_path);
if let Some(optimization) = self.optimization {
request = request.with_optimization(optimization);
}
request = match self.target_override {
BuildTargetOverride::Configured => request,
BuildTargetOverride::Target(target) => request.with_target(target),
BuildTargetOverride::Profile(profile) => request.with_profile(profile),
BuildTargetOverride::AllReachable => request.with_all_reachable(),
};
for selector in self.exclude {
request = request.with_exclude(selector)?;
}
Ok(request)
}
}

/// Run the canonical Renderflow execution lifecycle using the target intent
/// declared in the v1/v2 configuration.
pub fn run(config_path: &str, dry_run: bool, optimization: Option<OptimizationMode>) -> Result<()> {
run_selection(
run_selection(BuildOptions {
config_path,
dry_run,
false,
resume: false,
optimization,
None,
None,
&[],
false,
)
target_override: BuildTargetOverride::Configured,
exclude: &[],
})
}

/// Compatibility entrypoint for watch mode.
Expand All @@ -29,38 +66,14 @@ pub fn run_resilient(config_path: &str) -> Result<()> {
}

/// Run the canonical lifecycle with optional CLI target overrides.
pub(crate) fn run_selection(
config_path: &str,
dry_run: bool,
resume: bool,
optimization: Option<OptimizationMode>,
target: Option<&str>,
profile: Option<&str>,
exclude: &[String],
all_reachable: bool,
) -> Result<()> {
if dry_run {
pub(crate) fn run_selection(options: BuildOptions<'_>) -> Result<()> {
if options.dry_run {
info!(
"Dry-run mode enabled — planning and bounded provider probes may run, but transforms and output writes are disabled"
);
}

let mut request = PlanningRequest::from_path(config_path);
if let Some(optimization) = optimization {
request = request.with_optimization(optimization);
}
if let Some(target) = target {
request = request.with_target(target);
} else if let Some(profile) = profile {
request = request.with_profile(profile);
} else if all_reachable {
request = request.with_all_reachable();
}
for selector in exclude {
request = request.with_exclude(selector)?;
}

let resolved = resolve(request)?.with_resume(resume);
let resolved = resolve(options.planning_request()?)?.with_resume(options.resume);
info!(
source = %resolved.source_format(),
targets = %resolved
Expand All @@ -74,13 +87,13 @@ pub(crate) fn run_selection(
"Resolved canonical execution plan"
);

let result = execute(resolved, dry_run)?;
if dry_run {
let result = execute(resolved, options.dry_run)?;
if options.dry_run {
// stdout is reserved for machine-readable plan evidence; tracing remains on stderr.
println!("{}", serde_json::to_string_pretty(&result.plan)?);
}
for output in &result.run_manifest.artifact_manifest.outputs {
if dry_run {
if options.dry_run {
info!("[DRY RUN] Planned output: {}", output);
} else {
info!("✔ Output written to: {}", output);
Expand All @@ -106,3 +119,52 @@ pub(crate) fn run_selection(
}
Ok(())
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;

use super::*;

#[test]
fn build_options_map_profile_and_exclusions_into_planning_request() {
let exclusions = vec![
"family:video".to_string(),
"provider:tool.ffmpeg".to_string(),
];
let request = BuildOptions {
config_path: "custom.yaml",
dry_run: true,
resume: true,
optimization: Some(OptimizationMode::Quality),
target_override: BuildTargetOverride::Profile("everything"),
exclude: &exclusions,
}
.planning_request()
.unwrap();

assert_eq!(request.config_path, PathBuf::from("custom.yaml"));
assert_eq!(request.optimization, Some(OptimizationMode::Quality));
assert_eq!(request.profile.as_deref(), Some("everything"));
assert!(request.target.is_none());
assert!(!request.all_reachable);
assert_eq!(request.exclude.families, ["video"]);
assert_eq!(request.exclude.providers, ["tool.ffmpeg"]);
}

#[test]
fn build_options_reject_invalid_exclusion_syntax() {
let exclusions = vec!["video".to_string()];
let result = BuildOptions {
config_path: "renderflow.yaml",
dry_run: false,
resume: false,
optimization: None,
target_override: BuildTargetOverride::AllReachable,
exclude: &exclusions,
}
.planning_request();

assert!(result.is_err());
}
}
2 changes: 1 addition & 1 deletion crates/renderflow-core/src/graph/dag_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ fn failed_step(
started_at_unix_ms: u64,
duration_ms: u64,
) -> StepEvidence {
let message = redact_sensitive_text(&error.to_string());
let message = redact_sensitive_text(&format!("{error:#}"));
let step_id = format!("step:{}-to-{}", edge.from, edge.to);
StepEvidence {
step_id: step_id.clone(),
Expand Down
23 changes: 18 additions & 5 deletions crates/renderflow-core/tests/canonical_planner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,14 @@ fn v1_and_v2_resolve_through_the_same_canonical_planner() {
assert_eq!(v1.target_formats(), v2.target_formats());
assert_eq!(v1.plan().source, v2.plan().source);
assert_eq!(v1.plan().targets, v2.plan().targets);
assert_eq!(v1.plan().metadata.total_edges, v2.plan().metadata.total_edges);
assert_eq!(v1.plan().metadata.execution_depth, v2.plan().metadata.execution_depth);
assert_eq!(
v1.plan().metadata.total_edges,
v2.plan().metadata.total_edges
);
assert_eq!(
v1.plan().metadata.execution_depth,
v2.plan().metadata.execution_depth
);
}

#[test]
Expand All @@ -73,12 +79,16 @@ fn dry_run_returns_the_exact_frozen_plan_without_writing_outputs() {
let frozen_plan = serde_json::to_value(resolved.plan()).expect("plan should serialize");

assert!(!configs.v2_output.exists());
let result = execute(resolved, true).expect("dry-run should succeed without provider execution");
let result =
execute(resolved, true).expect("dry-run should succeed without provider execution");
assert_eq!(
serde_json::to_value(&result.plan).expect("result plan should serialize"),
frozen_plan
);
assert!(!configs.v2_output.exists(), "dry-run must not create output root");
assert!(
!configs.v2_output.exists(),
"dry-run must not create output root"
);
}

#[test]
Expand All @@ -87,5 +97,8 @@ fn v1_dry_run_is_also_side_effect_free() {
let resolved = resolve(PlanningRequest::from_path(&configs.v1)).expect("v1 should resolve");
assert!(!configs.v1_output.exists());
execute(resolved, true).expect("v1 dry-run should succeed");
assert!(!configs.v1_output.exists(), "v1 dry-run must not create output root");
assert!(
!configs.v1_output.exists(),
"v1 dry-run must not create output root"
);
}
2 changes: 1 addition & 1 deletion docs/publication-hygiene.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,4 @@ Probabilistic redaction always produces `review_required` status for the resulti

SDK integrations implement `ContentRedactionProvider` and declare a stable provider ID, version, and determinism class. Providers receive bytes plus configured content classes and return new bytes, changed classes, and safe findings. They must not mutate source files or include sensitive values in diagnostics.

Hygiene evidence uses [`renderflow.hygiene/v1`](../schemas/renderflow-hygiene-v1.schema.json) and is embedded in terminal artifact evidence. A `publication.hygiene` step records the policy digest, provider, input candidate, sanitized output, duration, and fidelity.
Hygiene evidence uses [`renderflow.hygiene/v1`](https://github.com/egohygiene/renderflow/blob/main/schemas/renderflow-hygiene-v1.schema.json) and is embedded in terminal artifact evidence. A `publication.hygiene` step records the policy digest, provider, input candidate, sanitized output, duration, and fidelity.
8 changes: 8 additions & 0 deletions docs/user-guide/tool-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ without editing this built-in catalog.
| `tool.epubcheck` | EPUBCheck | executable: `epubcheck` | optional | deterministic | local |
| `tool.ffmpeg` | FFmpeg | executable: `ffmpeg` | optional | configuration_dependent | local |
| `tool.ghostscript` | Ghostscript | executable: `gs` | experimental | configuration_dependent | local |
| `tool.handbrake` | HandBrakeCLI | executable: `HandBrakeCLI` | optional | configuration_dependent | local |
| `tool.imagemagick` | ImageMagick | executable: `magick` | experimental | configuration_dependent | local |
| `tool.img2pdf` | img2pdf | executable: `img2pdf` | experimental | deterministic | local |
| `tool.jq` | jq | executable: `jq` | experimental | deterministic | local |
| `tool.kepubify` | Kepubify | executable: `kepubify` | optional | configuration_dependent | local |
| `tool.lulu-rules` | Pinned Lulu publication rule pack | virtual | optional | deterministic | local |
| `tool.pandoc` | Pandoc | executable: `pandoc` | required | configuration_dependent | local |
| `tool.tectonic` | Tectonic | executable: `tectonic` | optional | configuration_dependent | network_optional |
| `tool.tesseract` | Tesseract OCR | executable: `tesseract` | experimental | configuration_dependent | local |
Expand All @@ -42,10 +44,16 @@ without editing this built-in catalog.
| `video.convert` | `tool.ffmpeg` |
| `pdf.process` | `tool.ghostscript` |
| `tiff.aggregate.press_pdf` | `tool.ghostscript` |
| `video.transcode.whole_file` | `tool.handbrake` |
| `image.convert` | `tool.imagemagick` |
| `image.aggregate.pdf` | `tool.img2pdf` |
| `data.json.transform` | `tool.jq` |
| `ebook.convert.kepub` | `tool.kepubify` |
| `publication.lulu.bookstore.preflight` | `tool.lulu-rules` |
| `publication.lulu.epub-distribution.preflight` | `tool.lulu-rules` |
| `publication.lulu.global-distribution.preflight` | `tool.lulu-rules` |
| `publication.lulu.pdf-ebook.preflight` | `tool.lulu-rules` |
| `publication.lulu.print-direct.preflight` | `tool.lulu-rules` |
| `document.convert` | `tool.pandoc` |
| `document.generate` | `tool.pandoc` |
| `latex.compile` | `tool.tectonic` |
Expand Down
30 changes: 19 additions & 11 deletions tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,6 @@ fn test_dry_run_output_labeled() {
);
}


#[test]
fn test_v2_dry_run_serializes_canonical_plan() {
let (config, _dir) = common::v2_config_file();
Expand Down Expand Up @@ -524,7 +523,9 @@ fn test_all_without_transforms_uses_builtin_capability_registry() {
let plan: serde_json::Value = serde_json::from_slice(&output.stdout)
.expect("all-reachable dry-run should emit canonical plan JSON");
assert_eq!(plan["source"], "markdown");
assert!(plan["targets"].as_array().is_some_and(|targets| !targets.is_empty()));
assert!(plan["targets"]
.as_array()
.is_some_and(|targets| !targets.is_empty()));
}

#[test]
Expand Down Expand Up @@ -665,7 +666,7 @@ fn test_inspect_missing_config_exits_with_error() {
}

#[test]
fn test_inspect_without_transforms_exits_with_error() {
fn test_inspect_without_transforms_uses_builtin_capability_registry() {
let (f, _dir) = common::valid_config_file();
let output = Command::new(env!("CARGO_BIN_EXE_renderflow"))
.args(["inspect", "--config"])
Expand All @@ -674,13 +675,14 @@ fn test_inspect_without_transforms_exits_with_error() {
.expect("failed to execute renderflow");

assert!(
!output.status.success(),
"inspect without a 'transforms' key in config should fail"
output.status.success(),
"inspect should use built-in capabilities without a transforms file: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stderr.contains("transforms"),
"error should mention 'transforms', got: {stderr}"
stdout.contains("DAG Execution Plan"),
"inspect should emit a built-in execution plan, got: {stdout}"
);
}

Expand Down Expand Up @@ -884,7 +886,7 @@ fn test_graph_stats_help_exits_successfully() {
}

#[test]
fn test_graph_plan_without_transforms_exits_with_error() {
fn test_graph_plan_without_transforms_uses_builtin_capability_registry() {
let (config_file, _dir) = common::valid_config_file();
let output = Command::new(env!("CARGO_BIN_EXE_renderflow"))
.args([
Expand All @@ -897,8 +899,14 @@ fn test_graph_plan_without_transforms_exits_with_error() {
.expect("failed to execute renderflow");

assert!(
!output.status.success(),
"graph plan without transforms should exit with error"
output.status.success(),
"graph plan should use built-in capabilities without a transforms file: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Execution Plan"),
"graph plan should emit a built-in execution plan, got: {stdout}"
);
}

Expand Down
3 changes: 1 addition & 2 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ transforms:\n \

let output_dir = dir.path().join("dist");
let config_content = format!(
"input: \"{}\"\noutput_dir: \"{}\"\ntransforms: \"{}\"\n",
"outputs:\n - type: html\ninput: \"{}\"\noutput_dir: \"{}\"\ntransforms: \"{}\"\n",
input_path.display(),
output_dir.display(),
transforms_path.display(),
Expand All @@ -56,7 +56,6 @@ transforms:\n \
(config_file, dir)
}


/// Create a minimal Renderflow v2 config for canonical planner CLI tests.
#[allow(dead_code)]
pub fn v2_config_file() -> (NamedTempFile, tempfile::TempDir) {
Expand Down
Loading
Loading