1use std::collections::HashSet;
20use std::fs;
21use std::io::{BufRead, BufReader};
22use std::path::{Path, PathBuf};
23use std::time::UNIX_EPOCH;
24
25use super::adapter::*;
26
27pub struct ClaudeHistoryAdapter {
32 user_home: Option<PathBuf>,
34 shared_dir: Option<PathBuf>,
36 agentmux_home: Option<PathBuf>,
38}
39
40impl ClaudeHistoryAdapter {
41 fn push_deduped_dir(dirs: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>, path: PathBuf) {
50 if !path.is_dir() {
51 return;
52 }
53 let key = fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
54 if seen.insert(key) {
55 dirs.push(path);
56 }
57 }
58
59 pub fn new() -> Self {
60 let shared_dir = std::env::var_os("AGENTMUX_SHARED_DIR")
63 .map(PathBuf::from)
64 .or_else(|| agentmux_common::data_paths::agentmux_root().ok().map(|root| root.join("shared")));
65 let agentmux_home = agentmux_common::DataPaths::from_env().map(|paths| paths.home_dir);
70 Self::with_roots(dirs::home_dir(), shared_dir, agentmux_home)
71 }
72
73 pub(crate) fn with_roots(
74 user_home: Option<PathBuf>,
75 shared_dir: Option<PathBuf>,
76 agentmux_home: Option<PathBuf>,
77 ) -> Self {
78 ClaudeHistoryAdapter { user_home, shared_dir, agentmux_home }
79 }
80
81 fn base_dirs(&self, problems: &mut Vec<String>) -> Vec<PathBuf> {
88 let mut base_dirs = Vec::new();
89 let mut seen_canonical: HashSet<PathBuf> = HashSet::new();
90
91 if let Some(home) = &self.user_home {
92 Self::push_deduped_dir(&mut base_dirs, &mut seen_canonical, home.join(".claude").join("projects"));
94
95 let config_dir = home.join(".config");
97 if config_dir.is_dir() {
98 if let Some(entries) = read_dir_or_note(problems, &config_dir) {
99 for entry in entries.flatten() {
100 let name = entry.file_name();
101 let name_str = name.to_string_lossy();
102 if name_str.starts_with("claude-") {
103 Self::push_deduped_dir(&mut base_dirs, &mut seen_canonical, entry.path().join("projects"));
104 }
105 }
106 }
107 }
108 }
109
110 if let Some(shared) = &self.shared_dir {
117 Self::push_deduped_dir(&mut base_dirs, &mut seen_canonical, shared.join("providers").join("claude").join("projects"));
118 if let Some(entries) = read_dir_or_note(problems, &shared.join("identities")) {
119 for entry in entries.flatten() {
120 Self::push_deduped_dir(&mut base_dirs, &mut seen_canonical, entry.path().join("claude").join("projects"));
121 }
122 }
123 }
124
125 if let Some(agentmux_home) = &self.agentmux_home {
130 for root_name in ["channels", "dev"] {
131 Self::scan_isolated_identities_under(
132 &agentmux_home.join(root_name),
133 &mut base_dirs,
134 &mut seen_canonical,
135 2,
136 problems,
137 );
138 }
139 }
140
141 base_dirs
142 }
143
144 fn scan_isolated_identities_under(
153 root: &Path,
154 base_dirs: &mut Vec<PathBuf>,
155 seen: &mut HashSet<PathBuf>,
156 max_depth: u8,
157 problems: &mut Vec<String>,
158 ) {
159 let Some(entries) = read_dir_or_note(problems, root) else {
160 return;
161 };
162 for entry in entries.flatten() {
163 let path = entry.path();
164 if !path.is_dir() {
165 continue;
166 }
167 let identities = path.join("identities");
168 if identities.is_dir() {
169 if let Some(id_entries) = read_dir_or_note(problems, &identities) {
170 for id_entry in id_entries.flatten() {
171 Self::push_deduped_dir(base_dirs, seen, id_entry.path().join("claude").join("projects"));
172 }
173 }
174 }
175 if max_depth > 1 {
176 Self::scan_isolated_identities_under(&path, base_dirs, seen, max_depth - 1, problems);
177 }
178 }
179 }
180
181 fn count_subagents(session_dir: &Path) -> u32 {
183 let subagents_dir = session_dir.join("subagents");
184 if !subagents_dir.is_dir() {
185 return 0;
186 }
187 fs::read_dir(&subagents_dir)
188 .map(|entries| {
189 entries
190 .flatten()
191 .filter(|e| {
192 let name = e.file_name();
193 let s = name.to_string_lossy();
194 s.starts_with("agent-") && s.ends_with(".jsonl")
195 })
196 .count() as u32
197 })
198 .unwrap_or(0)
199 }
200
201 fn decode_project_path(encoded: &str) -> String {
205 let mut result = encoded.to_string();
207 if result.len() >= 2 && result.as_bytes()[1] == b'-' && result.as_bytes()[0].is_ascii_uppercase() {
209 result = format!("{}:{}", &result[..1], &result[2..]);
210 }
211 result = result.replace('-', "/");
213 result
214 }
215
216 fn identity_id_from_path(path: &Path) -> String {
226 let components: Vec<String> = path
227 .components()
228 .map(|c| c.as_os_str().to_string_lossy().into_owned())
229 .collect();
230 for (i, comp) in components.iter().enumerate() {
231 if comp == "identities" && components.get(i + 2).map(String::as_str) == Some("claude") {
232 if let Some(bundle_id) = components.get(i + 1) {
233 return bundle_id.clone();
234 }
235 }
236 }
237 String::new()
238 }
239}
240
241fn note_unreadable(problems: &mut Vec<String>, path: &Path, e: &std::io::Error) {
245 if e.kind() != std::io::ErrorKind::NotFound {
246 problems.push(format!("{}: {e}", path.display()));
247 }
248}
249
250fn read_dir_or_note(problems: &mut Vec<String>, dir: &Path) -> Option<fs::ReadDir> {
251 fs::read_dir(dir).map_err(|e| note_unreadable(problems, dir, &e)).ok()
252}
253
254fn metadata_or_note(problems: &mut Vec<String>, file: &Path) -> Option<fs::Metadata> {
255 file.metadata().map_err(|e| note_unreadable(problems, file, &e)).ok()
256}
257
258pub(crate) fn message_from_entry(entry: &serde_json::Value, timestamp: i64) -> Option<HistoryMessage> {
263 let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
264 if entry_type == "user" {
265 let content = if let Some(msg) = entry.pointer("/message/content") {
266 if let Some(text) = msg.as_str() {
267 text.to_string()
268 } else if let Some(arr) = msg.as_array() {
269 arr.iter()
270 .filter_map(|block| {
271 if block.get("type").and_then(|v| v.as_str()) == Some("text") {
272 block.get("text").and_then(|v| v.as_str()).map(String::from)
273 } else {
274 None
275 }
276 })
277 .collect::<Vec<_>>()
278 .join("\n")
279 } else {
280 String::new()
281 }
282 } else {
283 String::new()
284 };
285
286 return (!content.is_empty()).then(|| HistoryMessage {
287 role: "user".to_string(),
288 content,
289 timestamp,
290 tool_uses: vec![],
291 });
292 } else if entry_type == "assistant" {
293 let mut text_parts = Vec::new();
294 let mut tool_uses = Vec::new();
295
296 if let Some(content_arr) = entry.pointer("/message/content").and_then(|v| v.as_array()) {
297 for block in content_arr {
298 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
299 match block_type {
300 "text" => {
301 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
302 text_parts.push(text.to_string());
303 }
304 }
305 "tool_use" => {
306 let name = block
307 .get("name")
308 .and_then(|v| v.as_str())
309 .unwrap_or("unknown")
310 .to_string();
311 let arg_summary = if let Some(input) = block.get("input") {
313 if let Some(obj) = input.as_object() {
314 obj.iter()
316 .next()
317 .map(|(k, v)| {
318 let val_str = if let Some(s) = v.as_str() {
319 s.chars().take(100).collect::<String>()
320 } else {
321 v.to_string().chars().take(100).collect::<String>()
322 };
323 format!("{}: {}", k, val_str)
324 })
325 .unwrap_or_default()
326 } else {
327 String::new()
328 }
329 } else {
330 String::new()
331 };
332 tool_uses.push(ToolUseSummary {
333 name,
334 argument_summary: arg_summary,
335 });
336 }
337 _ => {}
339 }
340 }
341 }
342
343 let content = text_parts.join("\n");
344 return (!content.is_empty() || !tool_uses.is_empty()).then(|| HistoryMessage {
345 role: "assistant".to_string(),
346 content,
347 timestamp,
348 tool_uses,
349 });
350 }
351 None
352}
353
354impl HistoryAdapter for ClaudeHistoryAdapter {
355 fn provider(&self) -> &str {
356 "claude"
357 }
358
359 fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
360 self.discover().map(|found| found.files)
361 }
362
363 fn discover(&self) -> Result<Discovery, HistoryError> {
364 let mut files = Vec::new();
365 let mut problems = Vec::new();
366
367 for base_dir in &self.base_dirs(&mut problems) {
368 let entries = match fs::read_dir(base_dir) {
369 Ok(e) => e,
370 Err(e) => {
371 note_unreadable(&mut problems, base_dir, &e);
372 continue;
373 }
374 };
375
376 for project_entry in entries {
377 let project_entry = match project_entry {
378 Ok(entry) => entry,
379 Err(e) => {
380 note_unreadable(&mut problems, base_dir, &e);
381 continue;
382 }
383 };
384 let project_path = project_entry.path();
385 if !project_path.is_dir() {
386 if project_path.extension().map_or(false, |e| e == "jsonl") {
388 if let Some(meta) = metadata_or_note(&mut problems, &project_path) {
389 let mtime = meta
390 .modified()
391 .ok()
392 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
393 .map(|d| d.as_millis() as i64)
394 .unwrap_or(0);
395 files.push(DiscoveredFile {
396 file_path: project_path.to_string_lossy().into(),
397 mtime_ms: mtime,
398 });
399 }
400 }
401 continue;
402 }
403
404 let dir_entries = match fs::read_dir(&project_path) {
407 Ok(e) => e,
408 Err(e) => {
409 note_unreadable(&mut problems, &project_path, &e);
410 continue;
411 }
412 };
413 for file_entry in dir_entries {
414 let file_entry = match file_entry {
415 Ok(entry) => entry,
416 Err(e) => {
417 note_unreadable(&mut problems, &project_path, &e);
418 continue;
419 }
420 };
421 let file_path = file_entry.path();
422 if file_path.extension().map_or(false, |e| e == "jsonl") {
423 if file_path
425 .parent()
426 .and_then(|p| p.file_name())
427 .map_or(false, |n| n == "subagents")
428 {
429 continue;
430 }
431 if let Some(meta) = metadata_or_note(&mut problems, &file_path) {
432 let mtime = meta
433 .modified()
434 .ok()
435 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
436 .map(|d| d.as_millis() as i64)
437 .unwrap_or(0);
438 files.push(DiscoveredFile {
439 file_path: file_path.to_string_lossy().into(),
440 mtime_ms: mtime,
441 });
442 }
443 }
444 }
445 }
446 }
447
448 files.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms));
449 Ok(Discovery { files, problems })
450 }
451
452 fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
453 let path = Path::new(file_path);
454 let file = fs::File::open(path)?;
455 let file_size = file.metadata()?.len();
456 let reader = BufReader::new(file);
457
458 let mut first_user_msg = String::new();
459 let mut model = "unknown".to_string();
460 let mut slug = String::new();
461 let mut cwd = String::new();
462 let mut git_branch = String::new();
463 let mut entry_count = 0u32;
464 let mut total_tokens: u64 = 0;
465 let mut first_timestamp: i64 = 0;
466 let mut last_timestamp: i64 = 0;
467 let mut starts_undated = false;
468 let mut session_id = String::new();
469
470 if let Some(stem) = path.file_stem() {
472 session_id = stem.to_string_lossy().into();
473 }
474
475 let mut lines_iter = reader.lines();
476 let mut found_all_meta = false;
477
478 while let Some(Ok(line)) = lines_iter.next() {
479 if line.trim().is_empty() {
480 continue;
481 }
482
483 let entry: serde_json::Value = match serde_json::from_str(&line) {
484 Ok(v) => v,
485 Err(_) => continue,
486 };
487 entry_count += 1;
488
489 if let Some(ts_str) = entry.get("timestamp").and_then(|v| v.as_str()) {
491 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) {
492 let ts = dt.timestamp_millis();
493 if first_timestamp == 0 {
494 first_timestamp = ts;
495 }
496 last_timestamp = ts;
497 }
498 }
499
500 if slug.is_empty() {
502 if let Some(s) = entry.get("slug").and_then(|v| v.as_str()) {
503 slug = s.to_string();
504 }
505 }
506
507 if session_id.is_empty() {
509 if let Some(s) = entry.get("sessionId").and_then(|v| v.as_str()) {
510 session_id = s.to_string();
511 }
512 }
513
514 if cwd.is_empty() {
516 if let Some(c) = entry.get("cwd").and_then(|v| v.as_str()) {
517 cwd = c.to_string();
518 }
519 }
520
521 if git_branch.is_empty() {
523 if let Some(b) = entry.get("gitBranch").and_then(|v| v.as_str()) {
524 git_branch = b.to_string();
525 }
526 }
527
528 let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
529
530 if first_timestamp == 0 && matches!(entry_type, "user" | "assistant") {
533 starts_undated = true;
534 }
535
536 if model == "unknown" && entry_type == "assistant" {
538 if let Some(m) = entry.pointer("/message/model").and_then(|v| v.as_str()) {
539 model = m.to_string();
540 }
541 if let Some(usage) = entry.pointer("/message/usage") {
543 if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
544 total_tokens += out;
545 }
546 }
547 } else if entry_type == "assistant" {
548 if let Some(usage) = entry.pointer("/message/usage") {
550 if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
551 total_tokens += out;
552 }
553 }
554 }
555
556 if first_user_msg.is_empty() && entry_type == "user" {
558 if let Some(content) = entry.pointer("/message/content") {
559 if let Some(text) = content.as_str() {
560 first_user_msg = text.chars().take(200).collect();
561 } else if let Some(arr) = content.as_array() {
562 for block in arr {
564 if block.get("type").and_then(|v| v.as_str()) == Some("text") {
565 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
566 first_user_msg = text.chars().take(200).collect();
567 break;
568 }
569 }
570 }
571 }
572 }
573 }
574
575 if !first_user_msg.is_empty()
577 && model != "unknown"
578 && !cwd.is_empty()
579 && !slug.is_empty()
580 {
581 found_all_meta = true;
582 break;
583 }
584 }
585
586 if found_all_meta {
588 for remaining_line in lines_iter {
589 if let Ok(line) = remaining_line {
590 if !line.trim().is_empty() {
591 entry_count += 1;
592 }
593 }
594 }
595 }
596
597 if entry_count == 0 {
598 return Ok(None);
599 }
600
601 if cwd.is_empty() {
603 if let Some(parent_name) = path
604 .parent()
605 .and_then(|p| p.file_name())
606 .map(|n| n.to_string_lossy().to_string())
607 {
608 cwd = Self::decode_project_path(&parent_name);
609 }
610 }
611
612 let subagent_count = if let Some(parent) = path.parent() {
614 let session_dir = parent.join(&session_id);
615 Self::count_subagents(&session_dir)
616 } else {
617 0
618 };
619
620 let file_meta = fs::metadata(file_path)?;
621 let modified_at = file_meta
622 .modified()
623 .ok()
624 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
625 .map(|d| d.as_millis() as i64)
626 .unwrap_or(last_timestamp);
627
628 Ok(Some(SessionMeta {
629 session_id,
630 file_path: file_path.to_string(),
631 provider: "claude".to_string(),
632 model,
633 slug,
634 working_directory: cwd,
635 created_at: first_timestamp,
636 modified_at,
637 message_count: entry_count,
638 first_user_message: first_user_msg,
639 file_size_bytes: file_size,
640 git_branch,
641 total_tokens,
642 subagent_count,
643 identity_id: Self::identity_id_from_path(path),
644 starts_undated,
645 }))
646 }
647
648 fn parse_file(&self, file_path: &str) -> Result<Option<HistorySession>, HistoryError> {
649 let meta = match self.extract_meta(file_path)? {
651 Some(m) => m,
652 None => return Ok(None),
653 };
654
655 let bytes = fs::read(file_path)?;
661 let ends_with_newline = bytes.last() == Some(&b'\n');
662 let lines: Vec<&[u8]> = bytes.split(|b| *b == b'\n').collect();
663 let last = lines.len() - 1;
664 let mut messages = Vec::new();
665 let mut skipped_records = 0u32;
666
667 for (i, line) in lines.into_iter().enumerate() {
668 if line.iter().all(u8::is_ascii_whitespace) {
669 continue;
670 }
671 let entry: serde_json::Value = match serde_json::from_slice(line) {
672 Ok(v) => v,
673 Err(_) => {
674 let in_progress = i == last && !ends_with_newline;
675 if !in_progress {
676 skipped_records += 1;
677 }
678 continue;
679 }
680 };
681
682 let timestamp = entry
684 .get("timestamp")
685 .and_then(|v| v.as_str())
686 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
687 .map(|dt| dt.timestamp_millis())
688 .unwrap_or(0);
689
690 if let Some(message) = message_from_entry(&entry, timestamp) {
691 messages.push(message);
692 }
693 }
694
695 Ok(Some(HistorySession { meta, messages, skipped_records }))
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use std::collections::HashSet;
703
704 const USER_LINE: &str = r#"{"type":"user","message":{"role":"user","content":"deploy the fix"},"timestamp":"2026-09-24T10:00:00Z","cwd":"/work/a"}"#;
705 const ASSISTANT_LINE: &str = r#"{"type":"assistant","message":{"role":"assistant","model":"m","content":[{"type":"text","text":"deployed"}]},"timestamp":"2026-09-24T10:00:05Z","cwd":"/work/a"}"#;
706
707 fn parse(content: &str) -> HistorySession {
708 let tmp = tempfile::TempDir::new().unwrap();
709 let file = tmp.path().join("s.jsonl");
710 fs::write(&file, content).unwrap();
711 ClaudeHistoryAdapter::with_roots(None, None, None)
712 .parse_file(&file.to_string_lossy())
713 .unwrap()
714 .unwrap()
715 }
716
717 #[test]
721 fn a_malformed_record_is_counted_not_silently_skipped() {
722 let session = parse(&format!("{USER_LINE}\n{{\"type\":\"assistant\",\"mess\n{ASSISTANT_LINE}\n"));
723 assert_eq!(session.messages.len(), 2);
724 assert_eq!(session.skipped_records, 1);
725 }
726
727 #[test]
732 fn an_unterminated_last_line_is_an_append_in_progress_not_corruption() {
733 let session = parse(&format!("{USER_LINE}\n{{\"type\":\"assistant\",\"message\":{{\"con"));
734 assert_eq!(session.messages.len(), 1);
735 assert_eq!(session.skipped_records, 0);
736 }
737
738 #[test]
741 fn a_missing_directory_is_not_a_discovery_problem() {
742 let tmp = tempfile::TempDir::new().unwrap();
743 let shared = tmp.path().join("shared");
744 fs::create_dir_all(shared.join("identities").join("bundle-without-claude")).unwrap();
745 let adapter = ClaudeHistoryAdapter::with_roots(
746 Some(tmp.path().join("no-such-user-home")),
747 Some(shared),
748 Some(tmp.path().join("no-such-agentmux-home")),
749 );
750 let found = adapter.discover().unwrap();
751 assert!(found.problems.is_empty(), "{:?}", found.problems);
752 }
753
754 #[cfg(unix)]
758 #[test]
759 fn an_unreadable_project_directory_is_a_discovery_problem() {
760 use std::os::unix::fs::PermissionsExt;
761 let tmp = tempfile::TempDir::new().unwrap();
762 let shared = tmp.path().join("shared");
763 let project = shared.join("providers").join("claude").join("projects").join("-work-locked");
764 fs::create_dir_all(&project).unwrap();
765 fs::set_permissions(&project, fs::Permissions::from_mode(0o000)).unwrap();
766 let readable_anyway = fs::read_dir(&project).is_ok(); let found = ClaudeHistoryAdapter::with_roots(None, Some(shared), None).discover().unwrap();
768 fs::set_permissions(&project, fs::Permissions::from_mode(0o755)).unwrap();
769 if readable_anyway {
770 return;
771 }
772 assert!(found.problems.iter().any(|p| p.contains("-work-locked")), "{:?}", found.problems);
773 }
774
775 #[test]
782 fn a_bundle_created_after_the_adapter_is_built_is_discovered() {
783 let tmp = tempfile::TempDir::new().unwrap();
784 let shared = tmp.path().join("shared");
785 let agentmux_home = tmp.path().join("agentmux");
786 fs::create_dir_all(&shared).unwrap();
787 fs::create_dir_all(agentmux_home.join("channels")).unwrap();
788 let adapter = ClaudeHistoryAdapter::with_roots(
789 None,
790 Some(shared.clone()),
791 Some(agentmux_home.clone()),
792 );
793
794 let global = shared.join("identities").join("late-bundle").join("claude").join("projects").join("-work");
795 let channel = agentmux_home
796 .join("channels")
797 .join("late-channel")
798 .join("identities")
799 .join("channel-bundle")
800 .join("claude")
801 .join("projects")
802 .join("-work");
803 for dir in [&global, &channel] {
804 fs::create_dir_all(dir).unwrap();
805 }
806 fs::write(global.join("in-global-bundle.jsonl"), "{}\n").unwrap();
807 fs::write(channel.join("in-channel-bundle.jsonl"), "{}\n").unwrap();
808
809 let found: Vec<String> = adapter
810 .discover_files()
811 .unwrap()
812 .into_iter()
813 .map(|f| f.file_path)
814 .collect();
815 assert!(found.iter().any(|p| p.ends_with("in-global-bundle.jsonl")), "{found:?}");
816 assert!(found.iter().any(|p| p.ends_with("in-channel-bundle.jsonl")), "{found:?}");
817 }
818
819 #[test]
820 fn push_deduped_dir_skips_a_path_that_is_not_a_real_directory() {
821 let tmp = tempfile::TempDir::new().unwrap();
822 let mut dirs = Vec::new();
823 let mut seen = HashSet::new();
824 ClaudeHistoryAdapter::push_deduped_dir(&mut dirs, &mut seen, tmp.path().join("does-not-exist"));
825 assert!(dirs.is_empty());
826 }
827
828 #[test]
829 fn push_deduped_dir_pushes_a_real_directory_once() {
830 let tmp = tempfile::TempDir::new().unwrap();
831 let dir = tmp.path().join("real");
832 std::fs::create_dir_all(&dir).unwrap();
833 let mut dirs = Vec::new();
834 let mut seen = HashSet::new();
835 ClaudeHistoryAdapter::push_deduped_dir(&mut dirs, &mut seen, dir.clone());
836 ClaudeHistoryAdapter::push_deduped_dir(&mut dirs, &mut seen, dir);
837 assert_eq!(dirs.len(), 1, "calling twice with the identical path must not duplicate it");
838 }
839
840 #[cfg(windows)]
846 #[test]
847 fn push_deduped_dir_treats_a_junction_and_its_target_as_the_same_entry() {
848 let tmp = tempfile::TempDir::new().unwrap();
849 let target = tmp.path().join("global").join("projects");
850 let link = tmp.path().join("isolated").join("projects");
851 agentmux_common::ensure_history_link(&link, &target).unwrap();
852
853 let mut dirs = Vec::new();
854 let mut seen = HashSet::new();
855 ClaudeHistoryAdapter::push_deduped_dir(&mut dirs, &mut seen, target.clone());
856 ClaudeHistoryAdapter::push_deduped_dir(&mut dirs, &mut seen, link);
857 assert_eq!(
858 dirs.len(),
859 1,
860 "the junction and its target must be recognized as the same physical location, not scanned twice"
861 );
862 }
863
864 #[test]
865 fn scan_isolated_identities_finds_a_channel_scoped_projects_dir_at_depth_one() {
866 let tmp = tempfile::TempDir::new().unwrap();
867 let projects = tmp
868 .path()
869 .join("local-main-abc123")
870 .join("identities")
871 .join("bundle-1")
872 .join("claude")
873 .join("projects");
874 std::fs::create_dir_all(&projects).unwrap();
875
876 let mut dirs = Vec::new();
877 let mut seen = HashSet::new();
878 ClaudeHistoryAdapter::scan_isolated_identities_under(tmp.path(), &mut dirs, &mut seen, 2, &mut Vec::new());
879 assert_eq!(dirs, vec![projects]);
880 }
881
882 #[test]
883 fn scan_isolated_identities_finds_a_dev_clone_scoped_projects_dir_at_depth_two() {
884 let tmp = tempfile::TempDir::new().unwrap();
885 let projects = tmp
886 .path()
887 .join("main")
888 .join("a1b2c3d4")
889 .join("identities")
890 .join("bundle-2")
891 .join("claude")
892 .join("projects");
893 std::fs::create_dir_all(&projects).unwrap();
894
895 let mut dirs = Vec::new();
896 let mut seen = HashSet::new();
897 ClaudeHistoryAdapter::scan_isolated_identities_under(tmp.path(), &mut dirs, &mut seen, 2, &mut Vec::new());
898 assert_eq!(dirs, vec![projects]);
899 }
900
901 #[test]
902 fn scan_isolated_identities_does_not_descend_past_max_depth() {
903 let tmp = tempfile::TempDir::new().unwrap();
904 let projects = tmp
906 .path()
907 .join("main")
908 .join("a1b2c3d4")
909 .join("extra-nesting")
910 .join("identities")
911 .join("bundle-3")
912 .join("claude")
913 .join("projects");
914 std::fs::create_dir_all(&projects).unwrap();
915
916 let mut dirs = Vec::new();
917 let mut seen = HashSet::new();
918 ClaudeHistoryAdapter::scan_isolated_identities_under(tmp.path(), &mut dirs, &mut seen, 2, &mut Vec::new());
919 assert!(dirs.is_empty(), "must not walk past the bounded depth");
920 }
921
922 #[test]
923 fn scan_isolated_identities_ignores_directories_with_no_identities_subdir() {
924 let tmp = tempfile::TempDir::new().unwrap();
925 std::fs::create_dir_all(tmp.path().join("some-channel").join("data")).unwrap();
926
927 let mut dirs = Vec::new();
928 let mut seen = HashSet::new();
929 ClaudeHistoryAdapter::scan_isolated_identities_under(tmp.path(), &mut dirs, &mut seen, 2, &mut Vec::new());
930 assert!(dirs.is_empty());
931 }
932}