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 project_path_val = String::new();
186        let mut file_path = std::path::PathBuf::new();
187        let mut first_user_message: Option<String> = None;
188
189        for (i, segment_id) in chain.iter().enumerate() {
190            let meta = self
191                .io
192                .read_conversation_metadata(project_path, segment_id)?;
193            total_messages += meta.message_count;
194
195            if started_at.is_none() || meta.started_at < started_at {
196                started_at = meta.started_at;
197            }
198            if last_activity.is_none() || meta.last_activity > last_activity {
199                last_activity = meta.last_activity;
200            }
201            if project_path_val.is_empty() {
202                project_path_val = meta.project_path;
203            }
204            if i == 0 {
205                file_path = meta.file_path;
206            }
207            // Chain is oldest-first; keep the first non-empty user prompt.
208            if first_user_message.is_none() && meta.first_user_message.is_some() {
209                first_user_message = meta.first_user_message;
210            }
211        }
212
213        Ok(ConversationMetadata {
214            session_id: head.clone(),
215            project_path: project_path_val,
216            file_path,
217            message_count: total_messages,
218            started_at,
219            last_activity,
220            first_user_message,
221        })
222    }
223
224    /// Lists logical conversation IDs for a project (chain heads only).
225    ///
226    /// Chained sessions collapse to a single entry (the head).
227    /// Use [`Self::list_segments`] for all file stems.
228    pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
229        self.chain_heads(project_path)
230    }
231
232    /// Lists metadata for all logical conversations in a project.
233    ///
234    /// Chain heads only. Results are sorted by last activity (most recent first).
235    pub fn list_conversation_metadata(
236        &self,
237        project_path: &str,
238    ) -> Result<Vec<ConversationMetadata>> {
239        let heads = self.chain_heads(project_path)?;
240        let mut metadata = Vec::new();
241
242        for session_id in heads {
243            match self.read_conversation_metadata(project_path, &session_id) {
244                Ok(meta) => metadata.push(meta),
245                Err(e) => {
246                    eprintln!("Warning: Failed to read metadata for {}: {}", session_id, e);
247                }
248            }
249        }
250
251        metadata.sort_by_key(|m| std::cmp::Reverse(m.last_activity));
252        Ok(metadata)
253    }
254
255    // ── Single-file access (opt-in) ──────────────────────────────────
256
257    /// Reads a single JSONL file without following chains.
258    pub fn read_segment(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
259        self.io.read_conversation(project_path, session_id)
260    }
261
262    /// Lists all file stems (including successor segments).
263    pub fn list_segments(&self, project_path: &str) -> Result<Vec<String>> {
264        self.io.list_conversations(project_path)
265    }
266
267    /// Lists all projects that have conversations.
268    ///
269    /// Returns the original project paths (e.g., "/Users/alex/project").
270    pub fn list_projects(&self) -> Result<Vec<String>> {
271        self.io.list_projects()
272    }
273
274    /// Reads the global history file.
275    ///
276    /// The history file contains a record of all queries across all projects.
277    pub fn read_history(&self) -> Result<Vec<HistoryEntry>> {
278        self.io.read_history()
279    }
280
281    /// Checks if the Claude directory exists.
282    pub fn exists(&self) -> bool {
283        self.io.exists()
284    }
285
286    /// Returns the path to the Claude directory.
287    pub fn claude_dir_path(&self) -> Result<std::path::PathBuf> {
288        self.io.claude_dir_path()
289    }
290
291    /// Checks if a specific conversation exists.
292    pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result<bool> {
293        self.io.conversation_exists(project_path, session_id)
294    }
295
296    /// Checks if a project directory exists.
297    pub fn project_exists(&self, project_path: &str) -> bool {
298        self.io.project_exists(project_path)
299    }
300
301    /// Creates a query builder for a conversation.
302    pub fn query<'a>(&self, conversation: &'a Conversation) -> ConversationQuery<'a> {
303        ConversationQuery::new(conversation)
304    }
305
306    /// Creates a query builder for history entries.
307    pub fn query_history<'a>(&self, history: &'a [HistoryEntry]) -> HistoryQuery<'a> {
308        HistoryQuery::new(history)
309    }
310
311    /// Reads all conversations for a project.
312    ///
313    /// Returns a vector of conversations sorted by last activity.
314    pub fn read_all_conversations(&self, project_path: &str) -> Result<Vec<Conversation>> {
315        let session_ids = self.list_conversations(project_path)?;
316        let mut conversations = Vec::new();
317
318        for session_id in session_ids {
319            match self.read_conversation(project_path, &session_id) {
320                Ok(convo) => conversations.push(convo),
321                Err(e) => {
322                    eprintln!("Warning: Failed to read conversation {}: {}", session_id, e);
323                }
324            }
325        }
326
327        conversations.sort_by_key(|c| std::cmp::Reverse(c.last_activity));
328        Ok(conversations)
329    }
330
331    /// Gets the most recent conversation for a project.
332    pub fn most_recent_conversation(&self, project_path: &str) -> Result<Option<Conversation>> {
333        let metadata = self.list_conversation_metadata(project_path)?;
334
335        if let Some(latest) = metadata.first() {
336            Ok(Some(
337                self.read_conversation(project_path, &latest.session_id)?,
338            ))
339        } else {
340            Ok(None)
341        }
342    }
343
344    /// Resolves the full session chain containing `session_id`, returned
345    /// in chronological order (oldest segment first).
346    ///
347    /// For single-segment sessions, returns `[session_id]`.
348    #[allow(dead_code)]
349    pub(crate) fn session_chain(
350        &self,
351        project_path: &str,
352        session_id: &str,
353    ) -> Result<Vec<String>> {
354        self.chain_for(project_path, session_id)
355    }
356
357    /// Returns the chain head (earliest segment) for `session_id`.
358    ///
359    /// For single-segment sessions, returns `session_id` unchanged.
360    #[allow(dead_code)]
361    pub(crate) fn chain_head(&self, project_path: &str, session_id: &str) -> Result<String> {
362        let chain = self.session_chain(project_path, session_id)?;
363        Ok(chain
364            .into_iter()
365            .next()
366            .unwrap_or_else(|| session_id.to_string()))
367    }
368
369    // ── Private helpers ──────────────────────────────────────────────
370
371    /// Refresh the chain index for `project_path` and resolve the chain
372    /// for `session_id`. RefCell borrow is scoped internally.
373    fn chain_for(&self, project_path: &str, session_id: &str) -> Result<Vec<String>> {
374        let mut cache = self.chain_cache.borrow_mut();
375        let index = cache
376            .entry(project_path.to_string())
377            .or_insert_with(chain::ChainIndex::new);
378        index.refresh(self.resolver(), project_path)?;
379        Ok(index.resolve_chain(session_id))
380    }
381
382    /// Refresh the chain index and return chain heads.
383    fn chain_heads(&self, project_path: &str) -> Result<Vec<String>> {
384        let mut cache = self.chain_cache.borrow_mut();
385        let index = cache
386            .entry(project_path.to_string())
387            .or_insert_with(chain::ChainIndex::new);
388        index.refresh(self.resolver(), project_path)?;
389        Ok(index.chain_heads())
390    }
391
392    /// Finds conversations that contain specific text.
393    pub fn find_conversations_with_text(
394        &self,
395        project_path: &str,
396        search_text: &str,
397    ) -> Result<Vec<Conversation>> {
398        let conversations = self.read_all_conversations(project_path)?;
399
400        Ok(conversations
401            .into_iter()
402            .filter(|convo| {
403                let query = ConversationQuery::new(convo);
404                !query.contains_text(search_text).is_empty()
405            })
406            .collect())
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::fs;
414    use tempfile::TempDir;
415
416    fn setup_test_manager() -> (TempDir, ClaudeConvo) {
417        let temp = TempDir::new().unwrap();
418        let claude_dir = temp.path().join(".claude");
419        fs::create_dir_all(claude_dir.join("projects/-test-project")).unwrap();
420
421        let resolver = PathResolver::new().with_claude_dir(claude_dir);
422        let manager = ClaudeConvo::with_resolver(resolver);
423
424        (temp, manager)
425    }
426
427    #[test]
428    fn test_basic_setup() {
429        let (_temp, manager) = setup_test_manager();
430        assert!(manager.exists());
431    }
432
433    #[test]
434    fn test_list_projects() {
435        let (_temp, manager) = setup_test_manager();
436        let projects = manager.list_projects().unwrap();
437        assert_eq!(projects.len(), 1);
438        assert_eq!(projects[0], "/test/project");
439    }
440
441    #[test]
442    fn test_project_exists() {
443        let (_temp, manager) = setup_test_manager();
444        assert!(manager.project_exists("/test/project"));
445        assert!(!manager.project_exists("/nonexistent"));
446    }
447
448    fn setup_test_with_conversation() -> (TempDir, ClaudeConvo) {
449        let temp = TempDir::new().unwrap();
450        let claude_dir = temp.path().join(".claude");
451        let project_dir = claude_dir.join("projects/-test-project");
452        fs::create_dir_all(&project_dir).unwrap();
453
454        let entry1 = r#"{"type":"user","uuid":"uuid-1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
455        let entry2 = r#"{"type":"assistant","uuid":"uuid-2","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there"}}"#;
456        fs::write(
457            project_dir.join("session-abc.jsonl"),
458            format!("{}\n{}\n", entry1, entry2),
459        )
460        .unwrap();
461
462        let resolver = PathResolver::new().with_claude_dir(claude_dir);
463        let manager = ClaudeConvo::with_resolver(resolver);
464        (temp, manager)
465    }
466
467    #[test]
468    fn test_read_conversation() {
469        let (_temp, manager) = setup_test_with_conversation();
470        let convo = manager
471            .read_conversation("/test/project", "session-abc")
472            .unwrap();
473        assert_eq!(convo.entries.len(), 2);
474        assert_eq!(convo.message_count(), 2);
475    }
476
477    #[test]
478    fn test_read_conversation_metadata() {
479        let (_temp, manager) = setup_test_with_conversation();
480        let meta = manager
481            .read_conversation_metadata("/test/project", "session-abc")
482            .unwrap();
483        assert_eq!(meta.message_count, 2);
484        assert_eq!(meta.session_id, "session-abc");
485    }
486
487    #[test]
488    fn test_list_conversations() {
489        let (_temp, manager) = setup_test_with_conversation();
490        let sessions = manager.list_conversations("/test/project").unwrap();
491        assert_eq!(sessions.len(), 1);
492        assert_eq!(sessions[0], "session-abc");
493    }
494
495    #[test]
496    fn test_list_conversation_metadata() {
497        let (_temp, manager) = setup_test_with_conversation();
498        let metadata = manager.list_conversation_metadata("/test/project").unwrap();
499        assert_eq!(metadata.len(), 1);
500        assert_eq!(metadata[0].session_id, "session-abc");
501    }
502
503    #[test]
504    fn test_conversation_exists() {
505        let (_temp, manager) = setup_test_with_conversation();
506        assert!(
507            manager
508                .conversation_exists("/test/project", "session-abc")
509                .unwrap()
510        );
511        assert!(
512            !manager
513                .conversation_exists("/test/project", "nonexistent")
514                .unwrap()
515        );
516    }
517
518    #[test]
519    fn test_io_accessor() {
520        let (_temp, manager) = setup_test_with_conversation();
521        assert!(manager.io().exists());
522    }
523
524    #[test]
525    fn test_resolver_accessor() {
526        let (_temp, manager) = setup_test_with_conversation();
527        assert!(manager.resolver().exists());
528    }
529
530    #[test]
531    fn test_claude_dir_path() {
532        let (_temp, manager) = setup_test_with_conversation();
533        let path = manager.claude_dir_path().unwrap();
534        assert!(path.exists());
535    }
536
537    #[test]
538    fn test_read_all_conversations() {
539        let (_temp, manager) = setup_test_with_conversation();
540        let convos = manager.read_all_conversations("/test/project").unwrap();
541        assert_eq!(convos.len(), 1);
542    }
543
544    #[test]
545    fn test_most_recent_conversation() {
546        let (_temp, manager) = setup_test_with_conversation();
547        let convo = manager.most_recent_conversation("/test/project").unwrap();
548        assert!(convo.is_some());
549    }
550
551    #[test]
552    fn test_most_recent_conversation_empty() {
553        let (_temp, manager) = setup_test_manager();
554        // No conversations in this project
555        let convo = manager.most_recent_conversation("/test/project").unwrap();
556        assert!(convo.is_none());
557    }
558
559    #[test]
560    fn test_find_conversations_with_text() {
561        let (_temp, manager) = setup_test_with_conversation();
562        let results = manager
563            .find_conversations_with_text("/test/project", "Hello")
564            .unwrap();
565        assert_eq!(results.len(), 1);
566
567        let no_results = manager
568            .find_conversations_with_text("/test/project", "nonexistent text xyz")
569            .unwrap();
570        assert!(no_results.is_empty());
571    }
572
573    #[test]
574    fn test_query_helper() {
575        let (_temp, manager) = setup_test_with_conversation();
576        let convo = manager
577            .read_conversation("/test/project", "session-abc")
578            .unwrap();
579        let q = manager.query(&convo);
580        let users = q.by_role(MessageRole::User);
581        assert_eq!(users.len(), 1);
582    }
583
584    #[test]
585    fn test_query_history_helper() {
586        let (_temp, manager) = setup_test_manager();
587        let history: Vec<HistoryEntry> = vec![];
588        let q = manager.query_history(&history);
589        let results = q.recent(5);
590        assert!(results.is_empty());
591    }
592
593    #[test]
594    fn test_read_history_no_file() {
595        let (_temp, manager) = setup_test_manager();
596        let history = manager.read_history().unwrap();
597        assert!(history.is_empty());
598    }
599
600    #[test]
601    fn test_default_impl() {
602        // Test that Default trait works
603        let _manager = ClaudeConvo::default();
604    }
605
606    // ── Session chain convenience methods ────────────────────────────
607
608    fn setup_chained_conversations() -> (TempDir, ClaudeConvo) {
609        let temp = TempDir::new().unwrap();
610        let claude_dir = temp.path().join(".claude");
611        let project_dir = claude_dir.join("projects/-test-project");
612        fs::create_dir_all(&project_dir).unwrap();
613
614        // session-a: standalone start
615        fs::write(
616            project_dir.join("session-a.jsonl"),
617            r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Start"}}"#,
618        ).unwrap();
619
620        // session-b: successor of a (bridge entry points to a)
621        let b = [
622            r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
623            r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"Middle"}}"#,
624        ];
625        fs::write(project_dir.join("session-b.jsonl"), b.join("\n")).unwrap();
626
627        // session-c: successor of b
628        let c = [
629            r#"{"uuid":"c0","type":"user","timestamp":"2024-01-01T02:00:00Z","sessionId":"session-b","message":{"role":"user","content":"Bridge"}}"#,
630            r#"{"uuid":"c1","type":"user","timestamp":"2024-01-01T02:00:01Z","sessionId":"session-c","message":{"role":"user","content":"End"}}"#,
631        ];
632        fs::write(project_dir.join("session-c.jsonl"), c.join("\n")).unwrap();
633
634        let resolver = PathResolver::new().with_claude_dir(claude_dir);
635        (temp, ClaudeConvo::with_resolver(resolver))
636    }
637
638    #[test]
639    fn test_session_chain_full() {
640        let (_temp, manager) = setup_chained_conversations();
641        let chain = manager.session_chain("/test/project", "session-a").unwrap();
642        assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
643    }
644
645    #[test]
646    fn test_session_chain_from_middle() {
647        let (_temp, manager) = setup_chained_conversations();
648        let chain = manager.session_chain("/test/project", "session-b").unwrap();
649        assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
650    }
651
652    #[test]
653    fn test_session_chain_single() {
654        let (_temp, manager) = setup_test_with_conversation();
655        let chain = manager
656            .session_chain("/test/project", "session-abc")
657            .unwrap();
658        assert_eq!(chain, vec!["session-abc"]);
659    }
660
661    #[test]
662    fn test_chain_head_from_tail() {
663        let (_temp, manager) = setup_chained_conversations();
664        let head = manager.chain_head("/test/project", "session-c").unwrap();
665        assert_eq!(head, "session-a");
666    }
667
668    #[test]
669    fn test_chain_head_already_head() {
670        let (_temp, manager) = setup_chained_conversations();
671        let head = manager.chain_head("/test/project", "session-a").unwrap();
672        assert_eq!(head, "session-a");
673    }
674
675    #[test]
676    fn test_chain_head_single_session() {
677        let (_temp, manager) = setup_test_with_conversation();
678        let head = manager.chain_head("/test/project", "session-abc").unwrap();
679        assert_eq!(head, "session-abc");
680    }
681
682    // ── Chain-default API tests ──────────────────────────────────────
683
684    #[test]
685    fn test_read_conversation_follows_chain() {
686        let (_temp, manager) = setup_chained_conversations();
687
688        // Reading from any segment returns the full merged conversation
689        let convo = manager
690            .read_conversation("/test/project", "session-a")
691            .unwrap();
692        assert_eq!(convo.session_id, "session-a");
693        assert_eq!(
694            convo.session_ids,
695            vec!["session-a", "session-b", "session-c"]
696        );
697        // a1, b1, c1 (bridge entries b0 and c0 filtered out)
698        assert_eq!(convo.entries.len(), 3);
699        assert_eq!(convo.entries[0].uuid, "a1");
700        assert_eq!(convo.entries[1].uuid, "b1");
701        assert_eq!(convo.entries[2].uuid, "c1");
702
703        // From the middle
704        let convo_b = manager
705            .read_conversation("/test/project", "session-b")
706            .unwrap();
707        assert_eq!(
708            convo_b.session_ids,
709            vec!["session-a", "session-b", "session-c"]
710        );
711        assert_eq!(convo_b.entries.len(), 3);
712
713        // From the tail
714        let convo_c = manager
715            .read_conversation("/test/project", "session-c")
716            .unwrap();
717        assert_eq!(convo_c.entries.len(), 3);
718    }
719
720    #[test]
721    fn test_list_conversations_returns_chain_heads() {
722        let (_temp, manager) = setup_chained_conversations();
723
724        let sessions = manager.list_conversations("/test/project").unwrap();
725        // Three files but only one chain head
726        assert_eq!(sessions.len(), 1);
727        assert!(sessions.contains(&"session-a".to_string()));
728    }
729
730    #[test]
731    fn test_read_segment_single_file() {
732        let (_temp, manager) = setup_chained_conversations();
733
734        // read_segment returns only the single file, not merged
735        let segment = manager.read_segment("/test/project", "session-b").unwrap();
736        assert_eq!(segment.session_id, "session-b");
737        assert_eq!(segment.entries.len(), 2); // b0 (bridge) + b1
738        assert!(segment.session_ids.is_empty());
739    }
740
741    #[test]
742    fn test_list_segments_returns_all() {
743        let (_temp, manager) = setup_chained_conversations();
744
745        let mut segments = manager.list_segments("/test/project").unwrap();
746        segments.sort();
747        assert_eq!(segments, vec!["session-a", "session-b", "session-c"]);
748    }
749
750    #[test]
751    fn test_read_conversation_metadata_aggregates_chain() {
752        let (_temp, manager) = setup_chained_conversations();
753
754        let meta = manager
755            .read_conversation_metadata("/test/project", "session-a")
756            .unwrap();
757        assert_eq!(meta.session_id, "session-a");
758        // a: 1 msg, b: 2 msgs, c: 2 msgs = 5 total
759        assert_eq!(meta.message_count, 5);
760        // started_at from first segment, last_activity from last
761        assert!(meta.started_at.is_some());
762        assert!(meta.last_activity.is_some());
763        assert!(meta.last_activity > meta.started_at);
764    }
765}