anamnesis_adapter_claude_code/
detector.rs1use std::path::PathBuf;
4
5use anamnesis_core::discovery::{Confidence, DetectOpts, DetectedSource, SourceDetector};
6use anamnesis_core::error::Result;
7use async_trait::async_trait;
8
9use crate::scanner::{count_records, scan_projects_root};
10
11pub struct ClaudeCodeDetector {
13 pub override_root: Option<PathBuf>,
15}
16
17impl ClaudeCodeDetector {
18 pub fn new() -> Self {
20 Self {
21 override_root: None,
22 }
23 }
24
25 pub fn with_root(root: impl Into<PathBuf>) -> Self {
27 Self {
28 override_root: Some(root.into()),
29 }
30 }
31}
32
33impl Default for ClaudeCodeDetector {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39#[async_trait]
40impl SourceDetector for ClaudeCodeDetector {
41 fn adapter_id(&self) -> &'static str {
42 crate::ADAPTER_ID
43 }
44
45 async fn detect(&self, opts: &DetectOpts) -> Result<Vec<DetectedSource>> {
46 let root = self.resolve_root(opts);
47 if !root.exists() {
48 return Ok(Vec::new());
49 }
50 let scans = match scan_projects_root(&root) {
53 Ok(s) => s,
54 Err(e) => {
55 return Err(anamnesis_core::Error::Adapter {
56 adapter: crate::ADAPTER_ID.into(),
57 message: format!("scan {}: {e}", root.display()),
58 });
59 }
60 };
61 if scans.is_empty() {
62 return Ok(vec![DetectedSource {
65 adapter: crate::ADAPTER_ID.into(),
66 instance: Some("default".into()),
67 location: root.display().to_string(),
68 local_path: Some(root),
69 confidence: Confidence::Medium,
70 estimated_records: Some(0),
71 note: Some("projects/ exists but is empty".into()),
72 }]);
73 }
74 let (mem, jsonl) = count_records(&scans);
75 let note = format!(
76 "{} project(s), {mem} memory file(s), {jsonl} session file(s)",
77 scans.len(),
78 );
79 Ok(vec![DetectedSource {
80 adapter: crate::ADAPTER_ID.into(),
81 instance: Some("default".into()),
82 location: root.display().to_string(),
83 local_path: Some(root),
84 confidence: Confidence::High,
85 estimated_records: Some(mem + jsonl),
86 note: Some(note),
87 }])
88 }
89}
90
91impl ClaudeCodeDetector {
92 fn resolve_root(&self, opts: &DetectOpts) -> PathBuf {
93 if let Some(p) = &self.override_root {
94 return p.clone();
95 }
96 let home = opts
97 .home_override
98 .clone()
99 .or_else(|| std::env::var_os("HOME").map(PathBuf::from))
100 .unwrap_or_else(|| PathBuf::from("/"));
101 home.join(".claude").join("projects")
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use std::fs;
109
110 static NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
111
112 fn tmp_dir() -> std::path::PathBuf {
113 let n = NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
117 let pid = std::process::id();
118 let p = std::env::temp_dir().join(format!("anamnesis-detector-{pid}-{n}"));
119 fs::create_dir_all(&p).unwrap();
120 p
121 }
122
123 #[tokio::test]
124 async fn returns_empty_when_root_missing() {
125 let d = ClaudeCodeDetector::with_root("/definitely/not/a/path");
126 let found = d.detect(&DetectOpts::default()).await.unwrap();
127 assert!(found.is_empty());
128 }
129
130 #[tokio::test]
131 async fn medium_confidence_when_root_exists_but_no_projects() {
132 let root = tmp_dir();
133 let d = ClaudeCodeDetector::with_root(&root);
134 let found = d.detect(&DetectOpts::default()).await.unwrap();
135 assert_eq!(found.len(), 1);
136 assert_eq!(found[0].confidence, Confidence::Medium);
137 assert_eq!(found[0].estimated_records, Some(0));
138 }
139
140 #[tokio::test]
141 async fn high_confidence_with_realistic_layout() {
142 let root = tmp_dir();
143 let proj = root.join("project-hash");
144 fs::create_dir_all(&proj).unwrap();
145 fs::write(proj.join("session-1.jsonl"), "{}").unwrap();
146 fs::write(proj.join("session-2.jsonl"), "{}").unwrap();
147 fs::create_dir_all(proj.join("memory")).unwrap();
148 fs::write(
149 proj.join("memory").join("user_role.md"),
150 "---\nname: x\n---\n",
151 )
152 .unwrap();
153 fs::write(proj.join("memory").join("MEMORY.md"), "index").unwrap();
154
155 let d = ClaudeCodeDetector::with_root(&root);
156 let found = d.detect(&DetectOpts::default()).await.unwrap();
157 assert_eq!(found.len(), 1);
158 let s = &found[0];
159 assert_eq!(s.confidence, Confidence::High);
160 assert_eq!(s.estimated_records, Some(3));
162 assert!(s.note.as_deref().unwrap().contains("1 project"));
163 }
164
165 #[tokio::test]
166 async fn respects_home_override_when_no_explicit_root() {
167 let root_home = tmp_dir();
168 std::fs::create_dir_all(root_home.join(".claude").join("projects")).unwrap();
169 let d = ClaudeCodeDetector::new();
170 let opts = DetectOpts {
171 home_override: Some(root_home.clone()),
172 ..Default::default()
173 };
174 let found = d.detect(&opts).await.unwrap();
175 assert_eq!(found.len(), 1);
176 assert_eq!(
177 found[0].local_path.as_deref().unwrap(),
178 root_home.join(".claude").join("projects"),
179 );
180 }
181
182 #[tokio::test]
183 async fn adapter_id_is_stable() {
184 let d = ClaudeCodeDetector::new();
185 assert_eq!(d.adapter_id(), "claude-code");
186 }
187}