Skip to main content

everruns_capability/
registry.rs

1//! Canonical-id/alias bookkeeping and duplicate rejection shared by
2//! capability registries and activation paths.
3
4use std::collections::{HashMap, HashSet};
5
6use crate::error::CapabilityError;
7
8/// Canonical-id and alias index for a capability registry.
9///
10/// Owns the identity bookkeeping every registry needs: which canonical ids
11/// exist, which legacy aliases resolve to them, and collision rejection when
12/// a new registration would shadow an existing id or alias. Registries keep
13/// their own `id -> implementation` storage and delegate identity questions
14/// here so the Framework and the product resolve capability identity the
15/// same way.
16#[derive(Debug, Clone, Default)]
17pub struct CapabilityIdIndex {
18    canonical: HashSet<String>,
19    /// Alias ID -> canonical ID.
20    aliases: HashMap<String, String>,
21}
22
23impl CapabilityIdIndex {
24    /// Create an empty index.
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Insert a canonical id and its aliases, rejecting collisions.
30    ///
31    /// Fails with [`CapabilityError::Duplicate`] when the canonical id (or
32    /// any alias) is already present as a canonical id or alias.
33    pub fn insert(
34        &mut self,
35        canonical: impl Into<String>,
36        aliases: &[&str],
37    ) -> Result<(), CapabilityError> {
38        let canonical = canonical.into();
39        if self.contains(&canonical) {
40            return Err(CapabilityError::Duplicate { id: canonical });
41        }
42        for alias in aliases {
43            if self.contains(alias) {
44                return Err(CapabilityError::Duplicate {
45                    id: (*alias).to_string(),
46                });
47            }
48        }
49        for alias in aliases {
50            self.aliases.insert((*alias).to_string(), canonical.clone());
51        }
52        self.canonical.insert(canonical);
53        Ok(())
54    }
55
56    /// Insert a canonical id and its aliases, replacing an existing entry
57    /// with the same canonical id (legacy registry override semantics).
58    pub fn insert_or_replace(&mut self, canonical: impl Into<String>, aliases: &[&str]) {
59        let canonical = canonical.into();
60        self.remove(&canonical);
61        for alias in aliases {
62            self.aliases.insert((*alias).to_string(), canonical.clone());
63        }
64        self.canonical.insert(canonical);
65    }
66
67    /// Resolve an id or alias to its canonical id, if registered.
68    pub fn canonical_of<'a>(&'a self, id: &'a str) -> Option<&'a str> {
69        if self.canonical.contains(id) {
70            Some(id)
71        } else {
72            self.aliases
73                .get(id)
74                .filter(|canonical| self.canonical.contains(*canonical))
75                .map(String::as_str)
76        }
77    }
78
79    /// Whether the id is present as a canonical id or alias.
80    pub fn contains(&self, id: &str) -> bool {
81        self.canonical.contains(id) || self.aliases.contains_key(id)
82    }
83
84    /// Remove an id (or alias) and everything resolving to its canonical id.
85    ///
86    /// Returns the removed canonical id, if any.
87    pub fn remove(&mut self, id: &str) -> Option<String> {
88        let canonical = self.canonical_of(id)?.to_string();
89        self.canonical.remove(&canonical);
90        self.aliases.retain(|_, target| *target != canonical);
91        Some(canonical)
92    }
93
94    /// Iterate the canonical ids in the index.
95    pub fn canonical_ids(&self) -> impl Iterator<Item = &str> {
96        self.canonical.iter().map(String::as_str)
97    }
98}
99
100/// Duplicate-activation guard used when composing an agent's capability set.
101///
102/// Callers canonicalize each incoming reference (via their registry) and then
103/// [`activate`](ActivationSet::activate) it; the second activation of the
104/// same canonical id fails with [`CapabilityError::Duplicate`]. Duplicate ids
105/// are never merged and later registrations never overwrite earlier ones.
106#[derive(Debug, Clone, Default)]
107pub struct ActivationSet {
108    seen: HashSet<String>,
109}
110
111impl ActivationSet {
112    /// Create an empty activation set.
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Record one canonical capability id, rejecting duplicates.
118    pub fn activate(&mut self, canonical_id: impl Into<String>) -> Result<(), CapabilityError> {
119        let canonical_id = canonical_id.into();
120        if !self.seen.insert(canonical_id.clone()) {
121            return Err(CapabilityError::Duplicate { id: canonical_id });
122        }
123        Ok(())
124    }
125
126    /// Whether a canonical id was already activated.
127    pub fn contains(&self, canonical_id: &str) -> bool {
128        self.seen.contains(canonical_id)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn index_rejects_duplicate_canonical_and_alias() {
138        let mut index = CapabilityIdIndex::new();
139        index.insert("bashkit_shell", &["virtual_bash"]).unwrap();
140
141        let err = index.insert("bashkit_shell", &[]).unwrap_err();
142        assert!(err.is_duplicate());
143        let err = index.insert("other", &["virtual_bash"]).unwrap_err();
144        assert!(err.is_duplicate());
145        // Registering a canonical id that shadows an alias is also rejected.
146        let err = index.insert("virtual_bash", &[]).unwrap_err();
147        assert!(err.is_duplicate());
148        let err = index
149            .insert("other", &["fresh_alias", "bashkit_shell"])
150            .unwrap_err();
151        assert_eq!(err.id(), "bashkit_shell");
152        assert!(err.is_duplicate());
153        assert!(!index.contains("other"));
154        assert!(!index.contains("fresh_alias"));
155        assert_eq!(index.canonical_of("virtual_bash"), Some("bashkit_shell"));
156    }
157
158    #[test]
159    fn index_resolves_aliases_to_canonical() {
160        let mut index = CapabilityIdIndex::new();
161        index.insert("bashkit_shell", &["virtual_bash"]).unwrap();
162        assert_eq!(index.canonical_of("bashkit_shell"), Some("bashkit_shell"));
163        assert_eq!(index.canonical_of("virtual_bash"), Some("bashkit_shell"));
164        assert_eq!(index.canonical_of("unknown"), None);
165    }
166
167    #[test]
168    fn replace_and_remove() {
169        let mut index = CapabilityIdIndex::new();
170        index.insert("cap", &["old_cap"]).unwrap();
171        index.insert("other", &["other_alias"]).unwrap();
172        index.insert_or_replace("cap", &["older_cap"]);
173        assert_eq!(index.canonical_of("older_cap"), Some("cap"));
174        assert_eq!(index.canonical_of("old_cap"), None);
175
176        assert_eq!(index.remove("older_cap"), Some("cap".to_string()));
177        assert!(!index.contains("cap"));
178        assert!(!index.contains("older_cap"));
179        assert_eq!(index.canonical_of("other_alias"), Some("other"));
180        assert_eq!(index.canonical_ids().collect::<Vec<_>>(), ["other"]);
181        assert_eq!(index.remove("missing"), None);
182    }
183
184    #[test]
185    fn activation_set_rejects_second_activation() {
186        let mut set = ActivationSet::new();
187        set.activate("current_time").unwrap();
188        let err = set.activate("current_time").unwrap_err();
189        assert_eq!(err.id(), "current_time");
190        assert!(err.is_duplicate());
191        assert!(set.contains("current_time"));
192        assert!(!set.contains("web_fetch"));
193        set.activate("web_fetch").unwrap();
194        assert!(set.contains("web_fetch"));
195    }
196}