rust_config_tree/template_tree.rs
1//! Low-level template target discovery.
2//!
3//! This module maps source config files to output template files by following
4//! include paths. It does not render template content; callers provide include
5//! discovery and decide how each target should be rendered.
6
7use std::path::{Path, PathBuf};
8
9use crate::{
10 BoxError, Result, absolutize_lexical, resolve_include_path,
11 tree::{TraversalState, validate_include_paths},
12};
13
14/// A source-to-output mapping for one generated config template.
15///
16/// The source path is used to discover includes. The target path is the output
17/// file that should receive the rendered template content.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct TemplateTarget {
20 source_path: PathBuf,
21 target_path: PathBuf,
22 include_paths: Vec<PathBuf>,
23}
24
25/// Accessors for generated template target metadata.
26impl TemplateTarget {
27 /// Returns the config source path used to discover this target's includes.
28 ///
29 /// # Arguments
30 ///
31 /// - `self`: Template target being inspected.
32 ///
33 /// # Returns
34 ///
35 /// Returns the source path for this template target.
36 ///
37 /// # Examples
38 ///
39 /// ```
40 /// use std::{io, path::{Path, PathBuf}};
41 /// use rust_config_tree::collect_template_targets;
42 ///
43 /// let targets = collect_template_targets(
44 /// "config.yaml",
45 /// "config.example.yaml",
46 /// |_path: &Path| -> io::Result<Vec<PathBuf>> { Ok(Vec::new()) },
47 /// )?;
48 ///
49 /// assert!(targets[0].source_path().ends_with("config.example.yaml"));
50 /// # Ok::<(), rust_config_tree::ConfigTreeError>(())
51 /// ```
52 pub fn source_path(&self) -> &Path {
53 &self.source_path
54 }
55
56 /// Returns the output path that should receive this target's template.
57 ///
58 /// # Arguments
59 ///
60 /// - `self`: Template target being inspected.
61 ///
62 /// # Returns
63 ///
64 /// Returns the output path for this template target.
65 ///
66 /// # Examples
67 ///
68 /// ```
69 /// use std::{io, path::{Path, PathBuf}};
70 /// use rust_config_tree::collect_template_targets;
71 ///
72 /// let targets = collect_template_targets(
73 /// "config.yaml",
74 /// "config.example.yaml",
75 /// |_path: &Path| -> io::Result<Vec<PathBuf>> { Ok(Vec::new()) },
76 /// )?;
77 ///
78 /// assert_eq!(targets[0].target_path(), Path::new("config.example.yaml"));
79 /// # Ok::<(), rust_config_tree::ConfigTreeError>(())
80 /// ```
81 pub fn target_path(&self) -> &Path {
82 &self.target_path
83 }
84
85 /// Returns include paths declared by this source target.
86 ///
87 /// # Arguments
88 ///
89 /// - `self`: Template target being inspected.
90 ///
91 /// # Returns
92 ///
93 /// Returns the include paths declared by the target source.
94 ///
95 /// # Examples
96 ///
97 /// ```
98 /// use std::{io, path::{Path, PathBuf}};
99 /// use rust_config_tree::collect_template_targets;
100 ///
101 /// let targets = collect_template_targets(
102 /// "config.yaml",
103 /// "config.example.yaml",
104 /// |path: &Path| -> io::Result<Vec<PathBuf>> {
105 /// if path.ends_with("config.example.yaml") {
106 /// Ok(vec![PathBuf::from("child.yaml")])
107 /// } else {
108 /// Ok(Vec::new())
109 /// }
110 /// },
111 /// )?;
112 ///
113 /// assert_eq!(targets[0].include_paths(), &[PathBuf::from("child.yaml")]);
114 /// # Ok::<(), rust_config_tree::ConfigTreeError>(())
115 /// ```
116 pub fn include_paths(&self) -> &[PathBuf] {
117 &self.include_paths
118 }
119
120 /// Decomposes the target into its source path, target path, and include paths.
121 ///
122 /// # Arguments
123 ///
124 /// - `self`: Template target to decompose.
125 ///
126 /// # Returns
127 ///
128 /// Returns `(source_path, target_path, include_paths)`.
129 ///
130 /// # Examples
131 ///
132 /// ```
133 /// use std::{io, path::{Path, PathBuf}};
134 /// use rust_config_tree::collect_template_targets;
135 ///
136 /// let mut targets = collect_template_targets(
137 /// "config.yaml",
138 /// "config.example.yaml",
139 /// |_path: &Path| -> io::Result<Vec<PathBuf>> { Ok(Vec::new()) },
140 /// )?;
141 ///
142 /// let (_source_path, target_path, include_paths) = targets.remove(0).into_parts();
143 /// assert_eq!(target_path, PathBuf::from("config.example.yaml"));
144 /// assert!(include_paths.is_empty());
145 /// # Ok::<(), rust_config_tree::ConfigTreeError>(())
146 /// ```
147 pub fn into_parts(self) -> (PathBuf, PathBuf, Vec<PathBuf>) {
148 (self.source_path, self.target_path, self.include_paths)
149 }
150}
151
152/// Chooses the source file used when generating templates.
153///
154/// Existing config files are preferred. If the config file does not exist, an
155/// existing output template is used as the source. If neither exists, the output
156/// path is returned so generation can start from an empty template tree.
157///
158/// # Arguments
159///
160/// - `config_path`: Preferred config source path.
161/// - `output_path`: Output template path used as the fallback source.
162///
163/// # Returns
164///
165/// Returns the path that should be used as the root template source.
166///
167/// # Examples
168///
169/// ```
170/// use std::path::PathBuf;
171/// use rust_config_tree::select_template_source;
172///
173/// let source = select_template_source("missing-config.yaml", "config.example.yaml");
174///
175/// assert_eq!(source, PathBuf::from("config.example.yaml"));
176/// ```
177pub fn select_template_source(
178 config_path: impl AsRef<Path>,
179 output_path: impl AsRef<Path>,
180) -> PathBuf {
181 let config_path = config_path.as_ref();
182 let output_path = output_path.as_ref();
183
184 if config_path.exists() {
185 return config_path.to_path_buf();
186 }
187
188 if output_path.exists() {
189 return output_path.to_path_buf();
190 }
191
192 output_path.to_path_buf()
193}
194
195/// Collects template targets by recursively following include paths.
196///
197/// `read_includes` receives each absolute source path and returns the include
198/// paths declared by that source. Relative include paths are resolved from the
199/// source file and mirrored under the output file's parent directory. Absolute
200/// include paths remain absolute targets. The callback is also called for source
201/// paths that do not exist yet, so callers can treat missing template sources as
202/// empty or synthesize default includes.
203///
204/// # Type Parameters
205///
206/// - `E`: Error type returned by `read_includes`.
207/// - `F`: Include reader callback type.
208///
209/// # Arguments
210///
211/// - `config_path`: Preferred config source path.
212/// - `output_path`: Root output template path.
213/// - `read_includes`: Callback that receives each normalized source path and
214/// returns include paths declared by that source.
215///
216/// # Returns
217///
218/// Returns all collected template targets in traversal order.
219///
220/// # Examples
221///
222/// ```
223/// use std::{io, path::{Path, PathBuf}};
224/// use rust_config_tree::collect_template_targets;
225///
226/// let targets = collect_template_targets(
227/// "config.yaml",
228/// "examples/config.example.yaml",
229/// |path: &Path| -> io::Result<Vec<PathBuf>> {
230/// if path.ends_with("config.example.yaml") {
231/// Ok(vec![PathBuf::from("child.yaml")])
232/// } else {
233/// Ok(Vec::new())
234/// }
235/// },
236/// )?;
237///
238/// assert_eq!(targets.len(), 2);
239/// assert_eq!(targets[1].target_path(), Path::new("examples/child.yaml"));
240/// # Ok::<(), rust_config_tree::ConfigTreeError>(())
241/// ```
242pub fn collect_template_targets<E, F>(
243 config_path: impl AsRef<Path>,
244 output_path: impl AsRef<Path>,
245 mut read_includes: F,
246) -> Result<Vec<TemplateTarget>>
247where
248 E: Into<BoxError>,
249 F: FnMut(&Path) -> std::result::Result<Vec<PathBuf>, E>,
250{
251 let source_path = select_template_source(config_path, output_path.as_ref());
252 let mut state = TraversalState::default();
253 let mut targets = Vec::new();
254 collect_template_target(
255 &source_path,
256 output_path.as_ref(),
257 &mut read_includes,
258 &mut state,
259 &mut targets,
260 )?;
261 Ok(targets)
262}
263
264/// Recursively maps one source template path to one output template path.
265///
266/// # Type Parameters
267///
268/// - `E`: Error type returned by `read_includes`.
269/// - `F`: Include reader callback type.
270///
271/// # Arguments
272///
273/// - `source_path`: Source template path used to read includes.
274/// - `target_path`: Output path mapped to `source_path`.
275/// - `read_includes`: Callback that reads include paths from a source path.
276/// - `state`: Traversal state used for cycle detection and deduplication.
277/// - `targets`: Output list receiving collected template targets.
278///
279/// # Returns
280///
281/// Returns `Ok(())` after this target and its children are collected.
282///
283/// # Examples
284///
285/// ```no_run
286/// let _ = ();
287/// ```
288fn collect_template_target<E, F>(
289 source_path: &Path,
290 target_path: &Path,
291 read_includes: &mut F,
292 state: &mut TraversalState,
293 targets: &mut Vec<TemplateTarget>,
294) -> Result<()>
295where
296 E: Into<BoxError>,
297 F: FnMut(&Path) -> std::result::Result<Vec<PathBuf>, E>,
298{
299 let source_path = absolutize_lexical(source_path)?;
300 if !state.enter(&source_path)? {
301 return Ok(());
302 }
303
304 let include_paths = read_includes(&source_path)
305 .map_err(|source| crate::ConfigTreeError::load(&source_path, source))?;
306 validate_include_paths(&source_path, &include_paths)?;
307
308 targets.push(TemplateTarget {
309 source_path: source_path.clone(),
310 target_path: target_path.to_path_buf(),
311 include_paths: include_paths.clone(),
312 });
313
314 let target_base_dir = target_path.parent().unwrap_or_else(|| Path::new("."));
315 for include_path in &include_paths {
316 let source_child = resolve_include_path(&source_path, include_path);
317 let target_child = if include_path.is_absolute() {
318 include_path.clone()
319 } else {
320 target_base_dir.join(include_path)
321 };
322 collect_template_target(&source_child, &target_child, read_includes, state, targets)?;
323 }
324
325 state.leave();
326 Ok(())
327}
328
329#[cfg(test)]
330#[path = "unit_tests/template.rs"]
331mod unit_tests;