@@ -76,39 +76,33 @@ pub fn run(config_path: &str, dry_run: bool) -> Result<()> {
7676 }
7777 info ! ( "Selected outputs: {}" , output_formats. join( ", " ) ) ;
7878
79- // Two ticks per output format: one for transforms, one for rendering.
80- let total_steps = config. outputs . len ( ) as u64 * 2 ;
79+ // One tick for transforms (run once) plus one tick per output format for rendering.
80+ let total_steps = 1 + config. outputs . len ( ) as u64 ;
8181 let pb = ProgressBar :: new ( total_steps) ;
8282 pb. set_style (
8383 ProgressStyle :: with_template ( "{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}" )
8484 . expect ( "hardcoded progress bar template is valid" )
8585 . progress_chars ( "█▓░" ) ,
8686 ) ;
8787
88+ // Transforms are pure in-memory operations (no files, no external commands) and
89+ // are not output-format dependent, so they are executed once and reused for all
90+ // output formats.
91+ let registry: TransformRegistry = register_transforms ( & config. variables ) ;
92+ info ! ( "Applying transforms (cached for all outputs)" ) ;
93+ pb. set_message ( "Applying transforms" ) ;
94+ let transformed = registry
95+ . apply_all ( normalized_content)
96+ . with_context ( || "Transform pipeline failed; no output formats will be rendered" ) ?;
97+ pb. inc ( 1 ) ;
98+
8899 let mut failed_outputs: Vec < ( String , anyhow:: Error ) > = Vec :: new ( ) ;
89100
90101 for output in & config. outputs {
91102 let format = output. output_type . clone ( ) ;
92103 let output_path = format ! ( "{}/{}.{}" , output_dir. display( ) , input_stem, format) ;
93104 info ! ( format = %format, output = %output_path, template = ?output. template, "Running pipeline for format" ) ;
94105
95- let registry: TransformRegistry = register_transforms ( & config. variables ) ;
96-
97- // Transforms are pure in-memory operations (no files, no external commands),
98- // so they run in both normal and dry-run mode to give an accurate preview.
99- pb. set_message ( format ! ( "[{format}] Applying transforms" ) ) ;
100- let transformed = match registry. apply_all ( normalized_content. clone ( ) ) {
101- Ok ( t) => t,
102- Err ( e) => {
103- warn ! ( format = %format, error = %e, "Transform failed for output format" ) ;
104- failed_outputs. push ( ( format. to_string ( ) , e) ) ;
105- // Consume both the transform tick and the render tick we're skipping.
106- pb. inc ( 2 ) ;
107- continue ;
108- }
109- } ;
110- pb. inc ( 1 ) ;
111-
112106 if dry_run {
113107 info ! ( "[dry-run] Would render {} output to: {}" , format, output_path) ;
114108 pb. set_message ( format ! ( "[{format}] Would render output" ) ) ;
@@ -120,7 +114,7 @@ pub fn run(config_path: &str, dry_run: bool) -> Result<()> {
120114 pipeline. add_step ( Box :: new ( StrategyStep :: new ( strategy, & output_path) ) ) ;
121115
122116 pb. set_message ( format ! ( "[{format}] Rendering output" ) ) ;
123- match pipeline. run_steps ( transformed) {
117+ match pipeline. run_steps ( transformed. clone ( ) ) {
124118 Ok ( _) => {
125119 pb. inc ( 1 ) ;
126120 pb. println ( format ! ( "✔ Output written to: {}" , output_path) ) ;
@@ -279,4 +273,54 @@ mod tests {
279273 let result = run ( "/nonexistent/renderflow.yaml" , true ) ;
280274 assert ! ( result. is_err( ) , "dry-run with missing config should still error" ) ;
281275 }
276+
277+ /// Build a config with multiple output formats for testing that transforms run once.
278+ fn multi_output_config_file ( ) -> ( NamedTempFile , tempfile:: TempDir ) {
279+ let dir = tempfile:: tempdir ( ) . expect ( "failed to create temp dir" ) ;
280+ let input_path = dir. path ( ) . join ( "input.md" ) ;
281+ // Content includes emoji and a variable so transforms have real work to do
282+ // across both the EmojiTransform and VariableSubstitutionTransform stages.
283+ fs:: write ( & input_path, "# Hello 😀\n \n Value: {{greeting}}\n " )
284+ . expect ( "failed to write input file" ) ;
285+ let output_dir = dir. path ( ) . join ( "dist" ) ;
286+ let config_content = format ! (
287+ "outputs:\n - type: html\n - type: pdf\n input: \" {}\" \n output_dir: \" {}\" \n variables:\n greeting: world\n " ,
288+ input_path. display( ) ,
289+ output_dir. display( )
290+ ) ;
291+ let mut f = NamedTempFile :: new ( ) . expect ( "failed to create temp file" ) ;
292+ f. write_all ( config_content. as_bytes ( ) )
293+ . expect ( "failed to write temp file" ) ;
294+ ( f, dir)
295+ }
296+
297+ #[ test]
298+ fn test_dry_run_multiple_outputs_succeeds ( ) {
299+ // Dry-run should succeed for multiple output formats without requiring
300+ // any external tools (pandoc/tectonic). Transforms run once and the
301+ // result is reused for each format.
302+ let ( f, dir) = multi_output_config_file ( ) ;
303+ let output_dir = dir. path ( ) . join ( "dist" ) ;
304+ let result = run ( f. path ( ) . to_str ( ) . unwrap ( ) , true ) ;
305+ assert ! ( result. is_ok( ) , "dry-run with multiple outputs should succeed: {:?}" , result) ;
306+ // No output directory should have been created in dry-run mode.
307+ assert ! ( !output_dir. exists( ) , "output directory must not be created in dry-run mode" ) ;
308+ }
309+
310+ #[ test]
311+ fn test_transforms_applied_once_content_consistent_across_formats ( ) {
312+ // Verify that transform output is consistent when multiple formats are
313+ // configured: the same variable substitution result should appear
314+ // regardless of how many output formats are requested. We exercise
315+ // this indirectly by checking that a dry-run with multiple outputs
316+ // succeeds with the same result as a single-output dry-run.
317+ let ( single_f, _single_dir) = valid_config_file ( ) ;
318+ let ( multi_f, _multi_dir) = multi_output_config_file ( ) ;
319+
320+ let single_result = run ( single_f. path ( ) . to_str ( ) . unwrap ( ) , true ) ;
321+ let multi_result = run ( multi_f. path ( ) . to_str ( ) . unwrap ( ) , true ) ;
322+
323+ assert ! ( single_result. is_ok( ) , "single-output dry-run failed: {:?}" , single_result) ;
324+ assert ! ( multi_result. is_ok( ) , "multi-output dry-run failed: {:?}" , multi_result) ;
325+ }
282326}
0 commit comments