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