memstead_base/
mem_management.rs1use std::path::Path;
29
30use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
31use thiserror::Error;
32
33use crate::workspace::{CreateRuleSetting, DeleteRuleSetting};
34
35#[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#[derive(Debug, Clone)]
59pub struct MatcherSet {
60 patterns: Vec<String>,
61 set: GlobSet,
62}
63
64impl MatcherSet {
65 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 pub fn matches(&self, candidate: &Path) -> bool {
96 self.set.is_match(candidate)
97 }
98
99 pub fn patterns(&self) -> &[String] {
102 &self.patterns
103 }
104
105 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#[derive(Debug, Clone)]
125pub struct CreateRuleSet {
126 rules: Vec<CreateRuleSetting>,
127 set: GlobSet,
128}
129
130#[derive(Debug, Clone)]
133pub struct DeleteRuleSet {
134 rules: Vec<DeleteRuleSetting>,
135 set: GlobSet,
136}
137
138impl CreateRuleSet {
139 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 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 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 pub fn patterns(&self) -> Vec<String> {
182 self.rules.iter().map(|r| r.pattern.clone()).collect()
183 }
184
185 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 #[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 #[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 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 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 assert!(cs.first_match(Path::new("plan-q4")).is_none());
351 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 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 #[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}