Skip to main content

toolpath_claude/
lib.rs

1#![doc = include_str!("../README.md")]
2
3#[cfg(feature = "watcher")]
4pub mod async_watcher;
5pub(crate) mod chain;
6mod constants;
7pub mod derive;
8pub mod error;
9pub mod io;
10pub mod paths;
11pub mod project;
12pub mod provider;
13pub mod query;
14pub mod reader;
15pub mod types;
16#[cfg(feature = "watcher")]
17pub mod watcher;
18pub mod writer;
19
20#[cfg(feature = "watcher")]
21pub use async_watcher::{AsyncConversationWatcher, WatcherConfig, WatcherHandle};
22pub use error::{ConvoError, Result};
23pub use io::ConvoIO;
24pub use paths::PathResolver;
25pub use project::ClaudeProjector;
26pub use query::{ConversationQuery, HistoryQuery};
27pub use reader::ConversationReader;
28pub use types::{
29    CacheCreation, ContentPart, Conversation, ConversationEntry, ConversationMetadata,
30    HistoryEntry, Message, MessageContent, MessageRole, ToolResultContent, ToolResultRef,
31    ToolUseRef, Usage,
32};
33#[cfg(feature = "watcher")]
34pub use watcher::ConversationWatcher;
35pub use writer::ConversationWriter;
36
37/// High-level interface for reading Claude conversations.
38///
39/// This is the primary entry point for most use cases. It provides
40/// convenient methods for reading conversations, listing projects,
41/// and accessing conversation history.
42///
43/// **Chain-default:** `read_conversation` and `list_conversations` operate
44/// on logical conversations (merged session chains). Use `read_segment`
45/// and `list_segments` for single-file access.
46///
47/// # Example
48///
49/// ```rust,no_run
50/// use toolpath_claude::ClaudeConvo;
51///
52/// let manager = ClaudeConvo::new();
53///
54/// // List all projects
55/// let projects = manager.list_projects()?;
56///
57/// // Read a conversation (follows session chains automatically)
58/// let convo = manager.read_conversation(
59///     "/Users/alex/project",
60///     "session-uuid"
61/// )?;
62///
63/// println!("Conversation has {} messages", convo.message_count());
64/// # Ok::<(), toolpath_claude::ConvoError>(())
65/// ```
66#[derive(Debug)]
67pub struct ClaudeConvo {
68    io: ConvoIO,
69    chain_cache: std::cell::RefCell<std::collections::HashMap<String, chain::ChainIndex>>,
70}
71
72impl Clone for ClaudeConvo {
73    fn clone(&self) -> Self {
74        Self {
75            io: self.io.clone(),
76            chain_cache: std::cell::RefCell::new(self.chain_cache.borrow().clone()),
77        }
78    }
79}
80
81impl Default for ClaudeConvo {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl ClaudeConvo {
88    /// Creates a new ClaudeConvo manager with default path resolution.
89    pub fn new() -> Self {
90        Self {
91            io: ConvoIO::new(),
92            chain_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
93        }
94    }
95
96    /// Creates a ClaudeConvo manager with a custom path resolver.
97    ///
98    /// This is useful for testing or when working with non-standard paths.
99    ///
100    /// # Example
101    ///
102    /// ```rust
103    /// use toolpath_claude::{ClaudeConvo, PathResolver};
104    ///
105    /// let resolver = PathResolver::new()
106    ///     .with_home("/custom/home")
107    ///     .with_claude_dir("/custom/.claude");
108    ///
109    /// let manager = ClaudeConvo::with_resolver(resolver);
110    /// ```
111    pub fn with_resolver(resolver: PathResolver) -> Self {
112        Self {
113            io: ConvoIO::with_resolver(resolver),
114            chain_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
115        }
116    }
117
118    /// Returns a reference to the underlying ConvoIO.
119    pub fn io(&self) -> &ConvoIO {
120        &self.io
121    }
122
123    /// Returns a reference to the path resolver.
124    pub fn resolver(&self) -> &PathResolver {
125        self.io.resolver()
126    }
127
128    /// Reads a conversation by project path and session ID.
129    ///
130    /// **Chain-aware:** if this session is part of a chain (file rotation),
131    /// all segments are merged into a single `Conversation` with bridge
132    /// entries filtered out and `segment_ids` populated.
133    ///
134    /// Use [`Self::read_segment`] for single-file access.
135    pub fn read_conversation(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
136        let chain = self.chain_for(project_path, session_id)?;
137
138        if chain.len() <= 1 {
139            return self.io.read_conversation(project_path, session_id);
140        }
141
142        // Multi-segment: merge all segments
143        let head = &chain[0];
144        let mut merged = Conversation::new(head.clone());
145
146        for segment_id in &chain {
147            let convo = self.io.read_conversation(project_path, segment_id)?;
148
149            if merged.started_at.is_none() {
150                merged.started_at = convo.started_at;
151            }
152            merged.last_activity = convo.last_activity.or(merged.last_activity);
153            if merged.project_path.is_none() {
154                merged.project_path = convo.project_path.clone();
155            }
156
157            // Bridge entries are the predecessor's tail copied to the TOP of
158            // a successor file — skip only that leading run. A foreign
159            // `sessionId` deeper in the segment is data, not a bridge; the
160            // old every-entry filter could erase a whole segment's turns and
161            // usage when all its entries carried one (the `.orphaned-*`
162            // failure shape).
163            let mut past_bridge = false;
164            for entry in &convo.entries {
165                if !past_bridge && chain::is_bridge_entry(entry, segment_id) {
166                    continue;
167                }
168                past_bridge = true;
169                merged.add_entry(entry.clone());
170            }
171        }
172
173        merged.segment_ids = chain;
174        Ok(merged)
175    }
176
177    /// Reads conversation metadata without loading the full content.
178    ///
179    /// **Chain-aware:** aggregates `message_count` (sum), `started_at`
180    /// (earliest), and `last_activity` (latest) across all segments.
181    pub fn read_conversation_metadata(
182        &self,
183        project_path: &str,
184        session_id: &str,
185    ) -> Result<ConversationMetadata> {
186        let chain = self.chain_for(project_path, session_id)?;
187
188        if chain.len() <= 1 {
189            return self.io.read_conversation_metadata(project_path, session_id);
190        }
191
192        let head = &chain[0];
193        let mut total_messages = 0usize;
194        let mut started_at = None;
195        let mut last_activity = None;
196        let mut file_path = std::path::PathBuf::new();
197        let mut first_user_message: Option<String> = None;
198
199        for (i, segment_id) in chain.iter().enumerate() {
200            let meta = self
201                .io
202                .read_conversation_metadata(project_path, segment_id)?;
203            total_messages += meta.message_count;
204
205            if started_at.is_none() || meta.started_at < started_at {
206                started_at = meta.started_at;
207            }
208            if last_activity.is_none() || meta.last_activity > last_activity {
209                last_activity = meta.last_activity;
210            }
211            if i == 0 {
212                file_path = meta.file_path;
213            }
214            // Chain is oldest-first; keep the first non-empty user prompt.
215            if first_user_message.is_none() && meta.first_user_message.is_some() {
216                first_user_message = meta.first_user_message;
217            }
218        }
219
220        Ok(ConversationMetadata {
221            session_id: head.clone(),
222            project_path: project_path.to_string(),
223            file_path,
224            message_count: total_messages,
225            started_at,
226            last_activity,
227            first_user_message,
228        })
229    }
230
231    /// Lists logical conversation IDs for a project (chain heads only).
232    ///
233    /// Chained sessions collapse to a single entry (the head).
234    /// Use [`Self::list_segments`] for all file stems.
235    pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
236        self.chain_heads(project_path)
237    }
238
239    /// Lists metadata for all logical conversations in a project.
240    ///
241    /// Chain heads only. Results are sorted by last activity (most recent first).
242    pub fn list_conversation_metadata(
243        &self,
244        project_path: &str,
245    ) -> Result<Vec<ConversationMetadata>> {
246        let heads = self.chain_heads(project_path)?;
247        let mut metadata = Vec::new();
248
249        for session_id in heads {
250            match self.read_conversation_metadata(project_path, &session_id) {
251                Ok(meta) => metadata.push(meta),
252                Err(e) => {
253                    eprintln!("Warning: Failed to read metadata for {}: {}", session_id, e);
254                }
255            }
256        }
257
258        metadata.sort_by_key(|m| std::cmp::Reverse(m.last_activity));
259        Ok(metadata)
260    }
261
262    // ── Single-file access (opt-in) ──────────────────────────────────
263
264    /// Reads a single JSONL file without following chains.
265    pub fn read_segment(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
266        self.io.read_conversation(project_path, session_id)
267    }
268
269    /// Lists all file stems (including successor segments).
270    pub fn list_segments(&self, project_path: &str) -> Result<Vec<String>> {
271        self.io.list_conversations(project_path)
272    }
273
274    /// Lists all projects that have conversations.
275    ///
276    /// Returns the original project paths (e.g., "/Users/alex/project").
277    pub fn list_projects(&self) -> Result<Vec<String>> {
278        self.io.list_projects()
279    }
280
281    /// Reads the global history file.
282    ///
283    /// The history file contains a record of all queries across all projects.
284    pub fn read_history(&self) -> Result<Vec<HistoryEntry>> {
285        self.io.read_history()
286    }
287
288    /// Checks if the Claude directory exists.
289    pub fn exists(&self) -> bool {
290        self.io.exists()
291    }
292
293    /// Returns the path to the Claude directory.
294    pub fn claude_dir_path(&self) -> Result<std::path::PathBuf> {
295        self.io.claude_dir_path()
296    }
297
298    /// Checks if a specific conversation exists.
299    pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result<bool> {
300        self.io.conversation_exists(project_path, session_id)
301    }
302
303    /// Checks if a project directory exists.
304    pub fn project_exists(&self, project_path: &str) -> bool {
305        self.io.project_exists(project_path)
306    }
307
308    /// Creates a query builder for a conversation.
309    pub fn query<'a>(&self, conversation: &'a Conversation) -> ConversationQuery<'a> {
310        ConversationQuery::new(conversation)
311    }
312
313    /// Creates a query builder for history entries.
314    pub fn query_history<'a>(&self, history: &'a [HistoryEntry]) -> HistoryQuery<'a> {
315        HistoryQuery::new(history)
316    }
317
318    /// Reads all conversations for a project.
319    ///
320    /// Returns a vector of conversations sorted by last activity.
321    pub fn read_all_conversations(&self, project_path: &str) -> Result<Vec<Conversation>> {
322        let session_ids = self.list_conversations(project_path)?;
323        let mut conversations = Vec::new();
324
325        for session_id in session_ids {
326            match self.read_conversation(project_path, &session_id) {
327                Ok(convo) => conversations.push(convo),
328                Err(e) => {
329                    eprintln!("Warning: Failed to read conversation {}: {}", session_id, e);
330                }
331            }
332        }
333
334        conversations.sort_by_key(|c| std::cmp::Reverse(c.last_activity));
335        Ok(conversations)
336    }
337
338    /// Gets the most recent conversation for a project.
339    pub fn most_recent_conversation(&self, project_path: &str) -> Result<Option<Conversation>> {
340        let metadata = self.list_conversation_metadata(project_path)?;
341
342        if let Some(latest) = metadata.first() {
343            Ok(Some(
344                self.read_conversation(project_path, &latest.session_id)?,
345            ))
346        } else {
347            Ok(None)
348        }
349    }
350
351    /// Resolves the full session chain containing `session_id`, returned
352    /// in chronological order (oldest segment first).
353    ///
354    /// For single-segment sessions, returns `[session_id]`. This is the
355    /// set of files [`Self::read_conversation`] would merge — appends
356    /// land in the *last* segment while the chain keeps the first
357    /// segment's id, so anything fingerprinting a conversation must
358    /// stat every segment, not the head file alone. Uses the same
359    /// cached chain index as the other chain surfaces; after a
360    /// [`Self::list_conversations`] call it costs no extra IO.
361    pub fn session_chain(&self, project_path: &str, session_id: &str) -> Result<Vec<String>> {
362        self.chain_for(project_path, session_id)
363    }
364
365    /// Returns the chain head (earliest segment) for `session_id`.
366    ///
367    /// For single-segment sessions, returns `session_id` unchanged.
368    #[allow(dead_code)]
369    pub(crate) fn chain_head(&self, project_path: &str, session_id: &str) -> Result<String> {
370        let chain = self.session_chain(project_path, session_id)?;
371        Ok(chain
372            .into_iter()
373            .next()
374            .unwrap_or_else(|| session_id.to_string()))
375    }
376
377    // ── Private helpers ──────────────────────────────────────────────
378
379    /// Refresh the chain index for `project_path` and resolve the chain
380    /// for `session_id`. RefCell borrow is scoped internally.
381    fn chain_for(&self, project_path: &str, session_id: &str) -> Result<Vec<String>> {
382        let mut cache = self.chain_cache.borrow_mut();
383        let index = cache
384            .entry(project_path.to_string())
385            .or_insert_with(chain::ChainIndex::new);
386        index.refresh(self.resolver(), project_path)?;
387        Ok(index.resolve_chain(session_id))
388    }
389
390    /// Refresh the chain index and return chain heads.
391    fn chain_heads(&self, project_path: &str) -> Result<Vec<String>> {
392        let mut cache = self.chain_cache.borrow_mut();
393        let index = cache
394            .entry(project_path.to_string())
395            .or_insert_with(chain::ChainIndex::new);
396        index.refresh(self.resolver(), project_path)?;
397        Ok(index.chain_heads())
398    }
399
400    /// Finds conversations that contain specific text.
401    pub fn find_conversations_with_text(
402        &self,
403        project_path: &str,
404        search_text: &str,
405    ) -> Result<Vec<Conversation>> {
406        let conversations = self.read_all_conversations(project_path)?;
407
408        Ok(conversations
409            .into_iter()
410            .filter(|convo| {
411                let query = ConversationQuery::new(convo);
412                !query.contains_text(search_text).is_empty()
413            })
414            .collect())
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use std::fs;
422    use tempfile::TempDir;
423
424    fn setup_test_manager() -> (TempDir, ClaudeConvo) {
425        let temp = TempDir::new().unwrap();
426        let claude_dir = temp.path().join(".claude");
427        fs::create_dir_all(claude_dir.join("projects/-test-project")).unwrap();
428
429        let resolver = PathResolver::new().with_claude_dir(claude_dir);
430        let manager = ClaudeConvo::with_resolver(resolver);
431
432        (temp, manager)
433    }
434
435    #[test]
436    fn test_basic_setup() {
437        let (_temp, manager) = setup_test_manager();
438        assert!(manager.exists());
439    }
440
441    #[test]
442    fn test_list_projects() {
443        let (_temp, manager) = setup_test_manager();
444        let projects = manager.list_projects().unwrap();
445        assert_eq!(projects.len(), 1);
446        assert_eq!(projects[0], "/test/project");
447    }
448
449    #[test]
450    fn test_project_exists() {
451        let (_temp, manager) = setup_test_manager();
452        assert!(manager.project_exists("/test/project"));
453        assert!(!manager.project_exists("/nonexistent"));
454    }
455
456    fn setup_test_with_conversation() -> (TempDir, ClaudeConvo) {
457        let temp = TempDir::new().unwrap();
458        let claude_dir = temp.path().join(".claude");
459        let project_dir = claude_dir.join("projects/-test-project");
460        fs::create_dir_all(&project_dir).unwrap();
461
462        let entry1 = r#"{"type":"user","uuid":"uuid-1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
463        let entry2 = r#"{"type":"assistant","uuid":"uuid-2","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there"}}"#;
464        fs::write(
465            project_dir.join("session-abc.jsonl"),
466            format!("{}\n{}\n", entry1, entry2),
467        )
468        .unwrap();
469
470        let resolver = PathResolver::new().with_claude_dir(claude_dir);
471        let manager = ClaudeConvo::with_resolver(resolver);
472        (temp, manager)
473    }
474
475    #[test]
476    fn test_read_conversation() {
477        let (_temp, manager) = setup_test_with_conversation();
478        let convo = manager
479            .read_conversation("/test/project", "session-abc")
480            .unwrap();
481        assert_eq!(convo.entries.len(), 2);
482        assert_eq!(convo.message_count(), 2);
483    }
484
485    #[test]
486    fn test_read_conversation_metadata() {
487        let (_temp, manager) = setup_test_with_conversation();
488        let meta = manager
489            .read_conversation_metadata("/test/project", "session-abc")
490            .unwrap();
491        assert_eq!(meta.message_count, 2);
492        assert_eq!(meta.session_id, "session-abc");
493    }
494
495    #[test]
496    fn test_list_conversations() {
497        let (_temp, manager) = setup_test_with_conversation();
498        let sessions = manager.list_conversations("/test/project").unwrap();
499        assert_eq!(sessions.len(), 1);
500        assert_eq!(sessions[0], "session-abc");
501    }
502
503    #[test]
504    fn test_list_conversation_metadata() {
505        let (_temp, manager) = setup_test_with_conversation();
506        let metadata = manager.list_conversation_metadata("/test/project").unwrap();
507        assert_eq!(metadata.len(), 1);
508        assert_eq!(metadata[0].session_id, "session-abc");
509    }
510
511    #[test]
512    fn test_conversation_exists() {
513        let (_temp, manager) = setup_test_with_conversation();
514        assert!(
515            manager
516                .conversation_exists("/test/project", "session-abc")
517                .unwrap()
518        );
519        assert!(
520            !manager
521                .conversation_exists("/test/project", "nonexistent")
522                .unwrap()
523        );
524    }
525
526    #[test]
527    fn test_io_accessor() {
528        let (_temp, manager) = setup_test_with_conversation();
529        assert!(manager.io().exists());
530    }
531
532    #[test]
533    fn test_resolver_accessor() {
534        let (_temp, manager) = setup_test_with_conversation();
535        assert!(manager.resolver().exists());
536    }
537
538    #[test]
539    fn test_claude_dir_path() {
540        let (_temp, manager) = setup_test_with_conversation();
541        let path = manager.claude_dir_path().unwrap();
542        assert!(path.exists());
543    }
544
545    #[test]
546    fn test_read_all_conversations() {
547        let (_temp, manager) = setup_test_with_conversation();
548        let convos = manager.read_all_conversations("/test/project").unwrap();
549        assert_eq!(convos.len(), 1);
550    }
551
552    #[test]
553    fn test_most_recent_conversation() {
554        let (_temp, manager) = setup_test_with_conversation();
555        let convo = manager.most_recent_conversation("/test/project").unwrap();
556        assert!(convo.is_some());
557    }
558
559    #[test]
560    fn test_most_recent_conversation_empty() {
561        let (_temp, manager) = setup_test_manager();
562        // No conversations in this project
563        let convo = manager.most_recent_conversation("/test/project").unwrap();
564        assert!(convo.is_none());
565    }
566
567    #[test]
568    fn test_find_conversations_with_text() {
569        let (_temp, manager) = setup_test_with_conversation();
570        let results = manager
571            .find_conversations_with_text("/test/project", "Hello")
572            .unwrap();
573        assert_eq!(results.len(), 1);
574
575        let no_results = manager
576            .find_conversations_with_text("/test/project", "nonexistent text xyz")
577            .unwrap();
578        assert!(no_results.is_empty());
579    }
580
581    #[test]
582    fn test_query_helper() {
583        let (_temp, manager) = setup_test_with_conversation();
584        let convo = manager
585            .read_conversation("/test/project", "session-abc")
586            .unwrap();
587        let q = manager.query(&convo);
588        let users = q.by_role(MessageRole::User);
589        assert_eq!(users.len(), 1);
590    }
591
592    #[test]
593    fn test_query_history_helper() {
594        let (_temp, manager) = setup_test_manager();
595        let history: Vec<HistoryEntry> = vec![];
596        let q = manager.query_history(&history);
597        let results = q.recent(5);
598        assert!(results.is_empty());
599    }
600
601    #[test]
602    fn test_read_history_no_file() {
603        let (_temp, manager) = setup_test_manager();
604        let history = manager.read_history().unwrap();
605        assert!(history.is_empty());
606    }
607
608    #[test]
609    fn test_default_impl() {
610        // Test that Default trait works
611        let _manager = ClaudeConvo::default();
612    }
613
614    // ── Session chain convenience methods ────────────────────────────
615
616    fn setup_chained_conversations() -> (TempDir, ClaudeConvo) {
617        let temp = TempDir::new().unwrap();
618        let claude_dir = temp.path().join(".claude");
619        let project_dir = claude_dir.join("projects/-test-project");
620        fs::create_dir_all(&project_dir).unwrap();
621
622        // session-a: standalone start
623        fs::write(
624            project_dir.join("session-a.jsonl"),
625            r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Start"}}"#,
626        ).unwrap();
627
628        // session-b: successor of a (bridge entry points to a)
629        let b = [
630            r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
631            r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"Middle"}}"#,
632        ];
633        fs::write(project_dir.join("session-b.jsonl"), b.join("\n")).unwrap();
634
635        // session-c: successor of b
636        let c = [
637            r#"{"uuid":"c0","type":"user","timestamp":"2024-01-01T02:00:00Z","sessionId":"session-b","message":{"role":"user","content":"Bridge"}}"#,
638            r#"{"uuid":"c1","type":"user","timestamp":"2024-01-01T02:00:01Z","sessionId":"session-c","message":{"role":"user","content":"End"}}"#,
639        ];
640        fs::write(project_dir.join("session-c.jsonl"), c.join("\n")).unwrap();
641
642        let resolver = PathResolver::new().with_claude_dir(claude_dir);
643        (temp, ClaudeConvo::with_resolver(resolver))
644    }
645
646    #[test]
647    fn test_session_chain_full() {
648        let (_temp, manager) = setup_chained_conversations();
649        let chain = manager.session_chain("/test/project", "session-a").unwrap();
650        assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
651    }
652
653    #[test]
654    fn test_session_chain_from_middle() {
655        let (_temp, manager) = setup_chained_conversations();
656        let chain = manager.session_chain("/test/project", "session-b").unwrap();
657        assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
658    }
659
660    #[test]
661    fn test_session_chain_single() {
662        let (_temp, manager) = setup_test_with_conversation();
663        let chain = manager
664            .session_chain("/test/project", "session-abc")
665            .unwrap();
666        assert_eq!(chain, vec!["session-abc"]);
667    }
668
669    #[test]
670    fn test_chain_head_from_tail() {
671        let (_temp, manager) = setup_chained_conversations();
672        let head = manager.chain_head("/test/project", "session-c").unwrap();
673        assert_eq!(head, "session-a");
674    }
675
676    #[test]
677    fn test_chain_head_already_head() {
678        let (_temp, manager) = setup_chained_conversations();
679        let head = manager.chain_head("/test/project", "session-a").unwrap();
680        assert_eq!(head, "session-a");
681    }
682
683    #[test]
684    fn test_chain_head_single_session() {
685        let (_temp, manager) = setup_test_with_conversation();
686        let head = manager.chain_head("/test/project", "session-abc").unwrap();
687        assert_eq!(head, "session-abc");
688    }
689
690    // ── Chain-default API tests ──────────────────────────────────────
691
692    #[test]
693    fn test_read_conversation_follows_chain() {
694        let (_temp, manager) = setup_chained_conversations();
695
696        // Reading from any segment returns the full merged conversation
697        let convo = manager
698            .read_conversation("/test/project", "session-a")
699            .unwrap();
700        assert_eq!(convo.session_id, "session-a");
701        assert_eq!(
702            convo.segment_ids,
703            vec!["session-a", "session-b", "session-c"]
704        );
705        // a1, b1, c1 (bridge entries b0 and c0 filtered out)
706        assert_eq!(convo.entries.len(), 3);
707        assert_eq!(convo.entries[0].uuid, "a1");
708        assert_eq!(convo.entries[1].uuid, "b1");
709        assert_eq!(convo.entries[2].uuid, "c1");
710
711        // From the middle
712        let convo_b = manager
713            .read_conversation("/test/project", "session-b")
714            .unwrap();
715        assert_eq!(
716            convo_b.segment_ids,
717            vec!["session-a", "session-b", "session-c"]
718        );
719        assert_eq!(convo_b.entries.len(), 3);
720
721        // From the tail
722        let convo_c = manager
723            .read_conversation("/test/project", "session-c")
724            .unwrap();
725        assert_eq!(convo_c.entries.len(), 3);
726    }
727
728    #[test]
729    fn test_bridge_skip_is_leading_run_only() {
730        // A foreign sessionId deeper in a segment is data, not a bridge —
731        // resumed sessions can interleave entries stamped with a prior id.
732        // Only the leading run at the top of a successor file is the copied
733        // predecessor tail.
734        let temp = TempDir::new().unwrap();
735        let claude_dir = temp.path().join(".claude");
736        let project_dir = claude_dir.join("projects/-test-project");
737        fs::create_dir_all(&project_dir).unwrap();
738
739        fs::write(
740            project_dir.join("session-a.jsonl"),
741            r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Start"}}"#,
742        ).unwrap();
743        let b = [
744            r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
745            r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"Own"}}"#,
746            r#"{"uuid":"b2","type":"user","timestamp":"2024-01-01T01:00:02Z","sessionId":"session-a","message":{"role":"user","content":"Foreign but kept"}}"#,
747        ];
748        fs::write(project_dir.join("session-b.jsonl"), b.join("\n")).unwrap();
749
750        let resolver = PathResolver::new().with_claude_dir(claude_dir);
751        let manager = ClaudeConvo::with_resolver(resolver);
752        let convo = manager
753            .read_conversation("/test/project", "session-a")
754            .unwrap();
755
756        // b0 (leading bridge) filtered; b2 (mid-segment foreign id) kept.
757        let uuids: Vec<&str> = convo.entries.iter().map(|e| e.uuid.as_str()).collect();
758        assert_eq!(uuids, vec!["a1", "b1", "b2"]);
759    }
760
761    #[test]
762    fn test_list_conversations_returns_chain_heads() {
763        let (_temp, manager) = setup_chained_conversations();
764
765        let sessions = manager.list_conversations("/test/project").unwrap();
766        // Three files but only one chain head
767        assert_eq!(sessions.len(), 1);
768        assert!(sessions.contains(&"session-a".to_string()));
769    }
770
771    #[test]
772    fn test_read_segment_single_file() {
773        let (_temp, manager) = setup_chained_conversations();
774
775        // read_segment returns only the single file, not merged
776        let segment = manager.read_segment("/test/project", "session-b").unwrap();
777        assert_eq!(segment.session_id, "session-b");
778        assert_eq!(segment.entries.len(), 2); // b0 (bridge) + b1
779        assert!(segment.segment_ids.is_empty());
780    }
781
782    #[test]
783    fn test_list_segments_returns_all() {
784        let (_temp, manager) = setup_chained_conversations();
785
786        let mut segments = manager.list_segments("/test/project").unwrap();
787        segments.sort();
788        assert_eq!(segments, vec!["session-a", "session-b", "session-c"]);
789    }
790
791    #[test]
792    fn test_read_conversation_metadata_aggregates_chain() {
793        let (_temp, manager) = setup_chained_conversations();
794
795        let meta = manager
796            .read_conversation_metadata("/test/project", "session-a")
797            .unwrap();
798        assert_eq!(meta.session_id, "session-a");
799        // a: 1 msg, b: 2 msgs, c: 2 msgs = 5 total
800        assert_eq!(meta.message_count, 5);
801        // started_at from first segment, last_activity from last
802        assert!(meta.started_at.is_some());
803        assert!(meta.last_activity.is_some());
804        assert!(meta.last_activity > meta.started_at);
805    }
806}