Skip to main content

saf_core/spec/
registry.rs

1//! Spec registry for loading and querying function specifications.
2//!
3//! The registry loads specs from multiple directories with a defined precedence order:
4//! 1. `./saf-specs/*.yaml` — project local (highest priority)
5//! 2. `~/.saf/specs/*.yaml` — user global
6//! 3. `<binary>/../share/saf/specs/*.yaml` — shipped defaults
7//! 4. `$SAF_SPECS_PATH/*.yaml` — explicit override (if set)
8
9use super::pattern::NamePattern;
10use super::schema::{SchemaError, SpecFile};
11use super::types::FunctionSpec;
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14use thiserror::Error;
15use tracing::{debug, warn};
16
17/// A registry of function specifications.
18///
19/// Provides lookup by function name with support for exact matches,
20/// glob patterns, and regex patterns.
21#[derive(Debug, Default)]
22pub struct SpecRegistry {
23    /// Merged specs indexed by exact name.
24    exact_specs: BTreeMap<String, FunctionSpec>,
25
26    /// Pattern-based specs (glob and regex).
27    pattern_specs: Vec<(NamePattern, FunctionSpec)>,
28
29    /// Paths that were loaded (for diagnostics).
30    loaded_paths: Vec<PathBuf>,
31
32    /// Warnings generated during loading.
33    warnings: Vec<String>,
34}
35
36impl SpecRegistry {
37    /// Create an empty registry.
38    #[must_use]
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Load specs from default discovery paths.
44    ///
45    /// Discovery order (later overrides earlier per-function):
46    /// 1. `<binary>/../share/saf/specs/*.yaml` — shipped defaults
47    /// 2. `~/.saf/specs/*.yaml` — user global
48    /// 3. `./saf-specs/*.yaml` — project local
49    /// 4. `$SAF_SPECS_PATH/*.yaml` — explicit override (if set)
50    ///
51    /// # Errors
52    /// Returns an error if any spec file fails to parse.
53    pub fn load() -> Result<Self, RegistryError> {
54        let mut registry = Self::new();
55        let paths = Self::discovery_paths();
56
57        for path in paths {
58            if path.exists() {
59                registry.load_directory(&path)?;
60            }
61        }
62
63        Ok(registry)
64    }
65
66    /// Load specs from specific paths only.
67    ///
68    /// Each path can be a file or directory. Directories are scanned for `*.yaml` files.
69    ///
70    /// # Errors
71    /// Returns an error if any path fails to load.
72    pub fn load_from(paths: &[PathBuf]) -> Result<Self, RegistryError> {
73        let mut registry = Self::new();
74
75        for path in paths {
76            if path.is_dir() {
77                registry.load_directory(path)?;
78            } else if path.is_file() {
79                registry.load_file(path)?;
80            } else {
81                return Err(RegistryError::PathNotFound(path.display().to_string()));
82            }
83        }
84
85        Ok(registry)
86    }
87
88    /// Load specs from a directory.
89    fn load_directory(&mut self, dir: &Path) -> Result<(), RegistryError> {
90        let pattern = dir.join("**/*.yaml");
91        let pattern_str = pattern.display().to_string();
92
93        let entries = glob::glob(&pattern_str).map_err(|e| RegistryError::GlobError {
94            pattern: pattern_str.clone(),
95            message: e.to_string(),
96        })?;
97
98        // Collect and sort for deterministic ordering
99        let mut files: Vec<PathBuf> = entries.filter_map(Result::ok).collect();
100        files.sort();
101
102        for file in files {
103            self.load_file(&file)?;
104        }
105
106        Ok(())
107    }
108
109    /// Load specs from a single file.
110    fn load_file(&mut self, path: &Path) -> Result<(), RegistryError> {
111        debug!("loading spec file: {}", path.display());
112
113        let spec_file = SpecFile::load(path).map_err(RegistryError::Schema)?;
114
115        for spec in spec_file.specs {
116            self.insert(spec)?;
117        }
118
119        self.loaded_paths.push(path.to_path_buf());
120        Ok(())
121    }
122
123    /// Insert a spec into the registry.
124    ///
125    /// For exact names: merges with existing spec (new overrides old).
126    /// For patterns: appends to pattern list.
127    fn insert(&mut self, spec: FunctionSpec) -> Result<(), RegistryError> {
128        let pattern = NamePattern::parse(&spec.name).map_err(|e| RegistryError::PatternError {
129            name: spec.name.clone(),
130            message: e.to_string(),
131        })?;
132
133        if pattern.is_exact() {
134            // Check for duplicate and warn
135            if self.exact_specs.contains_key(&spec.name) {
136                self.warnings.push(format!(
137                    "duplicate spec for '{}', later definition overrides",
138                    spec.name
139                ));
140            }
141
142            // Merge with existing or insert
143            self.exact_specs
144                .entry(spec.name.clone())
145                .and_modify(|existing| existing.merge(&spec))
146                .or_insert(spec);
147        } else {
148            // Pattern specs are appended (last one wins during lookup)
149            self.pattern_specs.push((pattern, spec));
150        }
151
152        Ok(())
153    }
154
155    /// Look up a function spec by name.
156    ///
157    /// Returns the first matching spec:
158    /// 1. Exact match has highest priority
159    /// 2. Pattern matches are checked in order (last loaded wins)
160    ///
161    /// Returns `None` for disabled specs.
162    #[must_use]
163    pub fn lookup(&self, name: &str) -> Option<&FunctionSpec> {
164        // Try exact match first
165        if let Some(spec) = self.exact_specs.get(name) {
166            if spec.is_disabled() {
167                return None;
168            }
169            return Some(spec);
170        }
171
172        // Try patterns (reverse order so last loaded wins).
173        // A disabled pattern does not suppress lower-priority enabled matches;
174        // we `continue` past it so the loop can find an enabled alternative.
175        for (pattern, spec) in self.pattern_specs.iter().rev() {
176            if pattern.matches(name) {
177                if spec.is_disabled() {
178                    continue;
179                }
180                return Some(spec);
181            }
182        }
183
184        None
185    }
186
187    /// Iterate over all exact-match specs.
188    pub fn iter(&self) -> impl Iterator<Item = &FunctionSpec> {
189        self.exact_specs.values()
190    }
191
192    /// Iterate over all pattern-based specs.
193    pub fn patterns(&self) -> impl Iterator<Item = &FunctionSpec> {
194        self.pattern_specs.iter().map(|(_, s)| s)
195    }
196
197    /// Get the number of exact-match specs.
198    #[must_use]
199    pub fn len(&self) -> usize {
200        self.exact_specs.len()
201    }
202
203    /// Check if the registry is empty.
204    #[must_use]
205    pub fn is_empty(&self) -> bool {
206        self.exact_specs.is_empty() && self.pattern_specs.is_empty()
207    }
208
209    /// Get paths that were loaded.
210    #[must_use]
211    pub fn loaded_paths(&self) -> &[PathBuf] {
212        &self.loaded_paths
213    }
214
215    /// Get warnings generated during loading.
216    #[must_use]
217    pub fn warnings(&self) -> &[String] {
218        &self.warnings
219    }
220
221    /// Get default discovery paths.
222    fn discovery_paths() -> Vec<PathBuf> {
223        let mut paths = Vec::new();
224
225        // 1. Shipped defaults (binary/../share/saf/specs)
226        if let Ok(exe) = std::env::current_exe() {
227            if let Some(parent) = exe.parent().and_then(|p| p.parent()) {
228                let share_path = parent.join("share/saf/specs");
229                paths.push(share_path);
230            }
231        }
232
233        // 2. User global (~/.saf/specs)
234        if let Some(home) = std::env::var_os("HOME") {
235            let home_path = PathBuf::from(home).join(".saf/specs");
236            paths.push(home_path);
237        }
238
239        // 3. Project local (./saf-specs)
240        paths.push(PathBuf::from("./saf-specs"));
241
242        // 4. Workspace share directory (./share/saf/specs)
243        // This handles development builds where the exe is in target/release/
244        // but specs are in the workspace root's share/ directory.
245        paths.push(PathBuf::from("./share/saf/specs"));
246
247        // 5. Explicit override ($SAF_SPECS_PATH)
248        if let Ok(specs_path) = std::env::var("SAF_SPECS_PATH") {
249            for path in specs_path.split(':') {
250                if !path.is_empty() {
251                    paths.push(PathBuf::from(path));
252                }
253            }
254        }
255
256        paths
257    }
258
259    /// Add a spec directly (for programmatic construction).
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if the spec has a duplicate function name.
264    pub fn add(&mut self, spec: FunctionSpec) -> Result<(), RegistryError> {
265        self.insert(spec)
266    }
267
268    /// Create a registry from a YAML string (for testing and programmatic use).
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if the YAML is invalid.
273    pub fn from_yaml(yaml: &str) -> Result<Self, RegistryError> {
274        let file = SpecFile::parse(yaml)?;
275        let mut registry = Self::new();
276        for spec in file.specs {
277            registry.insert(spec)?;
278        }
279        Ok(registry)
280    }
281
282    /// Build a `SpecRegistry` from multiple raw YAML strings.
283    ///
284    /// Used by the WASM frontend where filesystem access is unavailable.
285    /// Each string should be a complete spec file (with `version` and `specs` keys).
286    ///
287    /// # Errors
288    ///
289    /// Returns an error if any YAML string fails to parse.
290    pub fn from_yaml_strs(yamls: &[String]) -> Result<Self, RegistryError> {
291        let mut registry = Self::new();
292        for yaml_str in yamls {
293            let file = SpecFile::parse(yaml_str)?;
294            for spec in file.specs {
295                registry.insert(spec)?;
296            }
297        }
298        Ok(registry)
299    }
300
301    /// Report missing spec warning.
302    pub fn warn_missing(&self, name: &str) {
303        warn!("no spec for '{}', using conservative assumptions", name);
304    }
305}
306
307/// Errors that can occur when loading the registry.
308#[derive(Debug, Error)]
309pub enum RegistryError {
310    /// Schema/parsing error.
311    #[error("{0}")]
312    Schema(#[from] SchemaError),
313
314    /// Path not found.
315    #[error("path not found: {0}")]
316    PathNotFound(String),
317
318    /// Glob pattern error.
319    #[error("glob error for '{pattern}': {message}")]
320    GlobError {
321        /// Pattern that failed.
322        pattern: String,
323        /// Error message.
324        message: String,
325    },
326
327    /// Pattern parsing error.
328    #[error("invalid pattern in '{name}': {message}")]
329    PatternError {
330        /// Spec name with invalid pattern.
331        name: String,
332        /// Parse error message.
333        message: String,
334    },
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::spec::types::{Nullness, Pointer, ReturnSpec, Role};
341    use std::io::Write;
342    use tempfile::TempDir;
343
344    fn create_spec_file(dir: &Path, name: &str, content: &str) {
345        let path = dir.join(name);
346        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
347        let mut file = std::fs::File::create(path).unwrap();
348        file.write_all(content.as_bytes()).unwrap();
349    }
350
351    #[test]
352    fn test_empty_registry() {
353        let registry = SpecRegistry::new();
354        assert!(registry.is_empty());
355        assert_eq!(registry.len(), 0);
356        assert!(registry.lookup("malloc").is_none());
357    }
358
359    #[test]
360    fn test_load_single_file() {
361        let dir = TempDir::new().unwrap();
362        create_spec_file(
363            dir.path(),
364            "alloc.yaml",
365            r#"
366version: "1.0"
367specs:
368  - name: malloc
369    role: allocator
370    returns:
371      pointer: fresh_heap
372"#,
373        );
374
375        let registry = SpecRegistry::load_from(&[dir.path().to_path_buf()]).unwrap();
376        assert_eq!(registry.len(), 1);
377
378        let spec = registry.lookup("malloc").unwrap();
379        assert_eq!(spec.role, Some(Role::Allocator));
380        assert_eq!(
381            spec.returns.as_ref().unwrap().pointer,
382            Some(Pointer::FreshHeap)
383        );
384    }
385
386    #[test]
387    fn test_load_multiple_files_merge() {
388        let dir = TempDir::new().unwrap();
389
390        // First file: base spec
391        create_spec_file(
392            dir.path(),
393            "a_base.yaml",
394            r#"
395version: "1.0"
396specs:
397  - name: malloc
398    role: allocator
399"#,
400        );
401
402        // Second file: adds returns (loaded after a_base due to sort)
403        create_spec_file(
404            dir.path(),
405            "b_extra.yaml",
406            r#"
407version: "1.0"
408specs:
409  - name: malloc
410    returns:
411      nullness: maybe_null
412"#,
413        );
414
415        let registry = SpecRegistry::load_from(&[dir.path().to_path_buf()]).unwrap();
416
417        let spec = registry.lookup("malloc").unwrap();
418        // Role from first file
419        assert_eq!(spec.role, Some(Role::Allocator));
420        // Returns merged from second file
421        assert_eq!(
422            spec.returns.as_ref().unwrap().nullness,
423            Some(Nullness::MaybeNull)
424        );
425    }
426
427    #[test]
428    fn test_pattern_lookup() {
429        let dir = TempDir::new().unwrap();
430        create_spec_file(
431            dir.path(),
432            "string.yaml",
433            r#"
434version: "1.0"
435specs:
436  - name: "glob:str*"
437    role: string_operation
438"#,
439        );
440
441        let registry = SpecRegistry::load_from(&[dir.path().to_path_buf()]).unwrap();
442
443        // Pattern should match various str* functions
444        let strlen = registry.lookup("strlen").unwrap();
445        assert_eq!(strlen.role, Some(Role::StringOperation));
446
447        let strcpy = registry.lookup("strcpy").unwrap();
448        assert_eq!(strcpy.role, Some(Role::StringOperation));
449
450        // Non-matching should return None
451        assert!(registry.lookup("malloc").is_none());
452    }
453
454    #[test]
455    fn test_exact_overrides_pattern() {
456        let dir = TempDir::new().unwrap();
457        create_spec_file(
458            dir.path(),
459            "specs.yaml",
460            r#"
461version: "1.0"
462specs:
463  - name: "glob:str*"
464    role: string_operation
465  - name: strlen
466    pure: true
467"#,
468        );
469
470        let registry = SpecRegistry::load_from(&[dir.path().to_path_buf()]).unwrap();
471
472        // Exact match should override pattern
473        let strlen = registry.lookup("strlen").unwrap();
474        assert!(strlen.is_pure());
475        // Role comes from exact match (which doesn't have it)
476        assert!(strlen.role.is_none());
477
478        // Pattern still works for others
479        let strcpy = registry.lookup("strcpy").unwrap();
480        assert_eq!(strcpy.role, Some(Role::StringOperation));
481    }
482
483    #[test]
484    fn test_disabled_spec() {
485        let dir = TempDir::new().unwrap();
486        create_spec_file(
487            dir.path(),
488            "specs.yaml",
489            r#"
490version: "1.0"
491specs:
492  - name: malloc
493    role: allocator
494    disabled: true
495"#,
496        );
497
498        let registry = SpecRegistry::load_from(&[dir.path().to_path_buf()]).unwrap();
499        // Disabled spec should not be returned
500        assert!(registry.lookup("malloc").is_none());
501    }
502
503    #[test]
504    fn test_nested_directories() {
505        let dir = TempDir::new().unwrap();
506
507        create_spec_file(
508            dir.path(),
509            "libc/alloc.yaml",
510            r#"
511version: "1.0"
512specs:
513  - name: malloc
514    role: allocator
515"#,
516        );
517
518        create_spec_file(
519            dir.path(),
520            "libc/string.yaml",
521            r#"
522version: "1.0"
523specs:
524  - name: strlen
525    pure: true
526"#,
527        );
528
529        let registry = SpecRegistry::load_from(&[dir.path().to_path_buf()]).unwrap();
530        assert!(registry.lookup("malloc").is_some());
531        assert!(registry.lookup("strlen").is_some());
532    }
533
534    #[test]
535    fn test_add_programmatic() {
536        let mut registry = SpecRegistry::new();
537
538        let mut spec = FunctionSpec::new("my_alloc");
539        spec.role = Some(Role::Allocator);
540        spec.returns = Some(ReturnSpec {
541            pointer: Some(Pointer::FreshHeap),
542            ..ReturnSpec::default()
543        });
544
545        registry.add(spec).unwrap();
546
547        let found = registry.lookup("my_alloc").unwrap();
548        assert_eq!(found.role, Some(Role::Allocator));
549    }
550
551    #[test]
552    fn test_invalid_file() {
553        let dir = TempDir::new().unwrap();
554        create_spec_file(dir.path(), "bad.yaml", "not: valid: yaml:");
555
556        let result = SpecRegistry::load_from(&[dir.path().to_path_buf()]);
557        assert!(result.is_err());
558    }
559
560    #[test]
561    fn test_path_not_found() {
562        let result = SpecRegistry::load_from(&[PathBuf::from("/nonexistent/path")]);
563        assert!(matches!(
564            result.unwrap_err(),
565            RegistryError::PathNotFound(_)
566        ));
567    }
568}