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}