Skip to main content

memstead_base/filesystem/
tier3.rs

1//! Tier 3 wiki-link resolution against cached
2//! `.memstead/memstead-io/<scope>/<name>.mem` archives.
3//!
4//! ## What Tier 3 means in filesystem-mem context
5//!
6//! - **Tier 1** `[[slug]]` — same-mem.
7//! - **Tier 2** `[[leaf:slug]]` — cross-mem (mem-repo only —
8//!   multi-mem under one repo).
9//! - **Tier 3** `[[scope/name:slug]]` — registry-published mem. The
10//!   filesystem-mem workspace caches the dep at
11//!   `<workspace_root>/.memstead/memstead-io/<scope>/<name>.mem` (populated
12//!   by `memstead link <scope/name>`); this module reads that archive
13//!   and resolves the slug to a cross-mem [`EntityId`] of the
14//!   existing shape.
15//!
16//! ## mem-repo invariance
17//!
18//! The wiki-link parser at [`crate::entity::id::wiki_link_to_id`]
19//! still falls Tier 3 syntax back to Tier 1 silently — preserving
20//! existing mem-repo behaviour. This module is the
21//! filesystem-mem-only counterpart that surfaces resolution
22//! warnings as a separate validation pass over loaded entities.
23//! No engine path consumes it yet — wiring it into the unified
24//! [`crate::Engine`] folder-mount load is the pending step.
25//!
26//! ## What this module does NOT do (yet)
27//!
28//! - Rewrite `parse_result.inline_links` or store edges. The current
29//!   v1 surface returns warnings; the resolved [`crate::EntityId`] is
30//!   available via [`Tier3Ref::resolve`] but the engine's load path
31//!   does not yet swap the same-mem fallback in `inline_links` for
32//!   the resolved cross-mem id. A follow-up plan can take that
33//!   final step once the warning-only surface settles.
34
35use 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/// Parsed Tier 3 reference.
45#[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    /// On-disk path of the cached archive for this dep, given the
54    /// workspace root — the `.mem` cache file.
55    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    /// Resolve to a cross-mem [`EntityId`] by reading the cached
71    /// archive. The mem component of the returned id is the
72    /// archive's mem `name` (matching the archive's
73    /// `.memstead/config.json` `name` field — same value as the dep
74    /// reference's `name`).
75    ///
76    /// Returns [`Tier3ResolveError`] when the cache file is missing,
77    /// the archive cannot be read, or the slug is not present.
78    pub fn resolve(&self, workspace_root: &Path) -> Result<EntityId, Tier3ResolveError> {
79        // `.mem` is what `memstead link` writes — the sole cache spelling.
80        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        // Match by `relative_path` stem — the archive's entity ids
98        // are computed from the relative path via
99        // `file_path_to_id`, and the slug part of the
100        // `[[scope/name:slug]]` reference matches the file path
101        // (without the `.md` extension).
102        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    /// Display form used in warning messages and tests:
115    /// `<scope>/<name>:<slug>`.
116    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/// Errors surfaced by [`Tier3Ref::resolve`].
128#[derive(Debug, thiserror::Error)]
129pub enum Tier3ResolveError {
130    /// The cached archive does not exist on disk. Run `memstead link
131    /// <scope>/<name>` to populate it.
132    #[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    /// The cached archive is present but does not contain an entity
139    /// with the requested slug. The dep version may be stale (run
140    /// `memstead link <scope>/<name>` again to refresh) or the slug may
141    /// be a typo.
142    #[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    /// The cached archive could not be read — corrupt zip,
148    /// permission error, or similar. Concrete IO error message is
149    /// preserved for debugging.
150    #[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    /// Workspace-relative reference being resolved (e.g.
164    /// `"anthropic/core:agents"`). Used to attach context in
165    /// validation-pass warning emission.
166    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
175/// LoadError thin alias used by callers that want to forward archive
176/// IO problems through the load-error surface. Not constructed
177/// inside this module — exposed only so external callers don't have
178/// to reach into `crate::entity::loader` for the type.
179pub type Tier3LoadError = LoadError;
180
181/// Match `[[scope/name:slug]]` exactly. Same character class as the
182/// strict slug regex (lowercase + digits + hyphens), and rejects
183/// extra `[` / `]` / `:` / whitespace inside the link body.
184fn tier3_re() -> &'static Regex {
185    static RE: OnceLock<Regex> = OnceLock::new();
186    RE.get_or_init(|| {
187        // Tier 3 syntax: scope and name are slug-shaped per the
188        // registry's own validator. Slug part is more permissive —
189        // matches the parser's wider id-character class so legacy
190        // entities with `--`-shaped paths can still be referenced.
191        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
198/// Walk `text` for every `[[scope/name:slug]]` occurrence and
199/// produce a [`Tier3Ref`] per hit. Iteration is in-order; duplicate
200/// references in the same body are emitted once per occurrence
201/// (callers that want unique-by-key dedup do so themselves).
202pub fn extract_tier3_refs(text: &str) -> Vec<Tier3Ref> {
203    // Code is not a reference, by the one definition every other link
204    // scanner uses ([`crate::markdown`]). A Tier 3 reference written
205    // inside a fence or an inline span documents the syntax rather
206    // than using it. Masking preserves byte offsets, so captures are
207    // read back from the original.
208    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/// Validation-pass result: one entry per Tier 3 reference that
220/// could not resolve.
221#[derive(Debug, Clone)]
222pub struct Tier3Warning {
223    /// The entity that contained the unresolved reference.
224    pub entity_id: EntityId,
225    /// The Tier 3 reference body, e.g. `"anthropic/core:agents"`.
226    pub tier3: String,
227    /// Why resolution failed. Stable string — agents grep on the
228    /// prefix (`cache missing` vs `slug not found`).
229    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    /// Complement: a prose reference still resolves, with every part
285    /// read back from the original.
286    #[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        // Tier 1 (`[[slug]]`) and Tier 2 (`[[leaf:slug]]`) must NOT
299        // match — only the three-part scope/name:slug form.
300        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}