use std::path::{Path, PathBuf};
use regex::Regex;
use std::sync::OnceLock;
use crate::entity::EntityId;
use crate::entity::loader::LoadError;
use crate::entity::source::EntitySource;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tier3Ref {
pub scope: String,
pub name: String,
pub slug: String,
}
impl Tier3Ref {
pub fn cache_path(&self, workspace_root: &Path) -> PathBuf {
self.cache_dir(workspace_root).join(format!(
"{}.{}",
self.name,
memstead_schema::ARCHIVE_EXTENSION
))
}
fn cache_dir(&self, workspace_root: &Path) -> PathBuf {
workspace_root
.join(crate::workspace_store::WORKSPACE_STORE_DIR)
.join("memstead-io")
.join(&self.scope)
}
pub fn resolve(&self, workspace_root: &Path) -> Result<EntityId, Tier3ResolveError> {
let cache_path = self.cache_path(workspace_root);
if !cache_path.is_file() {
return Err(Tier3ResolveError::CacheMissing {
cache_path,
tier3: self.as_display(),
});
}
let source = EntitySource::ZipArchive(cache_path.clone());
let (entries, _) = source
.read_all()
.map_err(|e| Tier3ResolveError::ArchiveRead {
cache_path: cache_path.clone(),
tier3: self.as_display(),
error: e.to_string(),
})?;
let want = format!("{}.md", self.slug);
let found = entries.iter().any(|e| e.relative_path == want);
if !found {
return Err(Tier3ResolveError::SlugAbsent {
cache_path,
tier3: self.as_display(),
});
}
Ok(EntityId::new(&self.name, &self.slug))
}
pub fn as_display(&self) -> String {
format!("{}/{}:{}", self.scope, self.name, self.slug)
}
}
impl std::fmt::Display for Tier3Ref {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.as_display())
}
}
#[derive(Debug, thiserror::Error)]
pub enum Tier3ResolveError {
#[error(
"tier 3 link {tier3} cannot resolve: cached archive missing at {} \
— run `memstead link {{scope}}/{{name}}` to populate it",
cache_path.display()
)]
CacheMissing { cache_path: PathBuf, tier3: String },
#[error(
"tier 3 link {tier3} cannot resolve: slug not found in cached archive at {}",
cache_path.display()
)]
SlugAbsent { cache_path: PathBuf, tier3: String },
#[error(
"tier 3 link {tier3} cannot resolve: archive at {} unreadable: {error}",
cache_path.display()
)]
#[allow(dead_code)]
ArchiveRead {
cache_path: PathBuf,
tier3: String,
error: String,
},
}
impl Tier3ResolveError {
pub fn tier3(&self) -> &str {
match self {
Tier3ResolveError::CacheMissing { tier3, .. } => tier3,
Tier3ResolveError::SlugAbsent { tier3, .. } => tier3,
Tier3ResolveError::ArchiveRead { tier3, .. } => tier3,
}
}
}
pub type Tier3LoadError = LoadError;
fn tier3_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
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_./\-]*)\]\]",
)
.expect("tier-3 regex must compile")
})
}
pub fn extract_tier3_refs(text: &str) -> Vec<Tier3Ref> {
let re = tier3_re();
re.captures_iter(text)
.map(|cap| Tier3Ref {
scope: cap[1].to_string(),
name: cap[2].to_string(),
slug: cap[3].to_string(),
})
.collect()
}
#[derive(Debug, Clone)]
pub struct Tier3Warning {
pub entity_id: EntityId,
pub tier3: String,
pub reason: String,
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
use zip::CompressionMethod;
use zip::write::SimpleFileOptions;
fn write_archive(path: &Path, entries: &[(&str, &str)]) {
let file = std::fs::File::create(path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
for (name, content) in entries {
zip.start_file(*name, opts).unwrap();
zip.write_all(content.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
fn cache_archive(workspace_root: &Path, scope: &str, name: &str, entries: &[(&str, &str)]) {
let dir = workspace_root
.join(".memstead")
.join("memstead-io")
.join(scope);
std::fs::create_dir_all(&dir).unwrap();
write_archive(&dir.join(format!("{name}.mem")), entries);
}
#[test]
fn extract_tier3_refs_finds_simple_references() {
let body = "See [[anthropic/core:agents]] and [[scope/name:foo-bar]].";
let refs = extract_tier3_refs(body);
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].as_display(), "anthropic/core:agents");
assert_eq!(refs[1].as_display(), "scope/name:foo-bar");
}
#[test]
fn extract_tier3_refs_ignores_tier1_and_tier2() {
let body = "Tier 1: [[plain]]. Tier 2: [[leaf:slug]]. Mixed.";
let refs = extract_tier3_refs(body);
assert!(refs.is_empty());
}
#[test]
fn extract_tier3_refs_rejects_uppercase_in_scope_or_name() {
let body = "[[Anthropic/core:agents]] and [[anthropic/Core:agents]]";
let refs = extract_tier3_refs(body);
assert!(refs.is_empty());
}
#[test]
fn resolve_succeeds_against_present_cache() {
let tmp = TempDir::new().unwrap();
cache_archive(
tmp.path(),
"anthropic",
"core",
&[
(
"agents.md",
"---\ntype: spec\n---\n# Agents\n\n## Identity\n\nA.\n",
),
(
"tools.md",
"---\ntype: spec\n---\n# Tools\n\n## Identity\n\nT.\n",
),
],
);
let r = Tier3Ref {
scope: "anthropic".into(),
name: "core".into(),
slug: "agents".into(),
};
let id = r.resolve(tmp.path()).unwrap();
assert_eq!(id.as_ref(), "core--agents");
}
#[test]
fn resolve_fails_when_cache_missing() {
let tmp = TempDir::new().unwrap();
let r = Tier3Ref {
scope: "anthropic".into(),
name: "core".into(),
slug: "agents".into(),
};
let err = r.resolve(tmp.path()).expect_err("missing cache must error");
match err {
Tier3ResolveError::CacheMissing { .. } => {}
other => panic!("expected CacheMissing, got {other:?}"),
}
assert_eq!(err.tier3(), "anthropic/core:agents");
}
#[test]
fn resolve_fails_when_slug_absent_from_cache() {
let tmp = TempDir::new().unwrap();
cache_archive(
tmp.path(),
"anthropic",
"core",
&[(
"tools.md",
"---\ntype: spec\n---\n# Tools\n\n## Identity\n\nT.\n",
)],
);
let r = Tier3Ref {
scope: "anthropic".into(),
name: "core".into(),
slug: "agents".into(),
};
let err = r.resolve(tmp.path()).expect_err("absent slug must error");
match err {
Tier3ResolveError::SlugAbsent { .. } => {}
other => panic!("expected SlugAbsent, got {other:?}"),
}
}
#[test]
fn cache_path_lands_under_memstead_memstead_io() {
let r = Tier3Ref {
scope: "anthropic".into(),
name: "core".into(),
slug: "agents".into(),
};
let path = r.cache_path(Path::new("/ws"));
assert_eq!(
path,
PathBuf::from("/ws/.memstead/memstead-io/anthropic/core.mem")
);
}
}