mollendorff_forge/parser/
includes.rs1use 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
13pub 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 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 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 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 if !included_model.includes.is_empty() {
58 resolve_includes(&mut included_model, &include_path, visited)?;
59 }
60
61 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
75pub 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 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 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 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 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}