mermaid_cli/session/
conversation.rs

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