1use globset::{Glob, GlobSet, GlobSetBuilder};
2use globwalk::{GlobWalkerBuilder, WalkError};
3use serde::Deserialize;
4use std::collections::hash_map::Entry;
5use std::collections::HashMap;
6use std::fmt;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10const REGISTRY_FILE_NAME: &str = ".sea-registry.toml";
11
12#[derive(Debug, Clone)]
13pub struct NamespaceBinding {
14 pub namespace: String,
15 pub path: PathBuf,
16}
17
18#[derive(Debug, Clone)]
19pub struct NamespaceRegistry {
20 root: PathBuf,
21 default_namespace: String,
22 entries: Vec<CompiledRule>,
23}
24
25#[derive(Debug, Clone)]
26struct CompiledRule {
27 namespace: String,
28 matcher: GlobSet,
29 patterns: Vec<String>,
30 literal_prefix_len: usize,
32}
33
34#[derive(Debug, Deserialize)]
35struct RawRegistry {
36 version: u8,
37 #[serde(default)]
38 default_namespace: Option<String>,
39 #[serde(default)]
40 namespaces: Vec<RawNamespace>,
41}
42
43#[derive(Debug, Deserialize)]
44struct RawNamespace {
45 namespace: String,
46 patterns: Vec<String>,
47}
48
49#[derive(Debug)]
50pub enum RegistryError {
51 Io(std::io::Error),
52 ParseToml(toml::de::Error),
53 InvalidVersion(u8),
54 MissingNamespaces,
55 MissingPatterns {
56 namespace: String,
57 },
58 InvalidPattern {
59 namespace: String,
60 pattern: String,
61 source: globset::Error,
62 },
63 InvalidGlob {
64 pattern: String,
65 message: String,
66 },
67 Walk(WalkError),
68 Conflict {
69 path: PathBuf,
70 existing: String,
71 requested: String,
72 },
73 Ambiguous {
74 path: PathBuf,
75 namespaces: Vec<String>,
76 },
77}
78
79impl fmt::Display for RegistryError {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 RegistryError::Io(err) => write!(f, "IO error: {}", err),
83 RegistryError::ParseToml(err) => write!(f, "Failed to parse registry: {}", err),
84 RegistryError::InvalidVersion(version) => {
85 write!(f, "Unsupported registry version {}", version)
86 }
87 RegistryError::MissingNamespaces => {
88 write!(f, "Registry must declare at least one namespace")
89 }
90 RegistryError::MissingPatterns { namespace } => {
91 write!(
92 f,
93 "Namespace '{}' must provide at least one pattern",
94 namespace
95 )
96 }
97 RegistryError::InvalidPattern {
98 namespace,
99 pattern,
100 source,
101 } => write!(
102 f,
103 "Invalid pattern '{}' for namespace '{}': {}",
104 pattern, namespace, source
105 ),
106 RegistryError::InvalidGlob { pattern, message } => {
107 write!(f, "Failed to build glob '{}': {}", pattern, message)
108 }
109 RegistryError::Walk(err) => write!(f, "Failed to walk globbed files: {}", err),
110 RegistryError::Ambiguous { path, namespaces } => write!(
111 f,
112 "File '{}' matched multiple namespaces: {}",
113 path.display(),
114 namespaces.join(", "),
115 ),
116 RegistryError::Conflict {
117 path,
118 existing,
119 requested,
120 } => write!(
121 f,
122 "File '{}' matched multiple namespaces: '{}' and '{}'",
123 path.display(),
124 existing,
125 requested
126 ),
127 }
128 }
129}
130
131impl std::error::Error for RegistryError {}
132
133impl From<std::io::Error> for RegistryError {
134 fn from(value: std::io::Error) -> Self {
135 RegistryError::Io(value)
136 }
137}
138
139impl From<toml::de::Error> for RegistryError {
140 fn from(value: toml::de::Error) -> Self {
141 RegistryError::ParseToml(value)
142 }
143}
144
145impl From<WalkError> for RegistryError {
146 fn from(value: WalkError) -> Self {
147 RegistryError::Walk(value)
148 }
149}
150
151impl NamespaceRegistry {
152 pub fn new_empty(root: PathBuf) -> Result<Self, RegistryError> {
153 let canonical_root = root.canonicalize()?;
154 Ok(Self {
155 root: canonical_root,
156 default_namespace: "default".to_string(),
157 entries: Vec::new(),
158 })
159 }
160
161 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, RegistryError> {
162 let registry_path = path.as_ref();
163 let contents = fs::read_to_string(registry_path)?;
164 let raw: RawRegistry = toml::from_str(&contents)?;
165
166 if raw.version != 1 {
167 return Err(RegistryError::InvalidVersion(raw.version));
168 }
169
170 if raw.namespaces.is_empty() {
171 return Err(RegistryError::MissingNamespaces);
172 }
173
174 let root = registry_path
175 .parent()
176 .unwrap_or_else(|| Path::new("."))
177 .canonicalize()?;
178
179 let default_namespace = raw
180 .default_namespace
181 .unwrap_or_else(|| "default".to_string());
182
183 let mut entries = Vec::with_capacity(raw.namespaces.len());
184 for ns in raw.namespaces {
185 if ns.patterns.is_empty() {
186 return Err(RegistryError::MissingPatterns {
187 namespace: ns.namespace,
188 });
189 }
190
191 let mut builder = GlobSetBuilder::new();
192 for pattern in &ns.patterns {
193 let glob = Glob::new(pattern).map_err(|source| RegistryError::InvalidPattern {
194 namespace: ns.namespace.clone(),
195 pattern: pattern.clone(),
196 source,
197 })?;
198 builder.add(glob);
199 }
200 let matcher = builder
201 .build()
202 .map_err(|source| RegistryError::InvalidPattern {
203 namespace: ns.namespace.clone(),
204 pattern: ns.patterns.join(","),
205 source,
206 })?;
207
208 let mut literal_prefix_len = 0usize;
210 for p in &ns.patterns {
211 let mut len = 0usize;
212 for ch in p.chars() {
213 if ch == '*'
215 || ch == '?'
216 || ch == '['
217 || ch == '{'
218 || ch == ']'
219 || ch == '}'
220 || ch == '\\'
221 {
222 break;
223 }
224 len += ch.len_utf8();
225 }
226 if len > literal_prefix_len {
227 literal_prefix_len = len;
228 }
229 }
230
231 entries.push(CompiledRule {
232 namespace: ns.namespace,
233 matcher,
234 patterns: ns.patterns,
235 literal_prefix_len,
236 });
237 }
238
239 Ok(Self {
240 root,
241 default_namespace,
242 entries,
243 })
244 }
245
246 pub fn discover(start: impl AsRef<Path>) -> Result<Option<Self>, RegistryError> {
247 let mut current = start.as_ref();
248 let path_buf;
249 if current.is_file() {
250 path_buf = current
251 .parent()
252 .map(|p| {
253 if p.as_os_str().is_empty() {
254 PathBuf::from(".")
255 } else {
256 p.to_path_buf()
257 }
258 })
259 .unwrap_or_else(|| PathBuf::from("."));
260 current = &path_buf;
261 }
262
263 let mut dir = current.canonicalize()?;
264 loop {
265 let candidate = dir.join(REGISTRY_FILE_NAME);
266 if candidate.is_file() {
267 return Ok(Some(Self::from_file(candidate)?));
268 }
269
270 if !dir.pop() {
271 break;
272 }
273 }
274
275 Ok(None)
276 }
277
278 pub fn root(&self) -> &Path {
279 &self.root
280 }
281
282 pub fn default_namespace(&self) -> &str {
283 &self.default_namespace
284 }
285
286 pub fn namespace_for(&self, path: impl AsRef<Path>) -> Option<&str> {
287 self.namespace_for_with_options(path, false).ok()
288 }
289
290 pub fn namespace_for_with_options(
291 &self,
292 path: impl AsRef<Path>,
293 fail_on_ambiguity: bool,
294 ) -> Result<&str, RegistryError> {
295 let mut absolute = path.as_ref().to_path_buf();
296 if !absolute.is_absolute() {
297 absolute = self.root.join(absolute);
298 }
299 let absolute = absolute.canonicalize().ok().unwrap_or(absolute);
300 let relative = match absolute.strip_prefix(&self.root) {
301 Ok(rel) => rel,
302 Err(_) => return Ok(self.default_namespace.as_str()),
303 };
304 let normalized = normalize_path(relative);
305
306 let mut candidates: Vec<(&CompiledRule, usize)> = vec![];
310 let mut best_len: usize = 0;
311 for entry in &self.entries {
312 if entry.matcher.is_match(normalized.as_str()) {
313 let len = entry.literal_prefix_len;
314 if len > best_len {
315 candidates.clear();
316 candidates.push((entry, len));
317 best_len = len;
318 } else if len == best_len {
319 candidates.push((entry, len));
320 }
321 }
322 }
323
324 if candidates.is_empty() {
325 return Ok(self.default_namespace.as_str());
326 }
327
328 if candidates.len() == 1 {
329 return Ok(candidates[0].0.namespace.as_str());
330 }
331
332 if fail_on_ambiguity {
334 let mut names: Vec<String> = candidates
335 .into_iter()
336 .map(|(e, _)| e.namespace.clone())
337 .collect();
338 names.sort();
339 return Err(RegistryError::Ambiguous {
340 path: path.as_ref().to_path_buf(),
341 namespaces: names,
342 });
343 }
344
345 let mut chosen_entry: &CompiledRule = candidates[0].0;
347 for (entry, _) in candidates.into_iter().skip(1) {
348 if entry.namespace < chosen_entry.namespace {
349 chosen_entry = entry;
350 }
351 }
352
353 Ok(chosen_entry.namespace.as_str())
354 }
355
356 pub fn resolve_files(&self) -> Result<Vec<NamespaceBinding>, RegistryError> {
357 self.resolve_files_with_options(false)
358 }
359
360 pub fn resolve_files_with_options(
361 &self,
362 fail_on_ambiguity: bool,
363 ) -> Result<Vec<NamespaceBinding>, RegistryError> {
364 let mut matches: HashMap<PathBuf, (String, usize)> = HashMap::new();
366
367 for entry in &self.entries {
368 for pattern in &entry.patterns {
369 let walker = GlobWalkerBuilder::from_patterns(&self.root, &[pattern.as_str()])
370 .follow_links(true)
371 .file_type(globwalk::FileType::FILE)
372 .build()
373 .map_err(|err| RegistryError::InvalidGlob {
374 pattern: pattern.clone(),
375 message: err.to_string(),
376 })?;
377
378 for dir_entry in walker.into_iter() {
379 let dir_entry = dir_entry?;
380 let path = dir_entry.into_path();
381 let current_len = entry.literal_prefix_len;
382 match matches.entry(path.clone()) {
383 Entry::Vacant(v) => {
384 v.insert((entry.namespace.clone(), current_len));
385 }
386 Entry::Occupied(mut occupied) => {
387 let (ref existing_ns, existing_len) = occupied.get().clone();
388 if existing_ns == &entry.namespace {
389 continue;
391 }
392 if current_len > existing_len {
393 occupied.insert((entry.namespace.clone(), current_len));
394 } else if current_len == existing_len {
395 if fail_on_ambiguity {
396 let mut conflicted = vec![existing_ns.clone()];
398 conflicted.push(entry.namespace.clone());
399 conflicted.sort();
400 return Err(RegistryError::Ambiguous {
401 path: path.clone(),
402 namespaces: conflicted,
403 });
404 }
405 if entry.namespace < *existing_ns {
407 occupied.insert((entry.namespace.clone(), current_len));
408 }
409 }
410 }
412 }
413 }
414 }
415 }
416
417 let mut bindings: Vec<NamespaceBinding> = matches
418 .into_iter()
419 .map(|(path, (namespace, _len))| NamespaceBinding { path, namespace })
420 .collect();
421 bindings.sort_by(|a, b| a.path.cmp(&b.path));
422 Ok(bindings)
423 }
424}
425
426fn normalize_path(path: &Path) -> String {
427 let repr = path.to_string_lossy().replace('\\', "/");
428 repr.trim_start_matches("./").to_string()
429}
430
431#[cfg(test)]
432mod tests;