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 masked = crate::markdown::mask_code_blocks_and_spans(text);
209 let re = tier3_re();
210 re.captures_iter(&masked)
211 .map(|cap| Tier3Ref {
212 scope: text[cap.get(1).unwrap().range()].to_string(),
213 name: text[cap.get(2).unwrap().range()].to_string(),
214 slug: text[cap.get(3).unwrap().range()].to_string(),
215 })
216 .collect()
217}
218
219#[derive(Debug, Clone)]
222pub struct Tier3Warning {
223 pub entity_id: EntityId,
225 pub tier3: String,
227 pub reason: String,
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use std::io::Write;
236 use tempfile::TempDir;
237 use zip::CompressionMethod;
238 use zip::write::SimpleFileOptions;
239
240 fn write_archive(path: &Path, entries: &[(&str, &str)]) {
241 let file = std::fs::File::create(path).unwrap();
242 let mut zip = zip::ZipWriter::new(file);
243 let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
244 for (name, content) in entries {
245 zip.start_file(*name, opts).unwrap();
246 zip.write_all(content.as_bytes()).unwrap();
247 }
248 zip.finish().unwrap();
249 }
250
251 fn cache_archive(workspace_root: &Path, scope: &str, name: &str, entries: &[(&str, &str)]) {
252 let dir = workspace_root
253 .join(".memstead")
254 .join("memstead-io")
255 .join(scope);
256 std::fs::create_dir_all(&dir).unwrap();
257 write_archive(&dir.join(format!("{name}.mem")), entries);
258 }
259
260 #[test]
261 fn extract_tier3_refs_finds_simple_references() {
262 let body = "See [[anthropic/core:agents]] and [[scope/name:foo-bar]].";
263 let refs = extract_tier3_refs(body);
264 assert_eq!(refs.len(), 2);
265 assert_eq!(refs[0].as_display(), "anthropic/core:agents");
266 assert_eq!(refs[1].as_display(), "scope/name:foo-bar");
267 }
268
269 #[test]
270 fn extract_tier3_refs_ignores_code() {
271 for body in [
272 "```\n[[scope/name:slug]]\n```",
273 "~~~\n[[scope/name:slug]]\n~~~",
274 " [[scope/name:slug]]",
275 "An inline `[[scope/name:slug]]` sample.",
276 ] {
277 assert!(
278 extract_tier3_refs(body).is_empty(),
279 "code content is not a reference: {body:?}"
280 );
281 }
282 }
283
284 #[test]
287 fn extract_tier3_refs_still_finds_prose_beside_code() {
288 let refs =
289 extract_tier3_refs("See [[scope/name:slug]].\n\n```\n[[other/thing:ghost]]\n```\n");
290 assert_eq!(refs.len(), 1, "{refs:?}");
291 assert_eq!(refs[0].scope, "scope");
292 assert_eq!(refs[0].name, "name");
293 assert_eq!(refs[0].slug, "slug");
294 }
295
296 #[test]
297 fn extract_tier3_refs_ignores_tier1_and_tier2() {
298 let body = "Tier 1: [[plain]]. Tier 2: [[leaf:slug]]. Mixed.";
301 let refs = extract_tier3_refs(body);
302 assert!(refs.is_empty());
303 }
304
305 #[test]
306 fn extract_tier3_refs_rejects_uppercase_in_scope_or_name() {
307 let body = "[[Anthropic/core:agents]] and [[anthropic/Core:agents]]";
308 let refs = extract_tier3_refs(body);
309 assert!(refs.is_empty());
310 }
311
312 #[test]
313 fn resolve_succeeds_against_present_cache() {
314 let tmp = TempDir::new().unwrap();
315 cache_archive(
316 tmp.path(),
317 "anthropic",
318 "core",
319 &[
320 (
321 "agents.md",
322 "---\ntype: spec\n---\n# Agents\n\n## Identity\n\nA.\n",
323 ),
324 (
325 "tools.md",
326 "---\ntype: spec\n---\n# Tools\n\n## Identity\n\nT.\n",
327 ),
328 ],
329 );
330
331 let r = Tier3Ref {
332 scope: "anthropic".into(),
333 name: "core".into(),
334 slug: "agents".into(),
335 };
336 let id = r.resolve(tmp.path()).unwrap();
337 assert_eq!(id.as_ref(), "core--agents");
338 }
339
340 #[test]
341 fn resolve_fails_when_cache_missing() {
342 let tmp = TempDir::new().unwrap();
343 let r = Tier3Ref {
344 scope: "anthropic".into(),
345 name: "core".into(),
346 slug: "agents".into(),
347 };
348 let err = r.resolve(tmp.path()).expect_err("missing cache must error");
349 match err {
350 Tier3ResolveError::CacheMissing { .. } => {}
351 other => panic!("expected CacheMissing, got {other:?}"),
352 }
353 assert_eq!(err.tier3(), "anthropic/core:agents");
354 }
355
356 #[test]
357 fn resolve_fails_when_slug_absent_from_cache() {
358 let tmp = TempDir::new().unwrap();
359 cache_archive(
360 tmp.path(),
361 "anthropic",
362 "core",
363 &[(
364 "tools.md",
365 "---\ntype: spec\n---\n# Tools\n\n## Identity\n\nT.\n",
366 )],
367 );
368
369 let r = Tier3Ref {
370 scope: "anthropic".into(),
371 name: "core".into(),
372 slug: "agents".into(),
373 };
374 let err = r.resolve(tmp.path()).expect_err("absent slug must error");
375 match err {
376 Tier3ResolveError::SlugAbsent { .. } => {}
377 other => panic!("expected SlugAbsent, got {other:?}"),
378 }
379 }
380
381 #[test]
382 fn cache_path_lands_under_memstead_memstead_io() {
383 let r = Tier3Ref {
384 scope: "anthropic".into(),
385 name: "core".into(),
386 slug: "agents".into(),
387 };
388 let path = r.cache_path(Path::new("/ws"));
389 assert_eq!(
390 path,
391 PathBuf::from("/ws/.memstead/memstead-io/anthropic/core.mem")
392 );
393 }
394}