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