Skip to content

Commit 5826bf6

Browse files
authored
Merge pull request #282 from egohygiene/copilot/add-graph-based-cli-execution
feat: Add graph-based CLI execution (--target, --all)
2 parents 4ec0a35 + e6736ad commit 5826bf6

9 files changed

Lines changed: 708 additions & 3 deletions

File tree

src/cli.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ pub enum Commands {
4545
renderflow build --config custom.yaml Build with a custom config file\n \
4646
renderflow build --dry-run Preview what would be built\n \
4747
renderflow build --optimization speed Build using speed optimization mode\n \
48-
renderflow build --optimization pareto Build with Pareto-optimal path selection"
48+
renderflow build --optimization pareto Build with Pareto-optimal path selection\n \
49+
renderflow build --target pdf Build only the PDF output via graph resolution\n \
50+
renderflow build --all Build all reachable outputs via graph resolution"
4951
)]
5052
Build {
5153
/// Path to the renderflow configuration file
@@ -62,6 +64,19 @@ pub enum Commands {
6264
/// pareto (return Pareto-optimal frontier of non-dominated paths).
6365
#[arg(long, value_name = "MODE")]
6466
optimization: Option<OptimizationMode>,
67+
68+
/// Build only the specified output format using graph-based path resolution.
69+
/// The format must be reachable from the input format via the configured transforms.
70+
/// Requires a 'transforms' key in the config file.
71+
/// Cannot be combined with --all.
72+
#[arg(long, value_name = "FORMAT", conflicts_with = "all")]
73+
target: Option<String>,
74+
75+
/// Build all reachable output formats using graph-based path resolution.
76+
/// Requires a 'transforms' key in the config file.
77+
/// Cannot be combined with --target.
78+
#[arg(long, conflicts_with = "target")]
79+
all: bool,
6580
},
6681

6782
/// Watch for file changes and automatically rebuild

src/commands/graph_build.rs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
use std::fs;
2+
use std::path::Path;
3+
4+
use anyhow::{Context, Result};
5+
use tracing::info;
6+
7+
use crate::config::load_config_for_graph;
8+
use crate::files::ensure_output_dir;
9+
use crate::graph::Format;
10+
use crate::optimization::OptimizationMode;
11+
use crate::transforms::yaml_loader::build_graph_and_executor_from_yaml;
12+
13+
/// Run graph-based execution targeting a single output format.
14+
///
15+
/// The transform graph is resolved automatically from the `transforms` YAML
16+
/// file referenced in `config_path`. The shortest path (according to
17+
/// `optimization`) from the detected source format to `target` is found,
18+
/// and every intermediate and final format is produced.
19+
///
20+
/// # Errors
21+
///
22+
/// Returns an error when:
23+
/// * the config file cannot be read or parsed,
24+
/// * no `transforms` key is present in the config,
25+
/// * `target` is not a recognised format,
26+
/// * `target` is not reachable from the source format,
27+
/// * any transform in the execution plan fails.
28+
pub fn run_target(
29+
config_path: &str,
30+
target: &str,
31+
dry_run: bool,
32+
optimization: Option<OptimizationMode>,
33+
) -> Result<()> {
34+
let target_format = target
35+
.parse::<Format>()
36+
.with_context(|| format!("'{}' is not a valid target format", target))?;
37+
38+
run_impl(config_path, Some(vec![target_format]), dry_run, optimization)
39+
}
40+
41+
/// Run graph-based execution targeting all formats reachable from the source.
42+
///
43+
/// The transform graph is resolved automatically from the `transforms` YAML
44+
/// file referenced in `config_path`. Every format reachable from the source
45+
/// format is produced in dependency order.
46+
///
47+
/// # Errors
48+
///
49+
/// Returns an error when:
50+
/// * the config file cannot be read or parsed,
51+
/// * no `transforms` key is present in the config,
52+
/// * no output formats are reachable from the source format,
53+
/// * any transform in the execution plan fails.
54+
pub fn run_all(
55+
config_path: &str,
56+
dry_run: bool,
57+
optimization: Option<OptimizationMode>,
58+
) -> Result<()> {
59+
run_impl(config_path, None, dry_run, optimization)
60+
}
61+
62+
/// Shared implementation for `run_target` and `run_all`.
63+
///
64+
/// `explicit_targets` is `Some(vec)` for `--target` mode and `None` for
65+
/// `--all` mode (targets are discovered dynamically from the graph).
66+
fn run_impl(
67+
config_path: &str,
68+
explicit_targets: Option<Vec<Format>>,
69+
dry_run: bool,
70+
optimization: Option<OptimizationMode>,
71+
) -> Result<()> {
72+
if dry_run {
73+
info!("Dry-run mode enabled — no files will be created and no commands will be executed");
74+
}
75+
info!("Running graph-based build pipeline");
76+
77+
let config = load_config_for_graph(config_path)?;
78+
info!("Loaded config successfully");
79+
80+
let transforms_path = config.transforms.as_deref().ok_or_else(|| {
81+
anyhow::anyhow!(
82+
"Graph-based execution requires a 'transforms' key in the config file \
83+
pointing to a YAML transform configuration"
84+
)
85+
})?;
86+
87+
let (graph, executor) = build_graph_and_executor_from_yaml(transforms_path)?;
88+
info!("Loaded transform graph from '{}'", transforms_path);
89+
90+
let opt_mode = optimization.unwrap_or(config.optimization);
91+
info!(optimization = %opt_mode, "Using optimization mode");
92+
93+
// Derive the source format from the config's input field.
94+
let source_format: Format = config
95+
.input_format()
96+
.to_string()
97+
.parse()
98+
.with_context(|| {
99+
format!(
100+
"Could not map input format '{}' to a known graph format",
101+
config.input_format()
102+
)
103+
})?;
104+
105+
// Determine which formats to build.
106+
let targets: Vec<Format> = match explicit_targets {
107+
Some(t) => t,
108+
None => {
109+
// --all: discover every format reachable from the source.
110+
let reachable = graph.reachable_from(source_format);
111+
if reachable.is_empty() {
112+
anyhow::bail!(
113+
"No output formats are reachable from '{}' in the transform graph",
114+
source_format
115+
);
116+
}
117+
info!(
118+
"Discovered {} reachable output format(s): {}",
119+
reachable.len(),
120+
reachable
121+
.iter()
122+
.map(|f| f.to_string())
123+
.collect::<Vec<_>>()
124+
.join(", ")
125+
);
126+
reachable
127+
}
128+
};
129+
130+
// Build the minimal DAG that covers all targets.
131+
let dag = graph
132+
.build_multi_target_dag_with_mode(source_format, &targets, opt_mode)
133+
.ok_or_else(|| {
134+
anyhow::anyhow!(
135+
"Could not build an execution plan: one or more target formats \
136+
are not reachable from '{}' in the transform graph",
137+
source_format
138+
)
139+
})?;
140+
141+
let input_stem = Path::new(&config.input)
142+
.file_stem()
143+
.and_then(|s| s.to_str())
144+
.unwrap_or("document");
145+
146+
let output_dir = if dry_run {
147+
let path = std::path::PathBuf::from(&config.output_dir);
148+
info!(
149+
"[DRY RUN] Would create output directory: {}",
150+
path.display()
151+
);
152+
for target in &targets {
153+
let output_path = path.join(format!("{}.{}", input_stem, target));
154+
info!("[DRY RUN] Would write '{}' output to: {}", target, output_path.display());
155+
}
156+
return Ok(());
157+
} else {
158+
ensure_output_dir(&config.output_dir)?
159+
};
160+
161+
// Read and execute.
162+
let content = fs::read_to_string(&config.input)
163+
.with_context(|| format!("Failed to read input file: {}", config.input))?;
164+
165+
info!("Executing graph-based pipeline");
166+
let results = executor
167+
.execute(&dag, source_format, content)
168+
.context("Graph execution failed")?;
169+
170+
// Write each produced format to disk (skip the source format).
171+
for (format, output_content) in &results {
172+
if *format == source_format {
173+
continue;
174+
}
175+
let output_path = output_dir.join(format!("{}.{}", input_stem, format));
176+
fs::write(&output_path, output_content)
177+
.with_context(|| format!("Failed to write output to '{}'", output_path.display()))?;
178+
info!("✔ Output written to: {}", output_path.display());
179+
}
180+
181+
Ok(())
182+
}

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pub mod audit;
22
pub mod build;
3+
pub mod graph_build;
34
pub mod watch;

src/config.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ pub struct OutputConfig {
7171

7272
#[derive(Debug, Deserialize)]
7373
pub struct Config {
74+
#[serde(default)]
7475
pub outputs: Vec<OutputConfig>,
7576
pub input: String,
7677
#[serde(default = "default_output_dir")]
@@ -155,6 +156,19 @@ pub fn load_config(path: &str) -> Result<Config> {
155156
Ok(config)
156157
}
157158

159+
/// Load a config file without requiring the `outputs` key to be present.
160+
///
161+
/// Unlike [`load_config`], this function skips the full
162+
/// [`Config::validate`] call. It is intended for graph-based execution
163+
/// modes (`--target`, `--all`) where output formats are resolved from the
164+
/// transform graph rather than from a static `outputs` list.
165+
pub fn load_config_for_graph(path: &str) -> Result<Config> {
166+
let content = fs::read_to_string(path)
167+
.with_context(|| format!("Failed to read config file: {}", path))?;
168+
serde_yaml_ng::from_str(&content)
169+
.with_context(|| format!("Failed to parse YAML config: {}", path))
170+
}
171+
158172
#[cfg(test)]
159173
mod tests {
160174
use super::*;

src/graph/mod.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,47 @@ impl TransformGraph {
315315
Some(dag)
316316
}
317317

318+
/// Return all [`Format`] variants reachable from `from` via any sequence
319+
/// of directed edges.
320+
///
321+
/// The source format itself is excluded from the result. An empty `Vec`
322+
/// is returned when `from` is not a node in the graph or when no outgoing
323+
/// edges exist.
324+
///
325+
/// # Example
326+
///
327+
/// ```rust
328+
/// use renderflow::graph::{Format, TransformEdge, TransformGraph};
329+
///
330+
/// let mut graph = TransformGraph::new();
331+
/// graph.add_transform(TransformEdge::new(Format::Markdown, Format::Html, 0.5, 1.0));
332+
/// graph.add_transform(TransformEdge::new(Format::Html, Format::Pdf, 0.8, 0.85));
333+
///
334+
/// let reachable = graph.reachable_from(Format::Markdown);
335+
/// assert!(reachable.contains(&Format::Html));
336+
/// assert!(reachable.contains(&Format::Pdf));
337+
/// assert!(!reachable.contains(&Format::Markdown));
338+
/// ```
339+
pub fn reachable_from(&self, from: Format) -> Vec<Format> {
340+
use petgraph::visit::Bfs;
341+
342+
let Some(&start_idx) = self.nodes.get(&from) else {
343+
return Vec::new();
344+
};
345+
346+
let mut bfs = Bfs::new(&self.graph, start_idx);
347+
let mut reachable = Vec::new();
348+
349+
// The first call to `next` returns `start_idx` itself; skip it.
350+
bfs.next(&self.graph);
351+
352+
while let Some(nx) = bfs.next(&self.graph) {
353+
reachable.push(self.graph[nx]);
354+
}
355+
356+
reachable
357+
}
358+
318359
/// Return the Pareto-optimal frontier of paths from `from` to `to`.
319360
///
320361
/// All simple paths between the two formats are enumerated first. Any
@@ -891,4 +932,65 @@ mod tests {
891932
assert_eq!(frontier.len(), 2);
892933
assert!(frontier[0].total_cost <= frontier[1].total_cost);
893934
}
935+
936+
// ── reachable_from ────────────────────────────────────────────────────────
937+
938+
#[test]
939+
fn test_reachable_from_empty_graph_returns_empty() {
940+
let graph = TransformGraph::new();
941+
assert!(graph.reachable_from(Format::Markdown).is_empty());
942+
}
943+
944+
#[test]
945+
fn test_reachable_from_excludes_source() {
946+
let mut graph = TransformGraph::new();
947+
graph.add_transform(markdown_to_html());
948+
949+
let reachable = graph.reachable_from(Format::Markdown);
950+
assert!(!reachable.contains(&Format::Markdown));
951+
}
952+
953+
#[test]
954+
fn test_reachable_from_direct_neighbour() {
955+
let mut graph = TransformGraph::new();
956+
graph.add_transform(markdown_to_html());
957+
958+
let reachable = graph.reachable_from(Format::Markdown);
959+
assert!(reachable.contains(&Format::Html));
960+
assert_eq!(reachable.len(), 1);
961+
}
962+
963+
#[test]
964+
fn test_reachable_from_transitive_neighbours() {
965+
let mut graph = TransformGraph::new();
966+
graph.add_transform(markdown_to_html());
967+
graph.add_transform(html_to_pdf());
968+
969+
let reachable = graph.reachable_from(Format::Markdown);
970+
assert!(reachable.contains(&Format::Html));
971+
assert!(reachable.contains(&Format::Pdf));
972+
assert_eq!(reachable.len(), 2);
973+
}
974+
975+
#[test]
976+
fn test_reachable_from_unknown_format_returns_empty() {
977+
let mut graph = TransformGraph::new();
978+
graph.add_transform(markdown_to_html());
979+
980+
// Epub is not a node in the graph.
981+
assert!(graph.reachable_from(Format::Epub).is_empty());
982+
}
983+
984+
#[test]
985+
fn test_reachable_from_disconnected_component_not_included() {
986+
let mut graph = TransformGraph::new();
987+
graph.add_transform(markdown_to_html());
988+
// A disconnected edge: Rst → Latex (not reachable from Markdown).
989+
graph.add_transform(TransformEdge::new(Format::Rst, Format::Latex, 1.0, 1.0));
990+
991+
let reachable = graph.reachable_from(Format::Markdown);
992+
assert!(reachable.contains(&Format::Html));
993+
assert!(!reachable.contains(&Format::Rst));
994+
assert!(!reachable.contains(&Format::Latex));
995+
}
894996
}

src/main.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,14 @@ fn main() -> Result<()> {
3737
.init();
3838

3939
match cli.command {
40-
Some(Commands::Build { config, dry_run, optimization }) => {
41-
commands::build::run(&config, dry_run, optimization)?
40+
Some(Commands::Build { config, dry_run, optimization, target, all }) => {
41+
if let Some(ref target_format) = target {
42+
commands::graph_build::run_target(&config, target_format, dry_run, optimization)?
43+
} else if all {
44+
commands::graph_build::run_all(&config, dry_run, optimization)?
45+
} else {
46+
commands::build::run(&config, dry_run, optimization)?
47+
}
4248
}
4349
Some(Commands::Watch { config, debounce }) => commands::watch::run(&config, debounce)?,
4450
Some(Commands::Audit) => commands::audit::run()?,

0 commit comments

Comments
 (0)