1use crate::llm::Message;
2use anyhow::{bail, Context as _};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ChatSession {
9 pub version: u32,
10 pub name: String,
11 pub created_at: DateTime<Utc>,
12 pub updated_at: DateTime<Utc>,
13 pub context_config: SessionContextConfig,
14 pub llm_hint: String,
16 pub messages: Vec<SessionMessage>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SessionMessage {
21 pub role: String,
22 pub content: String,
23 pub ts: DateTime<Utc>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct SessionContextConfig {
28 pub context_days: u64,
29 pub since: Option<String>,
30}
31
32#[derive(Debug, Clone)]
33pub struct SessionSummary {
34 pub name: String,
35 pub updated_at: DateTime<Utc>,
36 pub message_count: usize,
37}
38
39pub fn validate_name(name: &str) -> anyhow::Result<()> {
42 if name.is_empty() {
43 bail!("session name cannot be empty");
44 }
45 if name == ".." || name == "." {
46 bail!("'{}' is not a valid session name", name);
47 }
48 if name.contains('/') || name.contains('\\') {
49 bail!("session name cannot contain path separators: '{}'", name);
50 }
51 if !name
52 .chars()
53 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
54 {
55 bail!(
56 "session name '{}' contains invalid characters (use letters, digits, -, _, .)",
57 name
58 );
59 }
60 Ok(())
61}
62
63impl ChatSession {
64 pub fn new(name: &str, context_config: SessionContextConfig) -> Self {
65 let now = Utc::now();
66 Self {
67 version: 1,
68 name: name.to_string(),
69 created_at: now,
70 updated_at: now,
71 context_config,
72 llm_hint: String::new(),
73 messages: Vec::new(),
74 }
75 }
76
77 pub fn load(sessions_dir: &Path, name: &str) -> anyhow::Result<Self> {
78 validate_name(name)?;
79 let path = sessions_dir.join(format!("{}.json", name));
80 let content = std::fs::read_to_string(&path)
81 .with_context(|| format!("cannot read session file {}", path.display()))?;
82 let session: ChatSession = serde_json::from_str(&content)
83 .with_context(|| format!("corrupt session file {}", path.display()))?;
84 Ok(session)
85 }
86
87 pub fn save(&mut self, sessions_dir: &Path) -> anyhow::Result<()> {
89 validate_name(&self.name)?;
90 std::fs::create_dir_all(sessions_dir).context("cannot create sessions directory")?;
91 self.updated_at = Utc::now();
92 let path = sessions_dir.join(format!("{}.json", self.name));
93 let tmp = sessions_dir.join(format!("{}.json.tmp.{}", self.name, std::process::id()));
94 let json = serde_json::to_string_pretty(self).context("serialize session")?;
95 std::fs::write(&tmp, &json).context("write session tmp")?;
96 std::fs::rename(&tmp, &path).context("rename session tmp")?;
97 Ok(())
98 }
99
100 pub fn list(sessions_dir: &Path) -> anyhow::Result<Vec<SessionSummary>> {
102 if !sessions_dir.exists() {
103 return Ok(Vec::new());
104 }
105 let mut summaries = Vec::new();
106 for entry in std::fs::read_dir(sessions_dir).context("read sessions dir")? {
107 let entry = entry?;
108 let path = entry.path();
109 if path.extension().and_then(|e| e.to_str()) != Some("json") {
110 continue;
111 }
112 if let Ok(content) = std::fs::read_to_string(&path) {
113 if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
114 summaries.push(SessionSummary {
115 name: session.name.clone(),
116 updated_at: session.updated_at,
117 message_count: session.messages.len(),
118 });
119 }
120 }
121 }
122 summaries.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
123 Ok(summaries)
124 }
125
126 pub fn to_messages(&self) -> Vec<Message> {
128 self.messages
129 .iter()
130 .map(|m| Message {
131 role: m.role.clone(),
132 content: m.content.clone(),
133 })
134 .collect()
135 }
136
137 pub fn push_user(&mut self, content: String) {
138 self.messages.push(SessionMessage {
139 role: "user".into(),
140 content,
141 ts: Utc::now(),
142 });
143 }
144
145 pub fn push_assistant(&mut self, content: String) {
146 self.messages.push(SessionMessage {
147 role: "assistant".into(),
148 content,
149 ts: Utc::now(),
150 });
151 }
152}