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//! consumed via the unified [`crate::Engine`] when a folder mount
24//! is present.
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    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/// Validation-pass result: one entry per Tier 3 reference that
214/// could not resolve.
215#[derive(Debug, Clone)]
216pub struct Tier3Warning {
217    /// The entity that contained the unresolved reference.
218    pub entity_id: EntityId,
219    /// The Tier 3 reference body, e.g. `"anthropic/core:agents"`.
220    pub tier3: String,
221    /// Why resolution failed. Stable string — agents grep on the
222    /// prefix (`cache missing` vs `slug not found`).
223    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        // Tier 1 (`[[slug]]`) and Tier 2 (`[[leaf:slug]]`) must NOT
266        // match — only the three-part scope/name:slug form.
267        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}