Skip to content

Commit 30c6abe

Browse files
Copilotszmyty
andauthored
Wire Pandoc-native template rendering into output strategies (#82)
* Initial plan * Wire template rendering into output strategies using Pandoc-native approach Co-authored-by: szmyty <14865041+szmyty@users.noreply.github.com> Agent-Logs-Url: https://github.com/egohygiene/renderflow/sessions/9fa2d768-a420-4783-abba-42f9d24533db --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: szmyty <14865041+szmyty@users.noreply.github.com>
1 parent 7d9f45d commit 30c6abe

4 files changed

Lines changed: 174 additions & 35 deletions

File tree

src/commands/build.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,22 @@ pub fn run(config_path: &str, dry_run: bool) -> Result<()> {
5252
.unwrap_or("document");
5353

5454
let tera = init_tera("templates")?;
55-
info!("Tera template engine initialised with {} template(s)", tera.get_template_names().count());
55+
let template_count = tera.get_template_names().count();
56+
info!("Tera template engine initialised with {} template(s)", template_count);
57+
58+
// Warn early if any configured template is not present in the templates directory.
59+
for output in &config.outputs {
60+
if let Some(ref name) = output.template {
61+
if !tera.get_template_names().any(|n| n == name) {
62+
warn!(
63+
template = %name,
64+
"Configured template '{}' was not found in the templates directory; \
65+
rendering will fail if this template is required.",
66+
name
67+
);
68+
}
69+
}
70+
}
5671

5772
let output_formats: Vec<String> = config.outputs.iter().map(|o| o.output_type.to_string()).collect();
5873
if output_formats.is_empty() {
@@ -101,7 +116,7 @@ pub fn run(config_path: &str, dry_run: bool) -> Result<()> {
101116
pb.inc(1);
102117
pb.println(format!("[dry-run] Would write output to: {}", output_path));
103118
} else {
104-
let strategy = select_strategy(format, output.template.clone())?;
119+
let strategy = select_strategy(format, output.template.clone(), "templates".to_string())?;
105120
pipeline.add_step(Box::new(StrategyStep::new(strategy, &output_path)));
106121

107122
pb.set_message(format!("[{format}] Rendering output"));

src/strategies/html.rs

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use anyhow::{Context, Result};
2+
use std::path::Path;
23
use tracing::info;
34

45
use crate::adapters::command::run_command;
@@ -7,18 +8,41 @@ use crate::strategies::OutputStrategy;
78
/// Renders a document to HTML format using pandoc.
89
pub struct HtmlStrategy {
910
pub template: Option<String>,
11+
pub template_dir: String,
1012
}
1113

1214
impl HtmlStrategy {
13-
pub fn new(template: Option<String>) -> Self {
14-
Self { template }
15+
pub fn new(template: Option<String>, template_dir: String) -> Self {
16+
Self { template, template_dir }
1517
}
1618
}
1719

1820
impl OutputStrategy for HtmlStrategy {
1921
fn render(&self, input: &str, output_path: &str) -> Result<()> {
2022
info!(input = %input, output = %output_path, template = ?self.template, "Rendering HTML via pandoc");
21-
run_command("pandoc", &["--from", "markdown", input, "-o", output_path])
23+
24+
// Resolve the optional template to a file path within the template directory.
25+
let template_path = if let Some(ref name) = self.template {
26+
let path = Path::new(&self.template_dir).join(name);
27+
if !path.exists() {
28+
anyhow::bail!(
29+
"Template file not found: '{}'. \
30+
Ensure the template exists in the configured template directory.",
31+
path.display()
32+
);
33+
}
34+
info!("Using template: {}", name);
35+
Some(path.to_string_lossy().into_owned())
36+
} else {
37+
None
38+
};
39+
40+
let mut args = vec!["--from", "markdown", input, "-o", output_path];
41+
if let Some(ref path) = template_path {
42+
args.extend_from_slice(&["--template", path.as_str()]);
43+
}
44+
45+
run_command("pandoc", &args)
2246
.with_context(|| format!(
2347
"Failed to render HTML output '{}'. \
2448
Check that pandoc is installed (`pandoc --version`) and that the input file '{}' is valid Markdown.",
@@ -35,7 +59,7 @@ mod tests {
3559

3660
#[test]
3761
fn test_html_strategy_errors_on_missing_input() {
38-
let strategy = HtmlStrategy::new(None);
62+
let strategy = HtmlStrategy::new(None, "templates".to_string());
3963
let result = strategy.render("/nonexistent/input.md", "/tmp/output.html");
4064
assert!(result.is_err());
4165
let msg = format!("{:#}", result.unwrap_err());
@@ -48,8 +72,41 @@ mod tests {
4872

4973
#[test]
5074
fn test_html_strategy_stores_template() {
51-
let strategy = HtmlStrategy::new(Some("default".to_string()));
52-
assert_eq!(strategy.template, Some("default".to_string()));
75+
let strategy = HtmlStrategy::new(Some("default.html".to_string()), "templates".to_string());
76+
assert_eq!(strategy.template, Some("default.html".to_string()));
77+
}
78+
79+
#[test]
80+
fn test_html_strategy_missing_template_returns_clear_error() {
81+
use std::io::Write;
82+
use tempfile::NamedTempFile;
83+
84+
let mut input = NamedTempFile::new().unwrap();
85+
writeln!(input, "# Hello\n\nThis is a test.").unwrap();
86+
87+
let strategy = HtmlStrategy::new(
88+
Some("nonexistent.html".to_string()),
89+
"/nonexistent/template/dir".to_string(),
90+
);
91+
let result = strategy.render(
92+
input.path().to_str().unwrap(),
93+
"/tmp/output.html",
94+
);
95+
assert!(result.is_err());
96+
let msg = result.unwrap_err().to_string();
97+
assert!(
98+
msg.contains("Template file not found"),
99+
"error should mention missing template, got: {}",
100+
msg
101+
);
102+
}
103+
104+
#[test]
105+
fn test_html_strategy_no_template_does_not_check_template_dir() {
106+
// When no template is configured the template_dir is never accessed,
107+
// so a non-existent directory must not cause an error at construction time.
108+
let strategy = HtmlStrategy::new(None, "/nonexistent/dir".to_string());
109+
assert!(strategy.template.is_none());
53110
}
54111

55112
#[test]
@@ -64,12 +121,42 @@ mod tests {
64121
let output = NamedTempFile::new().unwrap();
65122
let output_path = output.path().with_extension("html");
66123

67-
let strategy = HtmlStrategy::new(None);
124+
let strategy = HtmlStrategy::new(None, "templates".to_string());
68125
let result = strategy.render(
69126
input.path().to_str().unwrap(),
70127
output_path.to_str().unwrap(),
71128
);
72129
assert!(result.is_ok());
73130
assert!(output_path.exists());
74131
}
132+
133+
#[test]
134+
#[ignore = "requires pandoc to be installed"]
135+
fn test_html_strategy_with_template_produces_output() {
136+
use std::fs;
137+
use std::io::Write;
138+
use tempfile::{NamedTempFile, TempDir};
139+
140+
let template_dir = TempDir::new().unwrap();
141+
let template_path = template_dir.path().join("custom.html");
142+
// Use pandoc template syntax ($body$) rather than Tera syntax.
143+
fs::write(&template_path, "$body$").unwrap();
144+
145+
let mut input = NamedTempFile::new().unwrap();
146+
writeln!(input, "# Hello\n\nThis is a test.").unwrap();
147+
148+
let output = NamedTempFile::new().unwrap();
149+
let output_path = output.path().with_extension("html");
150+
151+
let strategy = HtmlStrategy::new(
152+
Some("custom.html".to_string()),
153+
template_dir.path().to_str().unwrap().to_string(),
154+
);
155+
let result = strategy.render(
156+
input.path().to_str().unwrap(),
157+
output_path.to_str().unwrap(),
158+
);
159+
assert!(result.is_ok(), "expected render to succeed with a valid template: {:?}", result);
160+
assert!(output_path.exists());
161+
}
75162
}

src/strategies/pdf.rs

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use anyhow::{Context, Result};
22
use std::io::ErrorKind;
3+
use std::path::Path;
34
use tracing::info;
45

56
use crate::adapters::command::run_command;
@@ -8,11 +9,12 @@ use crate::strategies::OutputStrategy;
89
/// Renders a document to PDF format using pandoc with the tectonic PDF engine.
910
pub struct PdfStrategy {
1011
pub template: Option<String>,
12+
pub template_dir: String,
1113
}
1214

1315
impl PdfStrategy {
14-
pub fn new(template: Option<String>) -> Self {
15-
Self { template }
16+
pub fn new(template: Option<String>, template_dir: String) -> Self {
17+
Self { template, template_dir }
1618
}
1719

1820
/// Returns an error if the tectonic PDF engine is not installed.
@@ -40,17 +42,35 @@ impl OutputStrategy for PdfStrategy {
4042

4143
Self::check_tectonic()?;
4244

43-
run_command(
44-
"pandoc",
45-
&[
46-
"--from",
47-
"markdown",
48-
input,
49-
"-o",
50-
output_path,
51-
"--pdf-engine=tectonic",
52-
],
53-
)
45+
// Resolve the optional template to a file path within the template directory.
46+
let template_path = if let Some(ref name) = self.template {
47+
let path = Path::new(&self.template_dir).join(name);
48+
if !path.exists() {
49+
anyhow::bail!(
50+
"Template file not found: '{}'. \
51+
Ensure the template exists in the configured template directory.",
52+
path.display()
53+
);
54+
}
55+
info!("Using template: {}", name);
56+
Some(path.to_string_lossy().into_owned())
57+
} else {
58+
None
59+
};
60+
61+
let mut args = vec![
62+
"--from",
63+
"markdown",
64+
input,
65+
"-o",
66+
output_path,
67+
"--pdf-engine=tectonic",
68+
];
69+
if let Some(ref path) = template_path {
70+
args.extend_from_slice(&["--template", path.as_str()]);
71+
}
72+
73+
run_command("pandoc", &args)
5474
.with_context(|| format!(
5575
"Failed to render PDF output '{}'. \
5676
Check that pandoc and tectonic are installed (`pandoc --version`, `tectonic --version`) \
@@ -77,7 +97,7 @@ mod tests {
7797

7898
#[test]
7999
fn test_pdf_strategy_errors_on_missing_input() {
80-
let strategy = PdfStrategy::new(None);
100+
let strategy = PdfStrategy::new(None, "templates".to_string());
81101
let result = strategy.render("/nonexistent/input.md", "/tmp/output.pdf");
82102
assert!(result.is_err());
83103
let msg = format!("{:#}", result.unwrap_err());
@@ -107,8 +127,16 @@ mod tests {
107127

108128
#[test]
109129
fn test_pdf_strategy_stores_template() {
110-
let strategy = PdfStrategy::new(Some("default".to_string()));
111-
assert_eq!(strategy.template, Some("default".to_string()));
130+
let strategy = PdfStrategy::new(Some("default.html".to_string()), "templates".to_string());
131+
assert_eq!(strategy.template, Some("default.html".to_string()));
132+
}
133+
134+
#[test]
135+
fn test_pdf_strategy_no_template_does_not_check_template_dir() {
136+
// When no template is configured the template_dir is never accessed,
137+
// so a non-existent directory must not cause an error at construction time.
138+
let strategy = PdfStrategy::new(None, "/nonexistent/dir".to_string());
139+
assert!(strategy.template.is_none());
112140
}
113141

114142
#[test]
@@ -123,7 +151,7 @@ mod tests {
123151
let output = NamedTempFile::new().unwrap();
124152
let output_path = output.path().with_extension("pdf");
125153

126-
let strategy = PdfStrategy::new(None);
154+
let strategy = PdfStrategy::new(None, "templates".to_string());
127155
let result = strategy.render(
128156
input.path().to_str().unwrap(),
129157
output_path.to_str().unwrap(),

src/strategies/selector.rs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ use crate::strategies::{HtmlStrategy, OutputStrategy, PdfStrategy};
55

66
/// Select an output strategy based on the given output type.
77
///
8-
/// The optional `template` name is forwarded to the chosen strategy so that it
9-
/// can reference the correct Tera template when rendering.
10-
pub fn select_strategy(output_type: OutputType, template: Option<String>) -> Result<Box<dyn OutputStrategy>> {
8+
/// The optional `template` name and `template_dir` are forwarded to the chosen
9+
/// strategy so that it can locate the correct template file when rendering.
10+
/// When `template` is `None` the strategy falls back to default pandoc behaviour.
11+
pub fn select_strategy(
12+
output_type: OutputType,
13+
template: Option<String>,
14+
template_dir: String,
15+
) -> Result<Box<dyn OutputStrategy>> {
1116
match output_type {
12-
OutputType::Html => Ok(Box::new(HtmlStrategy::new(template))),
13-
OutputType::Pdf => Ok(Box::new(PdfStrategy::new(template))),
17+
OutputType::Html => Ok(Box::new(HtmlStrategy::new(template, template_dir))),
18+
OutputType::Pdf => Ok(Box::new(PdfStrategy::new(template, template_dir))),
1419
}
1520
}
1621

@@ -20,26 +25,30 @@ mod tests {
2025

2126
#[test]
2227
fn test_select_strategy_html() {
23-
let result = select_strategy(OutputType::Html, None);
28+
let result = select_strategy(OutputType::Html, None, "templates".to_string());
2429
assert!(result.is_ok(), "expected html strategy to be selected");
2530
}
2631

2732
#[test]
2833
fn test_select_strategy_pdf() {
29-
let result = select_strategy(OutputType::Pdf, None);
34+
let result = select_strategy(OutputType::Pdf, None, "templates".to_string());
3035
assert!(result.is_ok(), "expected pdf strategy to be selected");
3136
}
3237

3338
#[test]
3439
fn test_select_strategy_html_renders_error_on_missing_input() {
35-
let strategy = select_strategy(OutputType::Html, None).unwrap();
40+
let strategy = select_strategy(OutputType::Html, None, "templates".to_string()).unwrap();
3641
let result = strategy.render("/nonexistent/input.md", "/tmp/output.html");
3742
assert!(result.is_err());
3843
}
3944

4045
#[test]
4146
fn test_select_strategy_passes_template_to_strategy() {
42-
let strategy = select_strategy(OutputType::Html, Some("default".to_string()));
47+
let strategy = select_strategy(
48+
OutputType::Html,
49+
Some("default.html".to_string()),
50+
"templates".to_string(),
51+
);
4352
assert!(strategy.is_ok());
4453
}
4554
}

0 commit comments

Comments
 (0)