sea_core/registry/
mod.rs

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    // Length of literal prefix of the pattern before any wildcard or special glob char
31    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            // compute the longest literal prefix across patterns for this namespace rule
209            let mut literal_prefix_len = 0usize;
210            for p in &ns.patterns {
211                let mut len = 0usize;
212                for ch in p.chars() {
213                    // stop at glob meta characters
214                    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        // Collect all matches and pick the best candidate(s) using longest literal
307        // prefix precedence. If more than one candidate exist with equal prefix
308        // length and 'fail_on_ambiguity' is true, return an error.
309        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        // multiple candidates with equal prefix length
333        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        // alphabetical fallback determination
346        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        // Map a file to (namespace, literal_prefix_len) and pick the best namespace for a file
365        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                                // same namespace - nothing to do
390                                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                                    // collect ambiguous namespaces
397                                    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                                // deterministic tie-breaker: choose alphabetically smallest namespace
406                                if entry.namespace < *existing_ns {
407                                    occupied.insert((entry.namespace.clone(), current_len));
408                                }
409                            }
410                            // if current_len < existing_len -> keep existing namespace
411                        }
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;