memstead_base/filesystem/
tier3.rs1use std::path::{Path, PathBuf};
36
37use regex::Regex;
38use std::sync::OnceLock;
39
40use crate::entity::EntityId;
41use crate::entity::loader::LoadError;
42use crate::entity::source::EntitySource;
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Tier3Ref {
47 pub scope: String,
48 pub name: String,
49 pub slug: String,
50}
51
52impl Tier3Ref {
53 pub fn cache_path(&self, workspace_root: &Path) -> PathBuf {
56 self.cache_dir(workspace_root).join(format!(
57 "{}.{}",
58 self.name,
59 memstead_schema::ARCHIVE_EXTENSION
60 ))
61 }
62
63 fn cache_dir(&self, workspace_root: &Path) -> PathBuf {
64 workspace_root
65 .join(crate::workspace_store::WORKSPACE_STORE_DIR)
66 .join("memstead-io")
67 .join(&self.scope)
68 }
69
70 pub fn resolve(&self, workspace_root: &Path) -> Result<EntityId, Tier3ResolveError> {
79 let cache_path = self.cache_path(workspace_root);
81 if !cache_path.is_file() {
82 return Err(Tier3ResolveError::CacheMissing {
83 cache_path,
84 tier3: self.as_display(),
85 });
86 }
87
88 let source = EntitySource::ZipArchive(cache_path.clone());
89 let (entries, _) = source
90 .read_all()
91 .map_err(|e| Tier3ResolveError::ArchiveRead {
92 cache_path: cache_path.clone(),
93 tier3: self.as_display(),
94 error: e.to_string(),
95 })?;
96
97 let want = format!("{}.md", self.slug);
103 let found = entries.iter().any(|e| e.relative_path == want);
104 if !found {
105 return Err(Tier3ResolveError::SlugAbsent {
106 cache_path,
107 tier3: self.as_display(),
108 });
109 }
110
111 Ok(EntityId::new(&self.name, &self.slug))
112 }
113
114 pub fn as_display(&self) -> String {
117 format!("{}/{}:{}", self.scope, self.name, self.slug)
118 }
119}
120
121impl std::fmt::Display for Tier3Ref {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.write_str(&self.as_display())
124 }
125}
126
127#[derive(Debug, thiserror::Error)]
129pub enum Tier3ResolveError {
130 #[error(
133 "tier 3 link {tier3} cannot resolve: cached archive missing at {} \
134 — run `memstead link {{scope}}/{{name}}` to populate it",
135 cache_path.display()
136 )]
137 CacheMissing { cache_path: PathBuf, tier3: String },
138 #[error(
143 "tier 3 link {tier3} cannot resolve: slug not found in cached archive at {}",
144 cache_path.display()
145 )]
146 SlugAbsent { cache_path: PathBuf, tier3: String },
147 #[error(
151 "tier 3 link {tier3} cannot resolve: archive at {} unreadable: {error}",
152 cache_path.display()
153 )]
154 #[allow(dead_code)]
155 ArchiveRead {
156 cache_path: PathBuf,
157 tier3: String,
158 error: String,
159 },
160}
161
162impl Tier3ResolveError {
163 pub fn tier3(&self) -> &str {
167 match self {
168 Tier3ResolveError::CacheMissing { tier3, .. } => tier3,
169 Tier3ResolveError::SlugAbsent { tier3, .. } => tier3,
170 Tier3ResolveError::ArchiveRead { tier3, .. } => tier3,
171 }
172 }
173}
174
175pub type Tier3LoadError = LoadError;
180
181fn tier3_re() -> &'static Regex {
185 static RE: OnceLock<Regex> = OnceLock::new();
186 RE.get_or_init(|| {
187 Regex::new(
192 r"\[\[([a-z0-9][a-z0-9-]{0,62}[a-z0-9])/([a-z0-9][a-z0-9-]{0,62}[a-z0-9]):([A-Za-z0-9][A-Za-z0-9_./\-]*)\]\]",
193 )
194 .expect("tier-3 regex must compile")
195 })
196}
197
198pub fn extract_tier3_refs(text: &str) -> Vec<Tier3Ref> {
203 let re = tier3_re();
204 re.captures_iter(text)
205 .map(|cap| Tier3Ref {
206 scope: cap[1].to_string(),
207 name: cap[2].to_string(),
208 slug: cap[3].to_string(),
209 })
210 .collect()
211}
212
213#[derive(Debug, Clone)]
216pub struct Tier3Warning {
217 pub entity_id: EntityId,
219 pub tier3: String,
221 pub reason: String,
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use std::io::Write;
230 use tempfile::TempDir;
231 use zip::CompressionMethod;
232 use zip::write::SimpleFileOptions;
233
234 fn write_archive(path: &Path, entries: &[(&str, &str)]) {
235 let file = std::fs::File::create(path).unwrap();
236 let mut zip = zip::ZipWriter::new(file);
237 let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
238 for (name, content) in entries {
239 zip.start_file(*name, opts).unwrap();
240 zip.write_all(content.as_bytes()).unwrap();
241 }
242 zip.finish().unwrap();
243 }
244
245 fn cache_archive(workspace_root: &Path, scope: &str, name: &str, entries: &[(&str, &str)]) {
246 let dir = workspace_root
247 .join(".memstead")
248 .join("memstead-io")
249 .join(scope);
250 std::fs::create_dir_all(&dir).unwrap();
251 write_archive(&dir.join(format!("{name}.mem")), entries);
252 }
253
254 #[test]
255 fn extract_tier3_refs_finds_simple_references() {
256 let body = "See [[anthropic/core:agents]] and [[scope/name:foo-bar]].";
257 let refs = extract_tier3_refs(body);
258 assert_eq!(refs.len(), 2);
259 assert_eq!(refs[0].as_display(), "anthropic/core:agents");
260 assert_eq!(refs[1].as_display(), "scope/name:foo-bar");
261 }
262
263 #[test]
264 fn extract_tier3_refs_ignores_tier1_and_tier2() {
265 let body = "Tier 1: [[plain]]. Tier 2: [[leaf:slug]]. Mixed.";
268 let refs = extract_tier3_refs(body);
269 assert!(refs.is_empty());
270 }
271
272 #[test]
273 fn extract_tier3_refs_rejects_uppercase_in_scope_or_name() {
274 let body = "[[Anthropic/core:agents]] and [[anthropic/Core:agents]]";
275 let refs = extract_tier3_refs(body);
276 assert!(refs.is_empty());
277 }
278
279 #[test]
280 fn resolve_succeeds_against_present_cache() {
281 let tmp = TempDir::new().unwrap();
282 cache_archive(
283 tmp.path(),
284 "anthropic",
285 "core",
286 &[
287 (
288 "agents.md",
289 "---\ntype: spec\n---\n# Agents\n\n## Identity\n\nA.\n",
290 ),
291 (
292 "tools.md",
293 "---\ntype: spec\n---\n# Tools\n\n## Identity\n\nT.\n",
294 ),
295 ],
296 );
297
298 let r = Tier3Ref {
299 scope: "anthropic".into(),
300 name: "core".into(),
301 slug: "agents".into(),
302 };
303 let id = r.resolve(tmp.path()).unwrap();
304 assert_eq!(id.as_ref(), "core--agents");
305 }
306
307 #[test]
308 fn resolve_fails_when_cache_missing() {
309 let tmp = TempDir::new().unwrap();
310 let r = Tier3Ref {
311 scope: "anthropic".into(),
312 name: "core".into(),
313 slug: "agents".into(),
314 };
315 let err = r.resolve(tmp.path()).expect_err("missing cache must error");
316 match err {
317 Tier3ResolveError::CacheMissing { .. } => {}
318 other => panic!("expected CacheMissing, got {other:?}"),
319 }
320 assert_eq!(err.tier3(), "anthropic/core:agents");
321 }
322
323 #[test]
324 fn resolve_fails_when_slug_absent_from_cache() {
325 let tmp = TempDir::new().unwrap();
326 cache_archive(
327 tmp.path(),
328 "anthropic",
329 "core",
330 &[(
331 "tools.md",
332 "---\ntype: spec\n---\n# Tools\n\n## Identity\n\nT.\n",
333 )],
334 );
335
336 let r = Tier3Ref {
337 scope: "anthropic".into(),
338 name: "core".into(),
339 slug: "agents".into(),
340 };
341 let err = r.resolve(tmp.path()).expect_err("absent slug must error");
342 match err {
343 Tier3ResolveError::SlugAbsent { .. } => {}
344 other => panic!("expected SlugAbsent, got {other:?}"),
345 }
346 }
347
348 #[test]
349 fn cache_path_lands_under_memstead_memstead_io() {
350 let r = Tier3Ref {
351 scope: "anthropic".into(),
352 name: "core".into(),
353 slug: "agents".into(),
354 };
355 let path = r.cache_path(Path::new("/ws"));
356 assert_eq!(
357 path,
358 PathBuf::from("/ws/.memstead/memstead-io/anthropic/core.mem")
359 );
360 }
361}