Skip to main content

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;