Skip to main content

mempal_agent_memory/
anchor.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use super::types::{AnchorKind, MemoryDomain, Provenance, SourceType};
5use thiserror::Error;
6
7pub const LEGACY_REPO_ANCHOR_ID: &str = "repo://legacy";
8pub const DEFAULT_FIELD: &str = "general";
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct DerivedAnchor {
12    pub anchor_kind: AnchorKind,
13    pub anchor_id: String,
14    pub parent_anchor_id: Option<String>,
15}
16
17#[derive(Debug, Error)]
18pub enum AnchorError {
19    #[error("cwd is required to derive anchor metadata")]
20    MissingCwd,
21    #[error("failed to canonicalize {path}: {source}")]
22    Canonicalize {
23        path: PathBuf,
24        #[source]
25        source: std::io::Error,
26    },
27    #[error("git rev-parse {arg} failed for {cwd}: {stderr}")]
28    Git {
29        cwd: PathBuf,
30        arg: &'static str,
31        stderr: String,
32    },
33    #[error(
34        "invalid explicit anchor for kind={kind}: expected prefix {expected_prefix}, got {anchor_id}"
35    )]
36    InvalidExplicitAnchor {
37        kind: &'static str,
38        expected_prefix: &'static str,
39        anchor_id: String,
40    },
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct BootstrapDefaults {
45    pub field: String,
46    pub anchor_kind: AnchorKind,
47    pub anchor_id: String,
48    pub parent_anchor_id: Option<String>,
49    pub provenance: Provenance,
50}
51
52pub fn bootstrap_anchor() -> (AnchorKind, String, Option<String>) {
53    (AnchorKind::Repo, LEGACY_REPO_ANCHOR_ID.to_string(), None)
54}
55
56pub fn bootstrap_provenance(source_type: &SourceType) -> Provenance {
57    match source_type {
58        SourceType::Project => Provenance::Research,
59        SourceType::Conversation | SourceType::Manual => Provenance::Human,
60    }
61}
62
63pub fn bootstrap_defaults(source_type: &SourceType) -> BootstrapDefaults {
64    let (anchor_kind, anchor_id, parent_anchor_id) = bootstrap_anchor();
65    BootstrapDefaults {
66        field: DEFAULT_FIELD.to_string(),
67        anchor_kind,
68        anchor_id,
69        parent_anchor_id,
70        provenance: bootstrap_provenance(source_type),
71    }
72}
73
74pub fn derive_anchor_from_cwd(cwd: Option<&Path>) -> Result<DerivedAnchor, AnchorError> {
75    let cwd = cwd.ok_or(AnchorError::MissingCwd)?;
76    let canonical_cwd = cwd
77        .canonicalize()
78        .map_err(|source| AnchorError::Canonicalize {
79            path: cwd.to_path_buf(),
80            source,
81        })?;
82
83    let worktree_root = match git_rev_parse(&canonical_cwd, "--show-toplevel") {
84        Ok(root) => canonicalize_path(Path::new(root.trim()))?,
85        Err(AnchorError::Git { stderr, .. }) if is_not_git_repository_stderr(&stderr) => {
86            return Ok(DerivedAnchor {
87                anchor_kind: AnchorKind::Worktree,
88                anchor_id: worktree_anchor_id(&canonical_cwd),
89                parent_anchor_id: None,
90            });
91        }
92        Err(error) => return Err(error),
93    };
94
95    let common_dir_raw = git_rev_parse(&worktree_root, "--git-common-dir")?;
96    let common_dir_path = resolve_git_path(&worktree_root, common_dir_raw.trim());
97    let common_dir = canonicalize_path(&common_dir_path)?;
98
99    Ok(DerivedAnchor {
100        anchor_kind: AnchorKind::Worktree,
101        anchor_id: worktree_anchor_id(&worktree_root),
102        parent_anchor_id: Some(repo_anchor_id(&common_dir)),
103    })
104}
105
106pub fn validate_anchor_domain(
107    domain: &MemoryDomain,
108    anchor_kind: &AnchorKind,
109) -> Result<(), &'static str> {
110    if matches!(anchor_kind, AnchorKind::Global) && !matches!(domain, MemoryDomain::Global) {
111        return Err("global anchor requires domain=global");
112    }
113    Ok(())
114}
115
116pub fn validate_explicit_anchor(
117    anchor_kind: &AnchorKind,
118    anchor_id: &str,
119) -> Result<(), AnchorError> {
120    let (kind, prefix) = match anchor_kind {
121        AnchorKind::Global => ("global", "global://"),
122        AnchorKind::Repo => ("repo", "repo://"),
123        AnchorKind::Worktree => ("worktree", "worktree://"),
124    };
125
126    let Some(rest) = anchor_id.strip_prefix(prefix) else {
127        return Err(AnchorError::InvalidExplicitAnchor {
128            kind,
129            expected_prefix: prefix,
130            anchor_id: anchor_id.to_string(),
131        });
132    };
133
134    if rest.trim().is_empty() {
135        return Err(AnchorError::InvalidExplicitAnchor {
136            kind,
137            expected_prefix: prefix,
138            anchor_id: anchor_id.to_string(),
139        });
140    }
141
142    Ok(())
143}
144
145pub fn is_not_git_repository_stderr(stderr: &str) -> bool {
146    let normalized = stderr.to_ascii_lowercase();
147    normalized.contains("not a git repository")
148}
149
150fn worktree_anchor_id(path: &Path) -> String {
151    format!("worktree://{}", path.display())
152}
153
154fn repo_anchor_id(path: &Path) -> String {
155    format!("repo://{}", path.display())
156}
157
158fn canonicalize_path(path: &Path) -> Result<PathBuf, AnchorError> {
159    path.canonicalize()
160        .map_err(|source| AnchorError::Canonicalize {
161            path: path.to_path_buf(),
162            source,
163        })
164}
165
166fn resolve_git_path(base: &Path, value: &str) -> PathBuf {
167    let path = PathBuf::from(value);
168    if path.is_absolute() {
169        path
170    } else {
171        base.join(path)
172    }
173}
174
175fn git_rev_parse(cwd: &Path, arg: &'static str) -> Result<String, AnchorError> {
176    let output = Command::new("git")
177        .arg("rev-parse")
178        .arg(arg)
179        .current_dir(cwd)
180        .output()
181        .map_err(|source| AnchorError::Canonicalize {
182            path: cwd.to_path_buf(),
183            source,
184        })?;
185
186    if output.status.success() {
187        return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
188    }
189
190    Err(AnchorError::Git {
191        cwd: cwd.to_path_buf(),
192        arg,
193        stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
194    })
195}
196
197#[cfg(test)]
198mod tests {
199    use super::{AnchorKind, is_not_git_repository_stderr, validate_explicit_anchor};
200
201    #[test]
202    fn test_not_git_repository_classifier_is_narrow() {
203        assert!(is_not_git_repository_stderr(
204            "fatal: not a git repository (or any of the parent directories): .git"
205        ));
206        assert!(!is_not_git_repository_stderr(
207            "fatal: ambiguous argument '--git-common-dir'"
208        ));
209        assert!(!is_not_git_repository_stderr(
210            "fatal: detected dubious ownership in repository"
211        ));
212    }
213
214    #[test]
215    fn test_validate_explicit_anchor_rejects_mismatched_prefix() {
216        let error = validate_explicit_anchor(&AnchorKind::Worktree, "/tmp/repo")
217            .expect_err("raw path should be rejected");
218        assert!(error.to_string().contains("worktree://"));
219    }
220}