Skip to main content

anamnesis_adapter_claude_code/
detector.rs

1//! Detector for Claude Code memory installations.
2
3use 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
11/// Detector for `~/.claude/projects/`.
12pub struct ClaudeCodeDetector {
13    /// Optional override path (set by tests; production uses `$HOME`).
14    pub override_root: Option<PathBuf>,
15}
16
17impl ClaudeCodeDetector {
18    /// Production constructor — resolves `$HOME/.claude/projects` at detect time.
19    pub fn new() -> Self {
20        Self {
21            override_root: None,
22        }
23    }
24
25    /// Test constructor — point at an explicit projects root.
26    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        // Scan returns Ok(empty) when root vanishes between the check and
51        // the open — treat that as "nothing to import" rather than an error.
52        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            // Directory exists but no project subdirs — medium confidence so the
63            // CLI shows it but doesn't auto-select.
64            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        // Atomic counter + pid avoids same-nanosecond collisions between
114        // parallel test threads (Windows in particular has coarser timer
115        // resolution and trips this race reliably).
116        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        // 2 jsonl + 1 memory (MEMORY.md excluded) = 3
161        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}