Skip to content

Commit e82a779

Browse files
authored
Merge pull request #286 from egohygiene/copilot/enable-parallel-execution-output-strategies
feat: improve parallel output rendering with per-output progress bars and explicit tests
2 parents 5f33664 + 61888be commit e82a779

1 file changed

Lines changed: 118 additions & 11 deletions

File tree

src/commands/build.rs

Lines changed: 118 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ pub fn run_resilient(config_path: &str) -> Result<()> {
4545
/// cache and dependency map after all formats have finished.
4646
type RenderResult = (String, String, Result<()>, Option<String>, Option<Vec<FileDependency>>);
4747

48+
/// Progress-bar symbols used in render status messages.
49+
const SYMBOL_SKIP: &str = "↩";
50+
const SYMBOL_OK: &str = "✔";
51+
const SYMBOL_FAIL: &str = "✘";
52+
53+
/// Create and register a per-output spinner progress bar on `mp`.
54+
///
55+
/// Pre-creating bars before the rayon parallel section avoids calling
56+
/// [`MultiProgress::add`] from multiple threads, which would acquire an
57+
/// internal mutex on every call and could serialise parallel workers.
58+
fn create_output_bar(mp: &MultiProgress, format_label: &str) -> ProgressBar {
59+
let bar = mp.add(ProgressBar::new_spinner());
60+
bar.set_style(
61+
ProgressStyle::with_template(" {spinner:.blue} [{prefix:.bold.cyan}] {msg}")
62+
.expect("hardcoded per-output progress bar template is valid"),
63+
);
64+
bar.set_prefix(format_label.to_string());
65+
bar.set_message("queued");
66+
bar
67+
}
68+
4869
fn run_impl(config_path: &str, dry_run: bool, resilient: bool, optimization: Option<OptimizationMode>) -> Result<()> {
4970
if dry_run {
5071
info!("Dry-run mode enabled — no files will be created and no commands will be executed");
@@ -126,6 +147,12 @@ fn run_impl(config_path: &str, dry_run: bool, resilient: bool, optimization: Opt
126147
.progress_chars("█▓░"),
127148
);
128149

150+
// Pre-create one spinner per output format *before* the parallel rendering
151+
// section. See [`create_output_bar`] for the rationale.
152+
let output_bars: Vec<ProgressBar> = config.outputs.iter().map(|output| {
153+
create_output_bar(&mp, &output.output_type.to_string())
154+
}).collect();
155+
129156
// Transforms are run once per output format (serially, before parallel rendering)
130157
// because some transforms are format-specific. In particular, EmojiTransform skips
131158
// replacement for HTML (which renders emoji natively) but applies it for PDF, DOCX,
@@ -207,9 +234,10 @@ fn run_impl(config_path: &str, dry_run: bool, resilient: bool, optimization: Opt
207234
let dep_map_path = output_dir.join(".renderflow-deps.json");
208235
let mut dep_map = load_dependency_map(&dep_map_path);
209236

210-
// Output formats are rendered concurrently via rayon. Progress bar updates
211-
// and log messages may interleave across formats; this is expected and
212-
// acceptable for parallel execution.
237+
// Output formats are rendered concurrently via rayon. Each output has its own
238+
// pre-created progress bar so workers never block each other updating the display.
239+
// Failures are captured per-output and aggregated at the end — a single format
240+
// failure does not abort sibling renders.
213241
//
214242
// Each element is (format_name, output_path, result, Option<new_output_hash>,
215243
// Option<file_deps>). The optional hash and deps are Some only when the
@@ -218,7 +246,8 @@ fn run_impl(config_path: &str, dry_run: bool, resilient: bool, optimization: Opt
218246
let render_results: Vec<RenderResult> = config
219247
.outputs
220248
.par_iter()
221-
.map(|output| {
249+
.zip(output_bars.par_iter())
250+
.map(|(output, bar)| {
222251
let format = output.output_type.clone();
223252
let format_str = format.to_string();
224253
let output_path = format!("{}/{}.{}", output_dir.display(), input_stem, format);
@@ -232,7 +261,7 @@ fn run_impl(config_path: &str, dry_run: bool, resilient: bool, optimization: Opt
232261

233262
if dry_run {
234263
info!("[DRY RUN] Would render {} output to: {}", format, output_path);
235-
pb.set_message(format!("[DRY RUN] [{format}] Would render output"));
264+
bar.finish_with_message(format!("[DRY RUN] {SYMBOL_OK} Would render to {}", output_path));
236265
pb.inc(1);
237266
pb.println(format!("[DRY RUN] Would write output to: {}", output_path));
238267
(format_str, output_path, Ok(()), None, None)
@@ -290,18 +319,19 @@ fn run_impl(config_path: &str, dry_run: bool, resilient: bool, optimization: Opt
290319
{
291320
debug!(hash = %output_hash, output = %output_path, "Output cache hit — skipping render");
292321
info!("Skipping {} render (unchanged)", format);
322+
bar.finish_with_message(format!("{SYMBOL_SKIP} unchanged: {}", output_path));
293323
pb.inc(1);
294-
pb.println(format!(" Skipping {} output (unchanged): {}", format, output_path));
324+
pb.println(format!("{SYMBOL_SKIP} Skipping {} output (unchanged): {}", format, output_path));
295325
return (format_str, output_path, Ok(()), Some(output_hash), Some(file_deps));
296326
}
297327

328+
bar.set_message("rendering…");
298329
let result = (|| -> Result<()> {
299330
debug!(hash = %output_hash, output = %output_path, "Output cache miss — rendering output");
300331
let strategy = select_strategy(&format, output.template.as_deref(), "templates")?;
301332
let mut pipeline = Pipeline::new();
302333
pipeline.add_step(Box::new(StrategyStep::new(strategy, &output_path, config.input_format(), config.variables.clone(), false)));
303334

304-
pb.set_message(format!("[{format}] Rendering output"));
305335
pipeline.run(transformed)?;
306336
Ok(())
307337
})();
@@ -311,14 +341,16 @@ fn run_impl(config_path: &str, dry_run: bool, resilient: bool, optimization: Opt
311341

312342
match &result {
313343
Ok(_) => {
344+
bar.finish_with_message(format!("{SYMBOL_OK} {}", output_path));
314345
pb.inc(1);
315-
pb.println(format!(" Output written to: {}", output_path));
346+
pb.println(format!("{SYMBOL_OK} Output written to: {}", output_path));
316347
info!(output = %output_path, "Pipeline completed for format: {}", format);
317348
}
318349
Err(e) => {
319350
warn!(format = %format, error = %e, "Rendering failed for output format");
351+
bar.finish_with_message(format!("{SYMBOL_FAIL} failed: {:#}", e));
320352
pb.inc(1);
321-
pb.println(format!(" Failed to render {} output: {:#}", format, e));
353+
pb.println(format!("{SYMBOL_FAIL} Failed to render {} output: {:#}", format, e));
322354
}
323355
}
324356
(format_str, output_path, result, new_hash, new_deps)
@@ -353,9 +385,9 @@ fn run_impl(config_path: &str, dry_run: bool, resilient: bool, optimization: Opt
353385
.collect();
354386

355387
if dry_run {
356-
pb.finish_with_message("[DRY RUN] Dry-run complete — no output written");
388+
pb.finish_with_message(format!("[DRY RUN] {SYMBOL_OK} Dry-run complete — no output written"));
357389
} else if failed_outputs.is_empty() {
358-
pb.finish_with_message("✔ Build complete");
390+
pb.finish_with_message(format!("{SYMBOL_OK} Build complete"));
359391
} else {
360392
pb.finish_with_message(format!("⚠ Build completed with {} failure(s)", failed_outputs.len()));
361393
let messages: Vec<String> = failed_outputs
@@ -854,4 +886,79 @@ mod tests {
854886
let result = run(f.path().to_str().unwrap(), true, None);
855887
assert!(result.is_ok(), "dry-run with output cache should succeed: {:?}", result);
856888
}
889+
890+
#[test]
891+
fn test_parallel_dry_run_produces_result_per_output() {
892+
// Dry-run with multiple output formats: the parallel rendering loop must
893+
// process every configured output and produce a result for each one.
894+
// This verifies that par_iter covers all outputs, not just the first.
895+
let (f, _dir) = multi_output_config_file();
896+
let result = run(f.path().to_str().unwrap(), true, None);
897+
assert!(result.is_ok(), "dry-run with multiple outputs should succeed: {:?}", result);
898+
}
899+
900+
#[test]
901+
#[ignore = "requires pandoc to be installed"]
902+
fn test_parallel_failure_isolation_and_aggregation() {
903+
// Verify that when multiple output formats are configured and one format
904+
// fails, the other formats are still attempted and their failures are
905+
// collected independently. The final error must mention every failing
906+
// format so the caller can identify which outputs need attention.
907+
//
908+
// We configure two unsupported formats. Each fails independently inside
909+
// the rayon parallel loop; the outer run() collects all failures and
910+
// returns a single aggregated error.
911+
let dir = tempfile::tempdir().expect("failed to create temp dir");
912+
let input_path = dir.path().join("input.md");
913+
fs::write(&input_path, "# Test\n").expect("failed to write input file");
914+
let output_dir = dir.path().join("dist");
915+
let config_content = format!(
916+
"outputs:\n - type: epub\n - type: rst\ninput: \"{}\"\noutput_dir: \"{}\"\n",
917+
input_path.display(),
918+
output_dir.display()
919+
);
920+
let mut f = NamedTempFile::new().expect("failed to create temp file");
921+
f.write_all(config_content.as_bytes())
922+
.expect("failed to write config");
923+
924+
let result = run(f.path().to_str().unwrap(), false, None);
925+
assert!(result.is_err(), "build with only unsupported formats should fail");
926+
let err_msg = format!("{:#}", result.unwrap_err());
927+
// Both format names must appear in the aggregated error message.
928+
assert!(
929+
err_msg.contains("epub"),
930+
"aggregated error should mention 'epub': {err_msg}"
931+
);
932+
assert!(
933+
err_msg.contains("rst"),
934+
"aggregated error should mention 'rst': {err_msg}"
935+
);
936+
}
937+
938+
#[test]
939+
#[ignore = "requires pandoc to be installed"]
940+
fn test_parallel_renders_all_formats_independently() {
941+
// With html, pdf, and docx configured the parallel loop must produce one
942+
// successful result per format. This exercises the full rayon path for
943+
// each output strategy running concurrently.
944+
let dir = tempfile::tempdir().expect("failed to create temp dir");
945+
let input_path = dir.path().join("input.md");
946+
fs::write(&input_path, "# Hello\n\nThis is a test.\n")
947+
.expect("failed to write input file");
948+
let output_dir = dir.path().join("dist");
949+
let config_content = format!(
950+
"outputs:\n - type: html\n - type: pdf\n - type: docx\ninput: \"{}\"\noutput_dir: \"{}\"\n",
951+
input_path.display(),
952+
output_dir.display()
953+
);
954+
let mut f = NamedTempFile::new().expect("failed to create temp file");
955+
f.write_all(config_content.as_bytes())
956+
.expect("failed to write config");
957+
958+
let result = run(f.path().to_str().unwrap(), false, None);
959+
assert!(result.is_ok(), "parallel build with html+pdf+docx should succeed: {:?}", result);
960+
assert!(output_dir.join("input.html").exists(), "html output must exist");
961+
assert!(output_dir.join("input.pdf").exists(), "pdf output must exist");
962+
assert!(output_dir.join("input.docx").exists(), "docx output must exist");
963+
}
857964
}

0 commit comments

Comments
 (0)