Skip to main content

memstead_base/
mem_management.rs

1//! Compiled lifecycle-policy rule sets — first-match-wins glob lookup
2//! over the `[[mem_management.create]]` /
3//! `[[mem_management.delete]]` arrays surfaced in `workspace.toml`.
4//!
5//! The data carriers ([`crate::workspace::CreateRuleSetting`],
6//! [`crate::workspace::DeleteRuleSetting`]) live with the workspace
7//! types in [`crate::workspace`]; this module owns the *compiled*
8//! matcher view that handlers call to decide whether a candidate
9//! mem path matches an operator-allowed rule.
10//!
11//! Each rule's pattern is a gitignore-style glob: `*` does not cross
12//! `/`; `**` matches zero-or-more path segments. Matching is
13//! case-sensitive, no normalization. The matcher targets the composed
14//! candidate `<path>/<name>` (the mem's full hierarchical branch
15//! path on the mem-repo) so rules can scope to a directory under
16//! the registry tree without separate path-glob plumbing. Flat-layout
17//! mems pass their leaf name as the candidate.
18//!
19//! **Boundary note.** The lifecycle orchestrators (`create_mem`,
20//! `delete_mem`, their params/responses, the shared `NOTE_MAX_LEN`
21//! cap, the `validate_mem_path` helper) live in
22//! [`memstead_engine::mem_management`]. The matcher primitives stay
23//! here because the lean engine's
24//! [`crate::Engine::cross_mem_link_allowed`] synthesises a
25//! [`CreateRuleSet`] on multi-folder workspaces — they are a lean
26//! policy primitive shared by both flavors.
27
28use std::path::Path;
29
30use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
31use thiserror::Error;
32
33use crate::workspace::{CreateRuleSetting, DeleteRuleSetting};
34
35/// Construction-time error for the matcher constructors. The
36/// `ParseEntry` variant names the offending entry string so callers
37/// can surface an actionable `INVALID_INPUT` envelope without
38/// re-deriving which entry blew up.
39#[derive(Debug, Error)]
40pub enum MatcherSetError {
41    #[error("invalid glob pattern {entry:?}: {source}")]
42    ParseEntry {
43        entry: String,
44        #[source]
45        source: globset::Error,
46    },
47    #[error("glob set build failed: {0}")]
48    Build(#[source] globset::Error),
49}
50
51/// Compiled set of allowlist globs with gitignore semantics.
52///
53/// The `patterns` vector preserves the original strings (used by
54/// `memstead_health` and error envelopes). The `set` is the compiled form
55/// used for `matches`. Empty-input construction is valid and produces
56/// a matcher that rejects every candidate — the natural default when
57/// `[mem_management]` is absent.
58#[derive(Debug, Clone)]
59pub struct MatcherSet {
60    patterns: Vec<String>,
61    set: GlobSet,
62}
63
64impl MatcherSet {
65    /// Compile a list of glob strings. Empty input is valid. A
66    /// malformed entry returns `ParseEntry` naming the entry; `Build`
67    /// covers residual post-assembly failures.
68    pub fn new<I, S>(entries: I) -> Result<Self, MatcherSetError>
69    where
70        I: IntoIterator<Item = S>,
71        S: AsRef<str>,
72    {
73        let mut builder = GlobSetBuilder::new();
74        let mut patterns: Vec<String> = Vec::new();
75        for entry in entries {
76            let entry_str = entry.as_ref();
77            let glob: Glob = GlobBuilder::new(entry_str)
78                .literal_separator(true)
79                .build()
80                .map_err(|source| MatcherSetError::ParseEntry {
81                    entry: entry_str.to_string(),
82                    source,
83                })?;
84            builder.add(glob);
85            patterns.push(entry_str.to_string());
86        }
87        let set = builder.build().map_err(MatcherSetError::Build)?;
88        Ok(Self { patterns, set })
89    }
90
91    /// Test whether `candidate` matches any compiled glob. The
92    /// caller passes a path-like string; the matcher compares as-is —
93    /// no normalization, no canonicalization, no cross-boundary
94    /// widening.
95    pub fn matches(&self, candidate: &Path) -> bool {
96        self.set.is_match(candidate)
97    }
98
99    /// Original glob strings in input order. Consumed by health
100    /// surfaces and `MEM_PATH_NOT_ALLOWED` envelopes.
101    pub fn patterns(&self) -> &[String] {
102        &self.patterns
103    }
104
105    /// `true` when no globs were compiled. Lifecycle handlers
106    /// short-circuit with a `reason: "no_allowlist_configured"` detail
107    /// instead of the generic "no match" message.
108    pub fn is_empty(&self) -> bool {
109        self.patterns.is_empty()
110    }
111}
112
113impl Default for MatcherSet {
114    fn default() -> Self {
115        Self::new::<_, &str>(std::iter::empty::<&str>()).expect("empty MatcherSet is always valid")
116    }
117}
118
119/// Compiled `[[mem_management.create]]` rule set. Each rule is a
120/// pre-built `globset::Glob` plus the raw [`CreateRuleSetting`]. The
121/// underlying `GlobSet` carries the same globs in declaration order
122/// so [`Self::first_match`] resolves to the first-listed rule whose
123/// glob matches.
124#[derive(Debug, Clone)]
125pub struct CreateRuleSet {
126    rules: Vec<CreateRuleSetting>,
127    set: GlobSet,
128}
129
130/// Compiled `[[mem_management.delete]]` rule set. Same first-match
131/// semantics as [`CreateRuleSet`], minus the schema dimension.
132#[derive(Debug, Clone)]
133pub struct DeleteRuleSet {
134    rules: Vec<DeleteRuleSetting>,
135    set: GlobSet,
136}
137
138impl CreateRuleSet {
139    /// Compile each rule's glob with `literal_separator(true)`. A
140    /// malformed pattern surfaces as
141    /// [`MatcherSetError::ParseEntry`] naming the offending entry so
142    /// `Engine::init` can produce an `INVALID_INPUT` envelope without
143    /// half-constructing.
144    pub fn new(rules: Vec<CreateRuleSetting>) -> Result<Self, MatcherSetError> {
145        let mut builder = GlobSetBuilder::new();
146        for r in &rules {
147            let glob = GlobBuilder::new(&r.pattern)
148                .literal_separator(true)
149                .build()
150                .map_err(|source| MatcherSetError::ParseEntry {
151                    entry: r.pattern.clone(),
152                    source,
153                })?;
154            builder.add(glob);
155        }
156        let set = builder.build().map_err(MatcherSetError::Build)?;
157        Ok(Self { rules, set })
158    }
159
160    /// First rule whose glob matches `candidate`, in declaration
161    /// order. `None` when no rule matches; callers check
162    /// [`Self::is_empty`] separately to distinguish "no rules
163    /// configured" from "rules configured but none match".
164    pub fn first_match(&self, candidate: &Path) -> Option<&CreateRuleSetting> {
165        let matched = self.set.matches(candidate);
166        matched.into_iter().next().map(|i| &self.rules[i])
167    }
168
169    /// Raw rule list in declaration order. Consumed by `memstead_overview`
170    /// when surfacing the lifecycle-namespaces section.
171    pub fn rules(&self) -> &[CreateRuleSetting] {
172        &self.rules
173    }
174
175    pub fn is_empty(&self) -> bool {
176        self.rules.is_empty()
177    }
178
179    /// Convenience: pattern strings in declaration order. Useful for
180    /// tests and diagnostic surfaces.
181    pub fn patterns(&self) -> Vec<String> {
182        self.rules.iter().map(|r| r.pattern.clone()).collect()
183    }
184
185    /// Convenience: any-rule match against `candidate`.
186    pub fn matches(&self, candidate: &Path) -> bool {
187        self.first_match(candidate).is_some()
188    }
189}
190
191impl DeleteRuleSet {
192    pub fn new(rules: Vec<DeleteRuleSetting>) -> Result<Self, MatcherSetError> {
193        let mut builder = GlobSetBuilder::new();
194        for r in &rules {
195            let glob = GlobBuilder::new(&r.pattern)
196                .literal_separator(true)
197                .build()
198                .map_err(|source| MatcherSetError::ParseEntry {
199                    entry: r.pattern.clone(),
200                    source,
201                })?;
202            builder.add(glob);
203        }
204        let set = builder.build().map_err(MatcherSetError::Build)?;
205        Ok(Self { rules, set })
206    }
207
208    pub fn first_match(&self, candidate: &Path) -> Option<&DeleteRuleSetting> {
209        let matched = self.set.matches(candidate);
210        matched.into_iter().next().map(|i| &self.rules[i])
211    }
212
213    pub fn rules(&self) -> &[DeleteRuleSetting] {
214        &self.rules
215    }
216
217    pub fn is_empty(&self) -> bool {
218        self.rules.is_empty()
219    }
220
221    pub fn patterns(&self) -> Vec<String> {
222        self.rules.iter().map(|r| r.pattern.clone()).collect()
223    }
224
225    pub fn matches(&self, candidate: &Path) -> bool {
226        self.first_match(candidate).is_some()
227    }
228}
229
230impl Default for CreateRuleSet {
231    fn default() -> Self {
232        Self::new(Vec::new()).expect("empty rule set always compiles")
233    }
234}
235
236impl Default for DeleteRuleSet {
237    fn default() -> Self {
238        Self::new(Vec::new()).expect("empty rule set always compiles")
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use std::path::Path;
246
247    fn cr(pattern: &str, schemas: &[&str]) -> CreateRuleSetting {
248        CreateRuleSetting {
249            pattern: pattern.to_string(),
250            schemas: schemas.iter().map(|s| s.to_string()).collect(),
251            default_cross_links: None,
252        }
253    }
254
255    fn dr(pattern: &str) -> DeleteRuleSetting {
256        DeleteRuleSetting {
257            pattern: pattern.to_string(),
258        }
259    }
260
261    // ---- MatcherSet primitives -------------------------------------
262
263    #[test]
264    fn empty_matcher_rejects_everything() {
265        let m = MatcherSet::new::<_, &str>(std::iter::empty::<&str>()).unwrap();
266        assert!(m.is_empty());
267        assert_eq!(m.patterns().len(), 0);
268        assert!(!m.matches(Path::new("anything")));
269    }
270
271    #[test]
272    fn single_segment_star_does_not_cross_slash() {
273        let m = MatcherSet::new(["memstead/*"]).unwrap();
274        assert!(m.matches(Path::new("memstead/engine")));
275        assert!(!m.matches(Path::new("memstead/engine/nested")));
276        assert!(!m.matches(Path::new("other/engine")));
277    }
278
279    #[test]
280    fn double_star_matches_any_segments() {
281        let m = MatcherSet::new(["memstead/**"]).unwrap();
282        assert!(m.matches(Path::new("memstead/engine")));
283        assert!(m.matches(Path::new("memstead/engine/nested")));
284        assert!(!m.matches(Path::new("other/engine")));
285    }
286
287    #[test]
288    fn malformed_entry_returns_parse_entry_error() {
289        let err = MatcherSet::new(["[unclosed"]).unwrap_err();
290        match err {
291            MatcherSetError::ParseEntry { entry, .. } => assert_eq!(entry, "[unclosed"),
292            MatcherSetError::Build(_) => panic!("expected ParseEntry, got Build"),
293        }
294    }
295
296    #[test]
297    fn case_sensitive_by_default() {
298        let m = MatcherSet::new(["MEMSTEAD/*"]).unwrap();
299        assert!(m.matches(Path::new("MEMSTEAD/foo")));
300        assert!(!m.matches(Path::new("memstead/foo")));
301    }
302
303    // ---- CreateRuleSet ---------------------------------------------
304
305    #[test]
306    fn empty_rule_set_matches_nothing() {
307        let cs = CreateRuleSet::new(vec![]).unwrap();
308        assert!(cs.is_empty());
309        assert!(cs.first_match(Path::new("anything")).is_none());
310    }
311
312    #[test]
313    fn first_match_resolves_in_declaration_order() {
314        // `planning/plan-*` is more specific than `planning/**` and
315        // appears first; both globs match `planning/plan-foo` but the
316        // resolver returns the first-listed.
317        let cs = CreateRuleSet::new(vec![
318            cr("planning/plan-*", &["default@1.0.0"]),
319            cr("planning/**", &["*"]),
320        ])
321        .unwrap();
322        let m = cs.first_match(Path::new("planning/plan-foo")).unwrap();
323        assert_eq!(m.pattern, "planning/plan-*");
324    }
325
326    #[test]
327    fn second_rule_picked_when_first_does_not_match() {
328        let cs = CreateRuleSet::new(vec![
329            cr("planning/plan-*", &["default@1.0.0"]),
330            cr("exec-*", &["default@1.0.0"]),
331        ])
332        .unwrap();
333        let m = cs.first_match(Path::new("exec-foo")).unwrap();
334        assert_eq!(m.pattern, "exec-*");
335    }
336
337    #[test]
338    fn flat_candidate_matches_flat_pattern() {
339        let cs = CreateRuleSet::new(vec![cr("exec-*", &["default@1.0.0"])]).unwrap();
340        assert!(cs.first_match(Path::new("exec-foo")).is_some());
341        // `exec-*` does not match `nested/exec-foo` (literal_separator).
342        assert!(cs.first_match(Path::new("nested/exec-foo")).is_none());
343    }
344
345    #[test]
346    fn hierarchical_candidate_requires_path_prefix() {
347        let cs = CreateRuleSet::new(vec![cr("planning/plan-*", &["default@1.0.0"])]).unwrap();
348        assert!(cs.first_match(Path::new("planning/plan-q4")).is_some());
349        // Same leaf without the path prefix does not match.
350        assert!(cs.first_match(Path::new("plan-q4")).is_none());
351        // Same leaf under a different path does not match.
352        assert!(cs.first_match(Path::new("other/plan-q4")).is_none());
353    }
354
355    #[test]
356    fn create_rule_set_malformed_pattern_returns_parse_entry_error() {
357        let err = CreateRuleSet::new(vec![cr("[unclosed", &["*"])]).unwrap_err();
358        match err {
359            MatcherSetError::ParseEntry { entry, .. } => {
360                assert_eq!(entry, "[unclosed")
361            }
362            _ => panic!("expected ParseEntry, got {err:?}"),
363        }
364    }
365
366    #[test]
367    fn create_rule_set_carries_default_cross_links_in_matched_rule() {
368        // Regression: lifting must keep default_cross_links accessible
369        // through first_match.
370        use memstead_schema::workspace_config::CrossLinkValue;
371        let rule = CreateRuleSetting {
372            pattern: "exec-*".to_string(),
373            schemas: vec!["default".to_string()],
374            default_cross_links: Some(CrossLinkValue::Wildcard),
375        };
376        let cs = CreateRuleSet::new(vec![rule]).unwrap();
377        let m = cs.first_match(Path::new("exec-foo")).unwrap();
378        assert_eq!(m.default_cross_links, Some(CrossLinkValue::Wildcard));
379    }
380
381    // ---- DeleteRuleSet ---------------------------------------------
382
383    #[test]
384    fn delete_rule_set_resolves_by_pattern_only() {
385        let ds = DeleteRuleSet::new(vec![dr("planning/plan-*"), dr("exec-*")]).unwrap();
386        assert!(ds.first_match(Path::new("planning/plan-foo")).is_some());
387        assert!(ds.first_match(Path::new("exec-bar")).is_some());
388        assert!(ds.first_match(Path::new("engine")).is_none());
389    }
390
391    #[test]
392    fn delete_rule_set_default_is_empty() {
393        let ds = DeleteRuleSet::default();
394        assert!(ds.is_empty());
395        assert!(!ds.matches(Path::new("anything")));
396    }
397
398    #[test]
399    fn create_rule_set_default_is_empty() {
400        let cs = CreateRuleSet::default();
401        assert!(cs.is_empty());
402        assert!(!cs.matches(Path::new("anything")));
403    }
404}