Skip to main content

mermaid_cli/session/
conversation.rs

1use crate::models::{ChatMessage, MessageRole};
2use anyhow::Result;
3use chrono::{DateTime, Local};
4use serde::{Deserialize, Serialize};
5use std::collections::VecDeque;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// A complete conversation history
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ConversationHistory {
12    pub id: String,
13    pub title: String,
14    pub messages: Vec<ChatMessage>,
15    pub model_name: String,
16    pub project_path: String,
17    pub created_at: DateTime<Local>,
18    pub updated_at: DateTime<Local>,
19    pub total_tokens: Option<usize>,
20    /// History of user input prompts for navigation (up/down arrows)
21    #[serde(default)]
22    pub input_history: VecDeque<String>,
23}
24
25impl ConversationHistory {
26    /// Create a new conversation history
27    pub fn new(project_path: String, model_name: String) -> Self {
28        let now = Local::now();
29        let id = format!("{}", now.format("%Y%m%d_%H%M%S"));
30        Self {
31            id: id.clone(),
32            title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
33            messages: Vec::new(),
34            model_name,
35            project_path,
36            created_at: now,
37            updated_at: now,
38            total_tokens: None,
39            input_history: VecDeque::new(),
40        }
41    }
42
43    /// Add messages to the conversation
44    pub fn add_messages(&mut self, messages: &[ChatMessage]) {
45        self.messages.extend_from_slice(messages);
46        self.updated_at = Local::now();
47        self.update_title();
48    }
49
50    /// Add input to history (with deduplication of consecutive identical inputs)
51    pub fn add_to_input_history(&mut self, input: String) {
52        // Skip empty inputs
53        if input.trim().is_empty() {
54            return;
55        }
56
57        // Don't add if it's identical to the last entry
58        if let Some(last) = self.input_history.back() {
59            if last == &input {
60                return;
61            }
62        }
63
64        // Cap history at 100 entries to prevent unbounded growth
65        if self.input_history.len() >= 100 {
66            self.input_history.pop_front(); // O(1) instead of O(n)
67        }
68
69        self.input_history.push_back(input);
70    }
71
72    /// Update the title based on the first user message
73    fn update_title(&mut self) {
74        if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
75            // Take first 60 chars of first user message as title
76            let preview = if first_user_msg.content.len() > 60 {
77                format!("{}...", &first_user_msg.content[..60])
78            } else {
79                first_user_msg.content.clone()
80            };
81            self.title = preview;
82        }
83    }
84
85    /// Get a summary for display
86    pub fn summary(&self) -> String {
87        let message_count = self.messages.len();
88        let duration = self.updated_at.signed_duration_since(self.created_at);
89        let hours = duration.num_hours();
90        let minutes = duration.num_minutes() % 60;
91
92        format!(
93            "{} | {} messages | {}h {}m | {}",
94            self.updated_at.format("%Y-%m-%d %H:%M"),
95            message_count,
96            hours,
97            minutes,
98            self.title
99        )
100    }
101}
102
103/// Manages conversation persistence for a project
104pub struct ConversationManager {
105    conversations_dir: PathBuf,
106}
107
108impl ConversationManager {
109    /// Create a new conversation manager for a project directory
110    pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
111        let conversations_dir = project_dir.as_ref().join(".mermaid").join("conversations");
112
113        // Create conversations directory if it doesn't exist
114        fs::create_dir_all(&conversations_dir)?;
115
116        Ok(Self { conversations_dir })
117    }
118
119    /// Save a conversation to disk
120    pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
121        let filename = format!("{}.json", conversation.id);
122        let path = self.conversations_dir.join(filename);
123
124        let json = serde_json::to_string_pretty(conversation)?;
125        fs::write(path, json)?;
126
127        Ok(())
128    }
129
130    /// Load a specific conversation by ID
131    pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
132        let filename = format!("{}.json", id);
133        let path = self.conversations_dir.join(filename);
134
135        let json = fs::read_to_string(path)?;
136        let conversation: ConversationHistory = serde_json::from_str(&json)?;
137
138        Ok(conversation)
139    }
140
141    /// Load the most recent conversation
142    pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
143        let conversations = self.list_conversations()?;
144
145        if conversations.is_empty() {
146            return Ok(None);
147        }
148
149        // Conversations are already sorted by modification time (newest first)
150        Ok(conversations.into_iter().next())
151    }
152
153    /// List all conversations in the project
154    pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
155        let mut conversations = Vec::new();
156
157        // Read all JSON files in the conversations directory
158        if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
159            for entry in entries.flatten() {
160                if let Some(ext) = entry.path().extension() {
161                    if ext == "json" {
162                        if let Ok(json) = fs::read_to_string(entry.path()) {
163                            if let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) {
164                                conversations.push(conv);
165                            }
166                        }
167                    }
168                }
169            }
170        }
171
172        // Sort by updated_at (newest first)
173        conversations.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
174
175        Ok(conversations)
176    }
177
178    /// Delete a conversation
179    pub fn delete_conversation(&self, id: &str) -> Result<()> {
180        let filename = format!("{}.json", id);
181        let path = self.conversations_dir.join(filename);
182
183        if path.exists() {
184            fs::remove_file(path)?;
185        }
186
187        Ok(())
188    }
189
190    /// Get the conversations directory path
191    pub fn conversations_dir(&self) -> &Path {
192        &self.conversations_dir
193    }
194}