@@ -45,6 +45,27 @@ pub fn run_resilient(config_path: &str) -> Result<()> {
4545/// cache and dependency map after all formats have finished.
4646type 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+
4869fn 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\n input: \" {}\" \n output_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 \n This 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\n input: \" {}\" \n output_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