1use std::collections::HashSet;
10
11use thiserror::Error;
12
13use crate::LOCKFILE_FILENAME;
14use crate::MANIFEST_FILENAME;
15use crate::SIGNATURE_FILENAME;
16use crate::relative_path::RelativePath;
17
18#[derive(Debug, Error)]
20pub enum TreeError {
21 #[error("reserved filename `{name}` is only permitted at the module root; found at `{path}`")]
25 ReservedFilename {
26 name: &'static str,
28 path: String,
30 },
31
32 #[error("paths collapse to the same NFC form `{nfc}`")]
36 AmbiguousPath {
37 nfc: String,
39 },
40}
41
42pub fn validate_tree<'a, I>(paths: I) -> Result<(), TreeError>
52where
53 I: IntoIterator<Item = &'a RelativePath>,
54{
55 let mut seen: HashSet<&RelativePath> = HashSet::new();
56 for path in paths {
57 if let Some((_, basename)) = path.as_str().rsplit_once('/')
58 && let Some(reserved) = [MANIFEST_FILENAME, LOCKFILE_FILENAME, SIGNATURE_FILENAME]
59 .into_iter()
60 .find(|r| *r == basename)
61 {
62 return Err(TreeError::ReservedFilename {
63 name: reserved,
64 path: path.as_str().to_string(),
65 });
66 }
67
68 if !seen.insert(path) {
69 return Err(TreeError::AmbiguousPath {
70 nfc: path.as_str().to_string(),
71 });
72 }
73 }
74 Ok(())
75}
76
77#[cfg(test)]
78mod tests {
79 use std::str::FromStr;
80
81 use super::*;
82
83 fn rel(s: &str) -> RelativePath {
84 RelativePath::from_str(s).unwrap()
85 }
86
87 #[test]
88 fn accepts_root_reserved_filenames() {
89 validate_tree(&[
90 rel(MANIFEST_FILENAME),
91 rel(LOCKFILE_FILENAME),
92 rel(SIGNATURE_FILENAME),
93 rel("index.wdl"),
94 ])
95 .unwrap();
96 }
97
98 #[test]
99 fn rejects_nested_manifest() {
100 let err = validate_tree(&[rel("src/module.json")]).unwrap_err();
101 assert!(matches!(
102 err,
103 TreeError::ReservedFilename {
104 name: MANIFEST_FILENAME,
105 ..
106 }
107 ));
108 }
109
110 #[test]
111 fn rejects_nested_lockfile() {
112 let err = validate_tree(&[rel("nested/dir/module-lock.json")]).unwrap_err();
113 assert!(matches!(
114 err,
115 TreeError::ReservedFilename {
116 name: LOCKFILE_FILENAME,
117 ..
118 }
119 ));
120 }
121
122 #[test]
123 fn rejects_nested_signature() {
124 let err = validate_tree(&[rel("sub/module.sig")]).unwrap_err();
125 assert!(matches!(
126 err,
127 TreeError::ReservedFilename {
128 name: SIGNATURE_FILENAME,
129 ..
130 }
131 ));
132 }
133
134 #[test]
135 fn rejects_paths_colliding_under_nfc() {
136 let err = validate_tree(&[rel("caf\u{00E9}.wdl"), rel("cafe\u{0301}.wdl")]).unwrap_err();
137 assert!(matches!(err, TreeError::AmbiguousPath { .. }));
138 }
139
140 #[test]
141 fn accepts_distinct_unicode_paths() {
142 validate_tree(&[rel("alpha.wdl"), rel("beta.wdl"), rel("caf\u{00E9}.wdl")]).unwrap();
143 }
144}