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