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]`.
344    #[allow(dead_code)]
345    pub(crate) fn session_chain(
346        &self,
347        project_path: &str,
348        session_id: &str,
349    ) -> Result<Vec<String>> {
350        self.chain_for(project_path, session_id)
351    }
352
353    /// Returns the chain head (earliest segment) for `session_id`.
354    ///
355    /// For single-segment sessions, returns `session_id` unchanged.
356    #[allow(dead_code)]
357    pub(crate) fn chain_head(&self, project_path: &str, session_id: &str) -> Result<String> {
358        let chain = self.session_chain(project_path, session_id)?;
359        Ok(chain
360            .into_iter()
361            .next()
362            .unwrap_or_else(|| session_id.to_string()))
363    }
364
365    // ── Private helpers ──────────────────────────────────────────────
366
367    /// Refresh the chain index for `project_path` and resolve the chain
368    /// for `session_id`. RefCell borrow is scoped internally.
369    fn chain_for(&self, project_path: &str, session_id: &str) -> Result<Vec<String>> {
370        let mut cache = self.chain_cache.borrow_mut();
371        let index = cache
372            .entry(project_path.to_string())
373            .or_insert_with(chain::ChainIndex::new);
374        index.refresh(self.resolver(), project_path)?;
375        Ok(index.resolve_chain(session_id))
376    }
377
378    /// Refresh the chain index and return chain heads.
379    fn chain_heads(&self, project_path: &str) -> Result<Vec<String>> {
380        let mut cache = self.chain_cache.borrow_mut();
381        let index = cache
382            .entry(project_path.to_string())
383            .or_insert_with(chain::ChainIndex::new);
384        index.refresh(self.resolver(), project_path)?;
385        Ok(index.chain_heads())
386    }
387
388    /// Finds conversations that contain specific text.
389    pub fn find_conversations_with_text(
390        &self,
391        project_path: &str,
392        search_text: &str,
393    ) -> Result<Vec<Conversation>> {
394        let conversations = self.read_all_conversations(project_path)?;
395
396        Ok(conversations
397            .into_iter()
398            .filter(|convo| {
399                let query = ConversationQuery::new(convo);
400                !query.contains_text(search_text).is_empty()
401            })
402            .collect())
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use std::fs;
410    use tempfile::TempDir;
411
412    fn setup_test_manager() -> (TempDir, ClaudeConvo) {
413        let temp = TempDir::new().unwrap();
414        let claude_dir = temp.path().join(".claude");
415        fs::create_dir_all(claude_dir.join("projects/-test-project")).unwrap();
416
417        let resolver = PathResolver::new().with_claude_dir(claude_dir);
418        let manager = ClaudeConvo::with_resolver(resolver);
419
420        (temp, manager)
421    }
422
423    #[test]
424    fn test_basic_setup() {
425        let (_temp, manager) = setup_test_manager();
426        assert!(manager.exists());
427    }
428
429    #[test]
430    fn test_list_projects() {
431        let (_temp, manager) = setup_test_manager();
432        let projects = manager.list_projects().unwrap();
433        assert_eq!(projects.len(), 1);
434        assert_eq!(projects[0], "/test/project");
435    }
436
437    #[test]
438    fn test_project_exists() {
439        let (_temp, manager) = setup_test_manager();
440        assert!(manager.project_exists("/test/project"));
441        assert!(!manager.project_exists("/nonexistent"));
442    }
443
444    fn setup_test_with_conversation() -> (TempDir, ClaudeConvo) {
445        let temp = TempDir::new().unwrap();
446        let claude_dir = temp.path().join(".claude");
447        let project_dir = claude_dir.join("projects/-test-project");
448        fs::create_dir_all(&project_dir).unwrap();
449
450        let entry1 = r#"{"type":"user","uuid":"uuid-1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
451        let entry2 = r#"{"type":"assistant","uuid":"uuid-2","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there"}}"#;
452        fs::write(
453            project_dir.join("session-abc.jsonl"),
454            format!("{}\n{}\n", entry1, entry2),
455        )
456        .unwrap();
457
458        let resolver = PathResolver::new().with_claude_dir(claude_dir);
459        let manager = ClaudeConvo::with_resolver(resolver);
460        (temp, manager)
461    }
462
463    #[test]
464    fn test_read_conversation() {
465        let (_temp, manager) = setup_test_with_conversation();
466        let convo = manager
467            .read_conversation("/test/project", "session-abc")
468            .unwrap();
469        assert_eq!(convo.entries.len(), 2);
470        assert_eq!(convo.message_count(), 2);
471    }
472
473    #[test]
474    fn test_read_conversation_metadata() {
475        let (_temp, manager) = setup_test_with_conversation();
476        let meta = manager
477            .read_conversation_metadata("/test/project", "session-abc")
478            .unwrap();
479        assert_eq!(meta.message_count, 2);
480        assert_eq!(meta.session_id, "session-abc");
481    }
482
483    #[test]
484    fn test_list_conversations() {
485        let (_temp, manager) = setup_test_with_conversation();
486        let sessions = manager.list_conversations("/test/project").unwrap();
487        assert_eq!(sessions.len(), 1);
488        assert_eq!(sessions[0], "session-abc");
489    }
490
491    #[test]
492    fn test_list_conversation_metadata() {
493        let (_temp, manager) = setup_test_with_conversation();
494        let metadata = manager.list_conversation_metadata("/test/project").unwrap();
495        assert_eq!(metadata.len(), 1);
496        assert_eq!(metadata[0].session_id, "session-abc");
497    }
498
499    #[test]
500    fn test_conversation_exists() {
501        let (_temp, manager) = setup_test_with_conversation();
502        assert!(
503            manager
504                .conversation_exists("/test/project", "session-abc")
505                .unwrap()
506        );
507        assert!(
508            !manager
509                .conversation_exists("/test/project", "nonexistent")
510                .unwrap()
511        );
512    }
513
514    #[test]
515    fn test_io_accessor() {
516        let (_temp, manager) = setup_test_with_conversation();
517        assert!(manager.io().exists());
518    }
519
520    #[test]
521    fn test_resolver_accessor() {
522        let (_temp, manager) = setup_test_with_conversation();
523        assert!(manager.resolver().exists());
524    }
525
526    #[test]
527    fn test_claude_dir_path() {
528        let (_temp, manager) = setup_test_with_conversation();
529        let path = manager.claude_dir_path().unwrap();
530        assert!(path.exists());
531    }
532
533    #[test]
534    fn test_read_all_conversations() {
535        let (_temp, manager) = setup_test_with_conversation();
536        let convos = manager.read_all_conversations("/test/project").unwrap();
537        assert_eq!(convos.len(), 1);
538    }
539
540    #[test]
541    fn test_most_recent_conversation() {
542        let (_temp, manager) = setup_test_with_conversation();
543        let convo = manager.most_recent_conversation("/test/project").unwrap();
544        assert!(convo.is_some());
545    }
546
547    #[test]
548    fn test_most_recent_conversation_empty() {
549        let (_temp, manager) = setup_test_manager();
550        // No conversations in this project
551        let convo = manager.most_recent_conversation("/test/project").unwrap();
552        assert!(convo.is_none());
553    }
554
555    #[test]
556    fn test_find_conversations_with_text() {
557        let (_temp, manager) = setup_test_with_conversation();
558        let results = manager
559            .find_conversations_with_text("/test/project", "Hello")
560            .unwrap();
561        assert_eq!(results.len(), 1);
562
563        let no_results = manager
564            .find_conversations_with_text("/test/project", "nonexistent text xyz")
565            .unwrap();
566        assert!(no_results.is_empty());
567    }
568
569    #[test]
570    fn test_query_helper() {
571        let (_temp, manager) = setup_test_with_conversation();
572        let convo = manager
573            .read_conversation("/test/project", "session-abc")
574            .unwrap();
575        let q = manager.query(&convo);
576        let users = q.by_role(MessageRole::User);
577        assert_eq!(users.len(), 1);
578    }
579
580    #[test]
581    fn test_query_history_helper() {
582        let (_temp, manager) = setup_test_manager();
583        let history: Vec<HistoryEntry> = vec![];
584        let q = manager.query_history(&history);
585        let results = q.recent(5);
586        assert!(results.is_empty());
587    }
588
589    #[test]
590    fn test_read_history_no_file() {
591        let (_temp, manager) = setup_test_manager();
592        let history = manager.read_history().unwrap();
593        assert!(history.is_empty());
594    }
595
596    #[test]
597    fn test_default_impl() {
598        // Test that Default trait works
599        let _manager = ClaudeConvo::default();
600    }
601
602    // ── Session chain convenience methods ────────────────────────────
603
604    fn setup_chained_conversations() -> (TempDir, ClaudeConvo) {
605        let temp = TempDir::new().unwrap();
606        let claude_dir = temp.path().join(".claude");
607        let project_dir = claude_dir.join("projects/-test-project");
608        fs::create_dir_all(&project_dir).unwrap();
609
610        // session-a: standalone start
611        fs::write(
612            project_dir.join("session-a.jsonl"),
613            r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Start"}}"#,
614        ).unwrap();
615
616        // session-b: successor of a (bridge entry points to a)
617        let b = [
618            r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
619            r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"Middle"}}"#,
620        ];
621        fs::write(project_dir.join("session-b.jsonl"), b.join("\n")).unwrap();
622
623        // session-c: successor of b
624        let c = [
625            r#"{"uuid":"c0","type":"user","timestamp":"2024-01-01T02:00:00Z","sessionId":"session-b","message":{"role":"user","content":"Bridge"}}"#,
626            r#"{"uuid":"c1","type":"user","timestamp":"2024-01-01T02:00:01Z","sessionId":"session-c","message":{"role":"user","content":"End"}}"#,
627        ];
628        fs::write(project_dir.join("session-c.jsonl"), c.join("\n")).unwrap();
629
630        let resolver = PathResolver::new().with_claude_dir(claude_dir);
631        (temp, ClaudeConvo::with_resolver(resolver))
632    }
633
634    #[test]
635    fn test_session_chain_full() {
636        let (_temp, manager) = setup_chained_conversations();
637        let chain = manager.session_chain("/test/project", "session-a").unwrap();
638        assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
639    }
640
641    #[test]
642    fn test_session_chain_from_middle() {
643        let (_temp, manager) = setup_chained_conversations();
644        let chain = manager.session_chain("/test/project", "session-b").unwrap();
645        assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
646    }
647
648    #[test]
649    fn test_session_chain_single() {
650        let (_temp, manager) = setup_test_with_conversation();
651        let chain = manager
652            .session_chain("/test/project", "session-abc")
653            .unwrap();
654        assert_eq!(chain, vec!["session-abc"]);
655    }
656
657    #[test]
658    fn test_chain_head_from_tail() {
659        let (_temp, manager) = setup_chained_conversations();
660        let head = manager.chain_head("/test/project", "session-c").unwrap();
661        assert_eq!(head, "session-a");
662    }
663
664    #[test]
665    fn test_chain_head_already_head() {
666        let (_temp, manager) = setup_chained_conversations();
667        let head = manager.chain_head("/test/project", "session-a").unwrap();
668        assert_eq!(head, "session-a");
669    }
670
671    #[test]
672    fn test_chain_head_single_session() {
673        let (_temp, manager) = setup_test_with_conversation();
674        let head = manager.chain_head("/test/project", "session-abc").unwrap();
675        assert_eq!(head, "session-abc");
676    }
677
678    // ── Chain-default API tests ──────────────────────────────────────
679
680    #[test]
681    fn test_read_conversation_follows_chain() {
682        let (_temp, manager) = setup_chained_conversations();
683
684        // Reading from any segment returns the full merged conversation
685        let convo = manager
686            .read_conversation("/test/project", "session-a")
687            .unwrap();
688        assert_eq!(convo.session_id, "session-a");
689        assert_eq!(
690            convo.session_ids,
691            vec!["session-a", "session-b", "session-c"]
692        );
693        // a1, b1, c1 (bridge entries b0 and c0 filtered out)
694        assert_eq!(convo.entries.len(), 3);
695        assert_eq!(convo.entries[0].uuid, "a1");
696        assert_eq!(convo.entries[1].uuid, "b1");
697        assert_eq!(convo.entries[2].uuid, "c1");
698
699        // From the middle
700        let convo_b = manager
701            .read_conversation("/test/project", "session-b")
702            .unwrap();
703        assert_eq!(
704            convo_b.session_ids,
705            vec!["session-a", "session-b", "session-c"]
706        );
707        assert_eq!(convo_b.entries.len(), 3);
708
709        // From the tail
710        let convo_c = manager
711            .read_conversation("/test/project", "session-c")
712            .unwrap();
713        assert_eq!(convo_c.entries.len(), 3);
714    }
715
716    #[test]
717    fn test_list_conversations_returns_chain_heads() {
718        let (_temp, manager) = setup_chained_conversations();
719
720        let sessions = manager.list_conversations("/test/project").unwrap();
721        // Three files but only one chain head
722        assert_eq!(sessions.len(), 1);
723        assert!(sessions.contains(&"session-a".to_string()));
724    }
725
726    #[test]
727    fn test_read_segment_single_file() {
728        let (_temp, manager) = setup_chained_conversations();
729
730        // read_segment returns only the single file, not merged
731        let segment = manager.read_segment("/test/project", "session-b").unwrap();
732        assert_eq!(segment.session_id, "session-b");
733        assert_eq!(segment.entries.len(), 2); // b0 (bridge) + b1
734        assert!(segment.session_ids.is_empty());
735    }
736
737    #[test]
738    fn test_list_segments_returns_all() {
739        let (_temp, manager) = setup_chained_conversations();
740
741        let mut segments = manager.list_segments("/test/project").unwrap();
742        segments.sort();
743        assert_eq!(segments, vec!["session-a", "session-b", "session-c"]);
744    }
745
746    #[test]
747    fn test_read_conversation_metadata_aggregates_chain() {
748        let (_temp, manager) = setup_chained_conversations();
749
750        let meta = manager
751            .read_conversation_metadata("/test/project", "session-a")
752            .unwrap();
753        assert_eq!(meta.session_id, "session-a");
754        // a: 1 msg, b: 2 msgs, c: 2 msgs = 5 total
755        assert_eq!(meta.message_count, 5);
756        // started_at from first segment, last_activity from last
757        assert!(meta.started_at.is_some());
758        assert!(meta.last_activity.is_some());
759        assert!(meta.last_activity > meta.started_at);
760    }
761}