Skip to content

Commit 913f760

Browse files
authored
Merge pull request #251 from egohygiene/copilot/implement-optimization-modes
feat: implement optimization modes (speed, quality, balanced)
2 parents 515c7e4 + dc0462b commit 913f760

7 files changed

Lines changed: 422 additions & 35 deletions

File tree

src/cli.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use clap::{Parser, Subcommand};
22

3+
use crate::optimization::OptimizationMode;
4+
35
/// Spec-driven document rendering engine
46
#[derive(Parser)]
57
#[command(
@@ -41,7 +43,8 @@ pub enum Commands {
4143
after_help = "Examples:\n \
4244
renderflow build Build using renderflow.yaml\n \
4345
renderflow build --config custom.yaml Build with a custom config file\n \
44-
renderflow build --dry-run Preview what would be built"
46+
renderflow build --dry-run Preview what would be built\n \
47+
renderflow build --optimization speed Build using speed optimization mode"
4548
)]
4649
Build {
4750
/// Path to the renderflow configuration file
@@ -51,6 +54,12 @@ pub enum Commands {
5154
/// Simulate execution: log intended actions without creating files or running commands
5255
#[arg(long)]
5356
dry_run: bool,
57+
58+
/// Optimization mode: controls how transformation paths are selected.
59+
/// Overrides the value set in the config file when provided.
60+
/// Choices: speed (minimise cost), quality (maximise quality), balanced (default).
61+
#[arg(long, value_name = "MODE")]
62+
optimization: Option<OptimizationMode>,
5463
},
5564

5665
/// Watch for file changes and automatically rebuild

src/commands/build.rs

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,18 @@ use crate::cache::{compute_input_hash, compute_output_hash, load_cache, load_out
1212
use crate::config::{load_config, OutputType};
1313
use crate::deps::validate_dependencies;
1414
use crate::files::{ensure_output_dir, validate_input};
15+
use crate::optimization::OptimizationMode;
1516
use crate::pipeline::{Pipeline, StrategyStep};
1617
use crate::strategies::select_strategy;
1718
use crate::template::{init_tera, validate_templates};
1819

1920
/// Run the full build pipeline.
2021
///
2122
/// Transforms fail fast: the first transform error aborts the build and returns an error.
22-
pub fn run(config_path: &str, dry_run: bool) -> Result<()> {
23-
run_impl(config_path, dry_run, false)
23+
///
24+
/// `optimization` overrides the mode from the config file when `Some`.
25+
pub fn run(config_path: &str, dry_run: bool, optimization: Option<OptimizationMode>) -> Result<()> {
26+
run_impl(config_path, dry_run, false, optimization)
2427
}
2528

2629
/// Run the build pipeline in resilient mode.
@@ -29,10 +32,10 @@ pub fn run(config_path: &str, dry_run: bool) -> Result<()> {
2932
/// aborting the build. Suitable for watch-mode rebuilds where a transient
3033
/// transform error should not stop the file watcher.
3134
pub fn run_resilient(config_path: &str) -> Result<()> {
32-
run_impl(config_path, false, true)
35+
run_impl(config_path, false, true, None)
3336
}
3437

35-
fn run_impl(config_path: &str, dry_run: bool, resilient: bool) -> Result<()> {
38+
fn run_impl(config_path: &str, dry_run: bool, resilient: bool, optimization: Option<OptimizationMode>) -> Result<()> {
3639
if dry_run {
3740
info!("Dry-run mode enabled — no files will be created and no commands will be executed");
3841
}
@@ -41,6 +44,10 @@ fn run_impl(config_path: &str, dry_run: bool, resilient: bool) -> Result<()> {
4144
let config = load_config(config_path)?;
4245
info!("Loaded config successfully");
4346

47+
// CLI flag takes precedence over config file; fall back to config value.
48+
let opt_mode = optimization.unwrap_or(config.optimization);
49+
info!(optimization = %opt_mode, "Using optimization mode");
50+
4451
let canonical_input = validate_input(&config.input)?;
4552

4653
// Validate required system dependencies after confirming the config and input
@@ -294,12 +301,12 @@ mod tests {
294301
#[ignore = "requires pandoc to be installed"]
295302
fn test_build_run_succeeds() {
296303
let (f, _dir) = valid_config_file();
297-
assert!(run(f.path().to_str().unwrap(), false).is_ok());
304+
assert!(run(f.path().to_str().unwrap(), false, None).is_ok());
298305
}
299306

300307
#[test]
301308
fn test_build_run_missing_config() {
302-
let result = run("/nonexistent/renderflow.yaml", false);
309+
let result = run("/nonexistent/renderflow.yaml", false, None);
303310
assert!(result.is_err());
304311
}
305312

@@ -314,7 +321,7 @@ mod tests {
314321
let mut f = NamedTempFile::new().expect("failed to create temp file");
315322
f.write_all(config_content.as_bytes())
316323
.expect("failed to write config");
317-
let result = run(f.path().to_str().unwrap(), false);
324+
let result = run(f.path().to_str().unwrap(), false, None);
318325
assert!(result.is_err(), "expected error when input file is missing");
319326
let msg = format!("{}", result.unwrap_err());
320327
assert!(msg.contains("Input file not found"), "unexpected error: {}", msg);
@@ -334,7 +341,7 @@ mod tests {
334341
let mut f = NamedTempFile::new().expect("failed to create temp file");
335342
f.write_all(config_content.as_bytes())
336343
.expect("failed to write config");
337-
let result = run(f.path().to_str().unwrap(), false);
344+
let result = run(f.path().to_str().unwrap(), false, None);
338345
assert!(result.is_err(), "expected error for unsupported format");
339346
let msg = format!("{}", result.unwrap_err());
340347
assert!(
@@ -363,15 +370,15 @@ mod tests {
363370
.write_all(config_content.as_bytes())
364371
.unwrap();
365372

366-
assert!(run(config_file.path().to_str().unwrap(), false).is_ok());
373+
assert!(run(config_file.path().to_str().unwrap(), false, None).is_ok());
367374
assert!(output_dir.join("input.html").exists());
368375
}
369376

370377
#[test]
371378
fn test_dry_run_succeeds_without_pandoc() {
372379
let (f, dir) = valid_config_file();
373380
let output_dir = dir.path().join("dist");
374-
let result = run(f.path().to_str().unwrap(), true);
381+
let result = run(f.path().to_str().unwrap(), true, None);
375382
assert!(result.is_ok(), "dry-run should succeed: {:?}", result);
376383
// No output directory should have been created in dry-run mode
377384
assert!(!output_dir.exists(), "output directory must not be created in dry-run mode");
@@ -381,14 +388,14 @@ mod tests {
381388
fn test_dry_run_does_not_create_output_files() {
382389
let (f, dir) = valid_config_file();
383390
let output_dir = dir.path().join("dist");
384-
run(f.path().to_str().unwrap(), true).expect("dry-run should not fail");
391+
run(f.path().to_str().unwrap(), true, None).expect("dry-run should not fail");
385392
// The dist directory and any rendered files must not exist
386393
assert!(!output_dir.exists(), "output directory must not be created in dry-run mode");
387394
}
388395

389396
#[test]
390397
fn test_dry_run_missing_config_still_errors() {
391-
let result = run("/nonexistent/renderflow.yaml", true);
398+
let result = run("/nonexistent/renderflow.yaml", true, None);
392399
assert!(result.is_err(), "dry-run with missing config should still error");
393400
}
394401

@@ -419,7 +426,7 @@ mod tests {
419426
// result is reused for each format.
420427
let (f, dir) = multi_output_config_file();
421428
let output_dir = dir.path().join("dist");
422-
let result = run(f.path().to_str().unwrap(), true);
429+
let result = run(f.path().to_str().unwrap(), true, None);
423430
assert!(result.is_ok(), "dry-run with multiple outputs should succeed: {:?}", result);
424431
// No output directory should have been created in dry-run mode.
425432
assert!(!output_dir.exists(), "output directory must not be created in dry-run mode");
@@ -435,8 +442,8 @@ mod tests {
435442
let (single_f, _single_dir) = valid_config_file();
436443
let (multi_f, _multi_dir) = multi_output_config_file();
437444

438-
let single_result = run(single_f.path().to_str().unwrap(), true);
439-
let multi_result = run(multi_f.path().to_str().unwrap(), true);
445+
let single_result = run(single_f.path().to_str().unwrap(), true, None);
446+
let multi_result = run(multi_f.path().to_str().unwrap(), true, None);
440447

441448
assert!(single_result.is_ok(), "single-output dry-run failed: {:?}", single_result);
442449
assert!(multi_result.is_ok(), "multi-output dry-run failed: {:?}", multi_result);
@@ -463,7 +470,7 @@ mod tests {
463470
let (f, dir) = valid_config_file();
464471
let output_dir = dir.path().join("dist");
465472
// No cache file exists — this is a fresh state.
466-
let result = run(f.path().to_str().unwrap(), true);
473+
let result = run(f.path().to_str().unwrap(), true, None);
467474
assert!(result.is_ok(), "dry-run should succeed without a cache: {:?}", result);
468475
// In dry-run mode the output directory is never created.
469476
assert!(!output_dir.exists(), "output directory must not be created in dry-run mode");
@@ -496,7 +503,7 @@ mod tests {
496503
f.write_all(config_content.as_bytes()).expect("failed to write config");
497504

498505
// The dry-run should succeed; cache hit is detected in both modes.
499-
let result = run(f.path().to_str().unwrap(), true);
506+
let result = run(f.path().to_str().unwrap(), true, None);
500507
assert!(result.is_ok(), "dry-run with cache hit should succeed: {:?}", result);
501508
}
502509

@@ -529,7 +536,7 @@ mod tests {
529536
f.write_all(config_content.as_bytes()).expect("failed to write config");
530537

531538
// Dry-run still succeeds; it runs transforms because the hash misses.
532-
let result = run(f.path().to_str().unwrap(), true);
539+
let result = run(f.path().to_str().unwrap(), true, None);
533540
assert!(result.is_ok(), "dry-run with cache miss should still succeed: {:?}", result);
534541
}
535542

@@ -550,7 +557,7 @@ mod tests {
550557
let mut f = NamedTempFile::new().expect("failed to create temp file");
551558
f.write_all(config_content.as_bytes()).expect("failed to write config");
552559

553-
run(f.path().to_str().unwrap(), false).expect("build should succeed");
560+
run(f.path().to_str().unwrap(), false, None).expect("build should succeed");
554561

555562
let cache_path = output_dir.join(".renderflow-cache.json");
556563
assert!(cache_path.exists(), "cache file must exist after a real build");
@@ -575,9 +582,9 @@ mod tests {
575582
f.write_all(config_content.as_bytes()).expect("failed to write config");
576583

577584
// First build — cache miss, cache written.
578-
run(f.path().to_str().unwrap(), false).expect("first build should succeed");
585+
run(f.path().to_str().unwrap(), false, None).expect("first build should succeed");
579586
// Second build — cache hit.
580-
run(f.path().to_str().unwrap(), false).expect("second build (cache hit) should succeed");
587+
run(f.path().to_str().unwrap(), false, None).expect("second build (cache hit) should succeed");
581588

582589
let cache_path = output_dir.join(".renderflow-cache.json");
583590
assert!(cache_path.exists(), "cache file must still exist after second build");
@@ -612,7 +619,7 @@ mod tests {
612619
let mut f = NamedTempFile::new().expect("failed to create temp file");
613620
f.write_all(config_content.as_bytes()).expect("failed to write config");
614621

615-
run(f.path().to_str().unwrap(), false).expect("build should succeed");
622+
run(f.path().to_str().unwrap(), false, None).expect("build should succeed");
616623

617624
let output_cache_path = output_dir.join(".renderflow-output-cache.json");
618625
assert!(output_cache_path.exists(), "output cache file must exist after a real build");
@@ -638,9 +645,9 @@ mod tests {
638645
f.write_all(config_content.as_bytes()).expect("failed to write config");
639646

640647
// First build — output cache miss, pandoc runs, cache written.
641-
run(f.path().to_str().unwrap(), false).expect("first build should succeed");
648+
run(f.path().to_str().unwrap(), false, None).expect("first build should succeed");
642649
// Second build — output cache hit, pandoc skipped.
643-
run(f.path().to_str().unwrap(), false).expect("second build (output cache hit) should succeed");
650+
run(f.path().to_str().unwrap(), false, None).expect("second build (output cache hit) should succeed");
644651

645652
let output_cache_path = output_dir.join(".renderflow-output-cache.json");
646653
assert!(output_cache_path.exists(), "output cache must still exist after second build");
@@ -664,21 +671,21 @@ mod tests {
664671
f.write_all(config_content.as_bytes()).expect("failed to write config");
665672

666673
// First build with original content.
667-
run(f.path().to_str().unwrap(), false).expect("first build should succeed");
674+
run(f.path().to_str().unwrap(), false, None).expect("first build should succeed");
668675

669676
// Modify input — caches must be invalidated.
670677
fs::write(&input_path, "# Modified\n").expect("failed to write updated input");
671678

672679
// Second build must succeed (re-render triggered by cache miss).
673-
run(f.path().to_str().unwrap(), false).expect("second build after input change should succeed");
680+
run(f.path().to_str().unwrap(), false, None).expect("second build after input change should succeed");
674681
}
675682

676683
#[test]
677684
fn test_output_cache_not_written_in_dry_run() {
678685
// In dry-run mode the output cache file must never be created.
679686
let (f, dir) = valid_config_file();
680687
let output_dir = dir.path().join("dist");
681-
run(f.path().to_str().unwrap(), true).expect("dry-run should succeed");
688+
run(f.path().to_str().unwrap(), true, None).expect("dry-run should succeed");
682689
let output_cache_path = output_dir.join(".renderflow-output-cache.json");
683690
assert!(
684691
!output_cache_path.exists(),
@@ -708,7 +715,7 @@ mod tests {
708715
let mut f = NamedTempFile::new().expect("failed to create temp file");
709716
f.write_all(config_content.as_bytes()).expect("failed to write config");
710717

711-
let result = run(f.path().to_str().unwrap(), true);
718+
let result = run(f.path().to_str().unwrap(), true, None);
712719
assert!(result.is_ok(), "dry-run with output cache should succeed: {:?}", result);
713720
}
714721
}

src/commands/watch.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ pub fn run(config_path: &str, debounce_ms: u64) -> Result<()> {
1313
info!("Starting watch mode for: {}", config_path);
1414

1515
// Perform an initial build before entering the watch loop.
16-
if let Err(e) = build::run(config_path, false) {
16+
if let Err(e) = build::run(config_path, false, None) {
1717
error!("Initial build failed: {:#}", e);
1818
}
1919

src/config.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::fs;
66

77
use crate::compat::{is_supported, unsupported_combination_message};
88
use crate::input_format::InputFormat;
9+
use crate::optimization::OptimizationMode;
910

1011
fn default_output_dir() -> String {
1112
"dist".to_string()
@@ -78,6 +79,10 @@ pub struct Config {
7879
pub variables: HashMap<String, String>,
7980
#[serde(default)]
8081
pub input_format: Option<InputFormat>,
82+
/// Optimization strategy used when selecting transformation paths.
83+
/// Defaults to [`OptimizationMode::Balanced`] when omitted.
84+
#[serde(default)]
85+
pub optimization: OptimizationMode,
8186
}
8287

8388
impl Config {
@@ -365,6 +370,61 @@ output_dir: "dist"
365370
assert!(config.variables.is_empty());
366371
}
367372

373+
#[test]
374+
fn test_load_config_optimization_defaults_to_balanced() {
375+
let yaml = r#"
376+
outputs:
377+
- type: html
378+
input: "input.md"
379+
output_dir: "dist"
380+
"#;
381+
let f = write_temp_yaml(yaml);
382+
let config = load_config(f.path().to_str().unwrap()).expect("should parse");
383+
assert_eq!(config.optimization, crate::optimization::OptimizationMode::Balanced);
384+
}
385+
386+
#[test]
387+
fn test_load_config_optimization_speed() {
388+
let yaml = r#"
389+
outputs:
390+
- type: html
391+
input: "input.md"
392+
output_dir: "dist"
393+
optimization: speed
394+
"#;
395+
let f = write_temp_yaml(yaml);
396+
let config = load_config(f.path().to_str().unwrap()).expect("should parse");
397+
assert_eq!(config.optimization, crate::optimization::OptimizationMode::Speed);
398+
}
399+
400+
#[test]
401+
fn test_load_config_optimization_quality() {
402+
let yaml = r#"
403+
outputs:
404+
- type: html
405+
input: "input.md"
406+
output_dir: "dist"
407+
optimization: quality
408+
"#;
409+
let f = write_temp_yaml(yaml);
410+
let config = load_config(f.path().to_str().unwrap()).expect("should parse");
411+
assert_eq!(config.optimization, crate::optimization::OptimizationMode::Quality);
412+
}
413+
414+
#[test]
415+
fn test_load_config_optimization_balanced_explicit() {
416+
let yaml = r#"
417+
outputs:
418+
- type: html
419+
input: "input.md"
420+
output_dir: "dist"
421+
optimization: balanced
422+
"#;
423+
let f = write_temp_yaml(yaml);
424+
let config = load_config(f.path().to_str().unwrap()).expect("should parse");
425+
assert_eq!(config.optimization, crate::optimization::OptimizationMode::Balanced);
426+
}
427+
368428
#[test]
369429
fn test_input_format_explicit_override() {
370430
let yaml = r#"

0 commit comments

Comments
 (0)