Skip to main content

mollendorff_forge/parser/
includes.rs

1//! Include file resolution for Forge models (v4.0)
2//!
3//! Handles parsing and resolution of _includes sections for cross-file references.
4
5use crate::error::{ForgeError, ForgeResult};
6use crate::types::{Include, ParsedModel, ResolvedInclude};
7use serde_yaml_ng::Value;
8use std::collections::HashSet;
9use std::path::Path;
10
11use super::model::parse_v1_model;
12
13/// Resolve all includes in a model, loading and parsing referenced files.
14/// Detects circular dependencies.
15///
16/// # Errors
17///
18/// Returns an error if an included file is not found, contains invalid YAML,
19/// or creates a circular dependency.
20pub fn resolve_includes<S: std::hash::BuildHasher>(
21    model: &mut ParsedModel,
22    base_path: &Path,
23    visited: &mut HashSet<std::path::PathBuf, S>,
24) -> ForgeResult<()> {
25    let base_dir = base_path.parent().unwrap_or_else(|| Path::new("."));
26
27    // Check for circular dependency
28    let canonical = base_path
29        .canonicalize()
30        .unwrap_or_else(|_| base_path.to_path_buf());
31    if visited.contains(&canonical) {
32        return Err(ForgeError::Parse(format!(
33            "Circular dependency detected: {} is already included",
34            base_path.display()
35        )));
36    }
37    visited.insert(canonical);
38
39    // Process each include
40    for include in model.includes.clone() {
41        let include_path = base_dir.join(&include.file);
42
43        if !include_path.exists() {
44            return Err(ForgeError::Parse(format!(
45                "Included file not found: {} (referenced as '{}')",
46                include_path.display(),
47                include.file
48            )));
49        }
50
51        // Parse the included file
52        let content = std::fs::read_to_string(&include_path)?;
53        let yaml: Value = serde_yaml_ng::from_str(&content)?;
54        let mut included_model = parse_v1_model(&yaml)?;
55
56        // Recursively resolve includes in the included file
57        if !included_model.includes.is_empty() {
58            resolve_includes(&mut included_model, &include_path, visited)?;
59        }
60
61        // Store resolved include
62        let resolved = ResolvedInclude {
63            include: include.clone(),
64            resolved_path: include_path.canonicalize().unwrap_or(include_path),
65            model: included_model,
66        };
67        model
68            .resolved_includes
69            .insert(include.namespace.clone(), resolved);
70    }
71
72    Ok(())
73}
74
75/// Parse _includes section from YAML (v4.0 cross-file references)
76///
77/// Expected format:
78/// ```yaml
79/// _includes:
80///   - file: "data_sources.yaml"
81///     as: "sources"
82///   - file: "pricing.yaml"
83///     as: "pricing"
84/// ```
85///
86/// # Errors
87///
88/// Returns an error if an include entry is missing required `file` or `as` fields,
89/// or is not a valid mapping.
90pub fn parse_includes(includes_seq: &[Value], model: &mut ParsedModel) -> ForgeResult<()> {
91    for include_val in includes_seq {
92        if let Value::Mapping(include_map) = include_val {
93            // Extract 'file' field (required)
94            let file = include_map
95                .get("file")
96                .and_then(|v| v.as_str())
97                .ok_or_else(|| ForgeError::Parse("Include must have a 'file' field".to_string()))?
98                .to_string();
99
100            // Extract 'as' field (required - the namespace alias)
101            let namespace = include_map
102                .get("as")
103                .and_then(|v| v.as_str())
104                .ok_or_else(|| {
105                    ForgeError::Parse(format!(
106                        "Include '{file}' must have an 'as' field for the namespace"
107                    ))
108                })?
109                .to_string();
110
111            model.add_include(Include::new(file, namespace));
112        } else {
113            return Err(ForgeError::Parse(
114                "Each include must be a mapping with 'file' and 'as' fields".to_string(),
115            ));
116        }
117    }
118    Ok(())
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::io::Write;
125    use tempfile::{NamedTempFile, TempDir};
126
127    #[test]
128    fn test_parse_includes_section() {
129        let temp_dir = TempDir::new().unwrap();
130
131        let included_path = temp_dir.path().join("external.yaml");
132        std::fs::write(
133            &included_path,
134            r#"
135_forge_version: "5.0.0"
136ext_data:
137  values: [10, 20, 30]
138"#,
139        )
140        .unwrap();
141
142        let main_content = r#"
143_forge_version: "5.0.0"
144_includes:
145  - file: "external.yaml"
146    as: "ext"
147main_data:
148  values: [1, 2, 3]
149"#
150        .to_string();
151
152        let main_path = temp_dir.path().join("main.yaml");
153        std::fs::write(&main_path, main_content).unwrap();
154
155        let content = std::fs::read_to_string(&main_path).unwrap();
156        let yaml: Value = serde_yaml_ng::from_str(&content).unwrap();
157        let mut model = parse_v1_model(&yaml).unwrap();
158
159        resolve_includes(&mut model, &main_path, &mut HashSet::new()).unwrap();
160
161        assert!(model.tables.contains_key("main_data"));
162        assert!(model.resolved_includes.contains_key("ext"));
163    }
164
165    #[test]
166    fn test_parse_includes_missing_file() {
167        let yaml_content = r#"
168_forge_version: "5.0.0"
169_includes:
170  - file: "nonexistent.yaml"
171    as: "ext"
172data:
173  values: [1, 2, 3]
174"#;
175
176        let mut temp_file = NamedTempFile::new().unwrap();
177        temp_file.write_all(yaml_content.as_bytes()).unwrap();
178
179        let content = std::fs::read_to_string(temp_file.path()).unwrap();
180        let yaml: Value = serde_yaml_ng::from_str(&content).unwrap();
181        let mut model = parse_v1_model(&yaml).unwrap();
182
183        let result = resolve_includes(&mut model, temp_file.path(), &mut HashSet::new());
184        assert!(result.is_err());
185        let err_msg = result.unwrap_err().to_string();
186        assert!(err_msg.contains("not found") || err_msg.contains("nonexistent"));
187    }
188
189    #[test]
190    fn test_parse_includes_missing_as_field() {
191        let yaml_content = r#"
192_forge_version: "5.0.0"
193_includes:
194  - file: "external.yaml"
195data:
196  values: [1, 2, 3]
197"#;
198
199        let yaml: Value = serde_yaml_ng::from_str(yaml_content).unwrap();
200
201        // Try to parse the includes section
202        if let Some(Value::Sequence(includes_seq)) = yaml.get("_includes") {
203            let mut model = ParsedModel::new();
204            let result = parse_includes(includes_seq, &mut model);
205            assert!(result.is_err());
206        }
207    }
208
209    #[test]
210    fn test_parse_includes_invalid_format() {
211        let yaml_content = r#"
212_forge_version: "5.0.0"
213_includes:
214  - "just a string, not a mapping"
215data:
216  values: [1, 2, 3]
217"#;
218
219        let yaml: Value = serde_yaml_ng::from_str(yaml_content).unwrap();
220
221        // Try to parse the includes section
222        if let Some(Value::Sequence(includes_seq)) = yaml.get("_includes") {
223            let mut model = ParsedModel::new();
224            let result = parse_includes(includes_seq, &mut model);
225            assert!(result.is_err());
226        }
227    }
228}