1use crate::Error;
6use std::collections::HashSet;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use crate::types::document::{DocumentContext, parse_and_hydrate};
12
13pub trait FileResolver {
14 fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error>;
16}
17
18pub struct OsResolver {
19 include_paths: Vec<PathBuf>,
20}
21
22impl OsResolver {
23 pub fn new(include_paths: Vec<PathBuf>) -> Self {
24 let canonical_paths =
25 include_paths.into_iter().map(|p| std::fs::canonicalize(&p).unwrap_or(p)).collect();
26
27 Self { include_paths: canonical_paths }
28 }
29}
30
31impl FileResolver for OsResolver {
32 fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
33 let local_path =
34 if path.is_absolute() { path.to_path_buf() } else { current_dir.join(path) };
35 let include_paths_iter = self.include_paths.iter().map(|dir| dir.join(path));
36 let first_existing_path = std::iter::once(local_path)
37 .chain(include_paths_iter)
38 .find(|p| p.exists())
39 .ok_or_else(|| Error::internal(format!("File not found: {:?}", path)))?;
40 let abs = fs::canonicalize(&first_existing_path).map_err(|e| Error::Io(e))?;
41 let content = std::fs::read_to_string(&abs).map_err(|e| Error::Io(e))?;
42 Ok((abs, content))
43 }
44}
45
46pub struct CmlLoader<R: FileResolver> {
48 resolver: R,
49 visited: HashSet<PathBuf>,
50}
51
52impl<R: FileResolver> CmlLoader<R> {
53 pub fn new(resolver: R) -> Self {
54 Self { resolver, visited: HashSet::new() }
55 }
56
57 pub fn load_and_merge_all(&mut self, root_path: &Path) -> Result<DocumentContext, Error> {
58 let (root_path_abs, buffer) =
59 self.resolver.resolve(root_path, Path::new(".")).map_err(|e| {
60 Error::parse(
61 format!("Could not resolve root file {}: {}", root_path.display(), e),
62 None,
63 None,
64 )
65 })?;
66
67 self.visited.insert(root_path_abs.clone());
68
69 let file_arc = Arc::new(root_path_abs.clone());
70 let mut root_doc = parse_and_hydrate(file_arc, &buffer)?;
71
72 let mut stack = HashSet::new();
73 stack.insert(root_path_abs.clone());
74
75 self.resolve_includes_recursive(&mut root_doc, &root_path_abs, &mut stack)?;
76
77 Ok(root_doc)
78 }
79
80 fn resolve_includes_recursive(
81 &mut self,
82 target_doc: &mut DocumentContext,
83 current_file_path: &Path,
84 stack: &mut HashSet<PathBuf>,
85 ) -> Result<(), Error> {
86 let current_dir = current_file_path.parent().unwrap_or_else(|| Path::new("."));
87
88 if let Some(includes) = target_doc.include.take() {
89 for include_span in includes {
90 let include_path = Path::new(&include_span.value);
91
92 let (shard_path_abs, buffer) = self
93 .resolver
94 .resolve(include_path, current_dir)
95 .map_err(|e| e.with_origin(include_span.origin.clone()))?;
96
97 if stack.contains(&shard_path_abs) {
98 return Err(Error::validate_context(
99 format!("Circular include detected: {:?}", shard_path_abs),
100 Some(include_span.origin.clone()),
101 ));
102 }
103
104 if self.visited.contains(&shard_path_abs) {
105 continue;
106 }
107
108 self.visited.insert(shard_path_abs.clone());
109 stack.insert(shard_path_abs.clone());
110
111 let file_arc = Arc::new(shard_path_abs.clone());
112 let mut shard_doc = parse_and_hydrate(file_arc, &buffer)
113 .map_err(|e| e.with_origin(include_span.origin.clone()))?;
114
115 let result =
116 self.resolve_includes_recursive(&mut shard_doc, &shard_path_abs, stack);
117
118 stack.remove(&shard_path_abs);
119
120 result?;
121
122 target_doc.merge_from(shard_doc, &shard_path_abs)?;
123 }
124 }
125 Ok(())
126 }
127
128 pub fn visited_files(&self) -> HashSet<PathBuf> {
129 self.visited.clone()
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use crate::OneOrMany;
136
137 use super::*;
138 use serde_json::json;
139 use std::collections::HashMap;
140
141 pub struct MockResolver {
142 pub files: HashMap<PathBuf, String>,
143 }
144
145 impl FileResolver for MockResolver {
146 fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
147 let relative = current_dir.join(path);
148 if let Some(content) = self.files.get(&relative) {
149 return Ok((relative, content.clone()));
150 }
151
152 if let Some(content) = self.files.get(path) {
153 return Ok((path.to_path_buf(), content.clone()));
154 }
155
156 Err(Error::internal(format!(
157 "Mock file not found: {:?}. (Current dir: {:?})",
158 path, current_dir
159 )))
160 }
161 }
162
163 #[test]
164 fn test_include_empty_array() {
165 let mut files = HashMap::new();
166 let main_path = PathBuf::from("main.cml");
167
168 files.insert(
169 main_path.clone(),
170 r#"{ "include": [], "program": { "runner": "elf" } }"#.to_string(),
171 );
172
173 let resolver = MockResolver { files };
174 let mut loader = CmlLoader::new(resolver);
175
176 let doc =
177 loader.load_and_merge_all(&main_path).expect("Failed to handle empty include array");
178 assert!(doc.program.is_some());
179 }
180
181 #[test]
182 fn test_no_includes() {
183 let mut files = HashMap::new();
184 let main_path = PathBuf::from("main.cml");
185
186 files.insert(main_path.clone(), r#"{ "program": { "runner": "elf" } }"#.to_string());
187
188 let resolver = MockResolver { files };
189 let mut loader = CmlLoader::new(resolver);
190
191 let doc = loader
192 .load_and_merge_all(&main_path)
193 .expect("Failed to load document without includes");
194 assert!(doc.program.is_some());
195 }
196
197 #[test]
198 fn test_recursive_merge() {
199 let mut files = HashMap::new();
200
201 let root_path = PathBuf::from("/app/main.cml");
202 let shard_path = PathBuf::from("/app/shards/network.shard.cml");
203
204 files.insert(
205 root_path.clone(),
206 r#"{ "include": [ "shards/network.shard.cml" ] }"#.to_string(),
207 );
208 files.insert(
209 shard_path.clone(),
210 r#"{ "capabilities": [ { "protocol": "fuchsia.test.Protocol" } ] }"#.to_string(),
211 );
212
213 let resolver = MockResolver { files };
214 let mut loader = CmlLoader::new(resolver);
215
216 let doc = loader.load_and_merge_all(&root_path).expect("Mock load failed");
217
218 let caps = doc.capabilities.as_ref().unwrap();
219 let protocol_field = caps[0].value.protocol.as_ref().expect("Protocol missing");
220
221 match &protocol_field.value {
222 OneOrMany::One(name) => {
223 assert_eq!(name.as_str(), "fuchsia.test.Protocol");
224 }
225 OneOrMany::Many(_) => {
226 panic!("Expected a single protocol, found a list");
227 }
228 }
229
230 assert_eq!(caps[0].origin.as_ref(), &shard_path);
231 }
232
233 #[test]
234 fn test_invalid_include_file_not_found() {
235 let mut files = HashMap::new();
236 let main_path = PathBuf::from("main.cml");
237
238 files.insert(main_path.clone(), r#"{ "include": ["doesnt_exist.cml"] }"#.to_string());
239
240 let resolver = MockResolver { files };
241 let mut loader = CmlLoader::new(resolver);
242
243 let result = loader.load_and_merge_all(&main_path);
244
245 assert!(result.is_err(), "Loader should fail when an include is missing");
246 }
247
248 #[test]
249 fn test_relative_include_chain() {
250 let mut files = HashMap::new();
251
252 let root_path = PathBuf::from("/root.cml");
253 let driver_path = PathBuf::from("/sys/driver.cml");
254 let logger_path = PathBuf::from("/sys/logger.cml");
255
256 files.insert(root_path.clone(), r#"{ "include": [ "sys/driver.cml" ] }"#.to_string());
257 files.insert(driver_path.clone(), r#"{ "include": [ "logger.cml" ] }"#.to_string());
259 files.insert(
260 logger_path.clone(),
261 r#"{
262 "capabilities": [ { "protocol": "fuchsia.sys.Logger" } ]
263 }"#
264 .to_string(),
265 );
266
267 let resolver = MockResolver { files };
268 let mut loader = CmlLoader::new(resolver);
269
270 let doc = loader.load_and_merge_all(&root_path).expect("Failed to follow relative chain");
271
272 let caps = doc.capabilities.as_ref().unwrap();
273 assert_eq!(caps.len(), 1);
274
275 assert_eq!(caps[0].origin.as_ref(), &logger_path);
276 }
277
278 #[test]
279 fn test_circular_include_error() {
280 let mut files = HashMap::new();
281 let a_path = PathBuf::from("/a.cml");
282 let b_path = PathBuf::from("/b.cml");
283
284 files.insert(a_path.clone(), r#"{ "include": [ "b.cml" ] }"#.to_string());
285 files.insert(b_path.clone(), r#"{ "include": [ "a.cml" ] }"#.to_string());
286
287 let resolver = MockResolver { files };
288 let mut loader = CmlLoader::new(resolver);
289
290 let result = loader.load_and_merge_all(&a_path);
291
292 assert!(result.is_err());
293 }
294
295 #[test]
296 fn test_include_from_search_path() {
297 let mut files = HashMap::new();
298 let root_path = PathBuf::from("/app/root.cml");
299
300 let sdk_path = PathBuf::from("/sdk/lib/common.cml");
301
302 files.insert(root_path.clone(), r#"{ "include": [ "common.cml" ] }"#.to_string());
303 files.insert(sdk_path.clone(), r#"{ "offer": [] }"#.to_string());
304
305 struct SearchPathMock {
306 files: HashMap<PathBuf, String>,
307 }
308 impl FileResolver for SearchPathMock {
309 fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
310 let local = current_dir.join(path);
311 if self.files.contains_key(&local.clone()) {
312 return Ok((local.clone(), self.files[&local].clone()));
313 }
314
315 let sdk_candidate = PathBuf::from("/sdk/lib").join(path);
316 if let Some(content) = self.files.get(&sdk_candidate) {
317 return Ok((sdk_candidate, content.clone()));
318 }
319 Err(Error::internal("Not found"))
320 }
321 }
322
323 let resolver = SearchPathMock { files };
324 let mut loader = CmlLoader::new(resolver);
325
326 let result = loader.load_and_merge_all(&root_path);
327 assert!(result.is_ok(), "Loader should have found common.cml in the SDK path");
328 }
329
330 #[test]
331 fn test_include_cml_with_dictionary() {
332 let mut files = HashMap::new();
333 let shard_path = PathBuf::from("shard.cml");
334 let main_path = PathBuf::from("main.cml");
335
336 files.insert(
337 shard_path.clone(),
338 json!({
339 "expose": [
340 {
341 "dictionary": "diagnostics",
342 "from": "self",
343 }
344 ],
345 "capabilities": [
346 {
347 "dictionary": "diagnostics",
348 }
349 ]
350 })
351 .to_string(),
352 );
353 files.insert(
354 main_path.clone(),
355 json!({
356 "include": ["shard.cml"],
357 "program": {
358 "binary": "bin/hello_world",
359 "runner": "foo"
360 }
361 })
362 .to_string(),
363 );
364
365 let resolver = MockResolver { files };
366
367 let mut loader = CmlLoader::new(resolver);
368 let merged_doc = loader.load_and_merge_all(&main_path).unwrap();
369
370 let expected_cml = json!({
371 "program": {
372 "binary": "bin/hello_world",
373 "runner": "foo"
374 },
375 "expose": [
376 {
377 "dictionary": "diagnostics",
378 "from": "self",
379 }
380 ],
381 "capabilities": [
382 {
383 "dictionary": "diagnostics",
384 }
385 ]
386 })
387 .to_string();
388
389 let expected_doc = crate::load_cml_with_context(&expected_cml, Path::new("expected.cml"))
390 .expect("failed to parse expected");
391
392 assert_eq!(merged_doc, expected_doc)
393 }
394
395 #[test]
396 fn test_diamond_dependency_is_safe() {
397 let mut files = HashMap::new();
398
399 let a = PathBuf::from("/a.cml");
400 let b = PathBuf::from("/b.cml");
401 let c = PathBuf::from("/c.cml");
402 let d = PathBuf::from("/d.cml");
403
404 files.insert(a.clone(), r#"{ "include": ["b.cml", "c.cml"] }"#.to_string());
405 files.insert(b.clone(), r#"{ "include": ["d.cml"] }"#.to_string());
406 files.insert(c.clone(), r#"{ "include": ["d.cml"] }"#.to_string());
407
408 files.insert(
409 d.clone(),
410 r#"{
411 "capabilities": [ { "protocol": "fuchsia.diamond.Protocol" } ]
412 }"#
413 .to_string(),
414 );
415
416 let resolver = MockResolver { files };
417 let mut loader = CmlLoader::new(resolver);
418
419 let doc = loader.load_and_merge_all(&a).expect("Diamond dependency should succeed");
420
421 let caps = doc.capabilities.as_ref().unwrap();
422
423 assert_eq!(caps.len(), 1, "Should have exactly one capability after merge");
424
425 assert!(loader.visited_files().contains(&d));
426 }
427
428 #[test]
429 fn test_include_multiple_shards() {
430 let mut files = HashMap::new();
431 let main_path = PathBuf::from("main.cml");
432
433 files.insert(
434 main_path.clone(),
435 r#"{ "include": ["shard1.cml", "shard2.cml"] }"#.to_string(),
436 );
437 files.insert(
438 PathBuf::from("shard1.cml"),
439 r#"{ "use": [{ "protocol": "fuchsia.foo.A" }] }"#.to_string(),
440 );
441 files.insert(
442 PathBuf::from("shard2.cml"),
443 r#"{ "use": [{ "protocol": "fuchsia.foo.B" }] }"#.to_string(),
444 );
445
446 let resolver = MockResolver { files };
447 let mut loader = CmlLoader::new(resolver);
448
449 let doc = loader.load_and_merge_all(&main_path).expect("Failed to load multiple shards");
450
451 let uses = doc.r#use.expect("Should have merged use block");
452 assert_eq!(uses.len(), 2, "Should have loaded and merged both shards");
453 }
454
455 #[test]
456 fn test_include_absolute_path() {
457 let mut files = HashMap::new();
458 let main_path = PathBuf::from("/some/nested/dir/main.cml");
459
460 files.insert(main_path.clone(), r#"{ "include": ["//path/to/shard.cml"] }"#.to_string());
461
462 files.insert(
464 PathBuf::from("/path/to/shard.cml"),
465 r#"{ "capabilities": [{ "protocol": "foo" }] }"#.to_string(),
466 );
467
468 let resolver = MockResolver { files };
469 let mut loader = CmlLoader::new(resolver);
470
471 let doc =
472 loader.load_and_merge_all(&main_path).expect("Failed to resolve absolute include");
473 assert!(doc.capabilities.is_some());
474 }
475}