1use crate::draft::QuestionDraft;
8use cerno_sdk::{Client, SystemOne};
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
13pub struct Session {
14 #[serde(default)]
15 pub state: String,
16 #[serde(default)]
17 pub model: Option<String>,
18 #[serde(default)]
19 pub calibration: Option<f64>,
20 #[serde(default)]
21 pub questions: Vec<QuestionDraft>,
22}
23
24pub fn default_path() -> Option<PathBuf> {
29 let base = match std::env::var_os("XDG_CONFIG_HOME") {
30 Some(dir) if !dir.is_empty() => PathBuf::from(dir),
31 _ => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
32 };
33 Some(base.join("cerno").join("last-session.json"))
34}
35
36impl Session {
37 pub fn load_from(path: &Path) -> Self {
39 std::fs::read_to_string(path)
40 .ok()
41 .and_then(|text| serde_json::from_str(&text).ok())
42 .unwrap_or_default()
43 }
44
45 pub fn load() -> Self {
46 default_path()
47 .map(|p| Self::load_from(&p))
48 .unwrap_or_default()
49 }
50
51 pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
55 if let Some(parent) = path.parent() {
56 std::fs::create_dir_all(parent)?;
57 }
58 let mut staging = path.as_os_str().to_owned();
59 staging.push(".tmp");
60 let staging = PathBuf::from(staging);
61
62 write_private(&staging, &serde_json::to_string_pretty(self)?)?;
63 std::fs::rename(&staging, path).inspect_err(|_| {
64 let _ = std::fs::remove_file(&staging);
65 })
66 }
67
68 pub fn clear_at(path: &Path) -> std::io::Result<()> {
71 match std::fs::remove_file(path) {
72 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
73 other => other,
74 }
75 }
76
77 pub fn persist(&self) -> std::io::Result<()> {
80 match default_path() {
81 Some(path) if self.is_empty() => Self::clear_at(&path),
82 Some(path) => self.save_to(&path),
83 None => Ok(()),
84 }
85 }
86
87 pub fn build<'a>(&self, client: &'a Client) -> SystemOne<'a> {
99 let mut builder = client.systemone(self.state.clone());
100
101 if let Some(model) = &self.model {
102 builder = builder.model(model);
103 }
104 if let Some(temperature) = self.calibration {
105 builder = builder.calibration(temperature);
106 }
107
108 for draft in &self.questions {
109 builder = draft.apply(builder);
110 }
111 builder
112 }
113
114 pub fn is_empty(&self) -> bool {
116 self.state.trim().is_empty() && self.questions.is_empty()
117 }
118}
119
120#[cfg(unix)]
123fn write_private(path: &Path, text: &str) -> std::io::Result<()> {
124 use std::io::Write;
125 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
126
127 let mut file = std::fs::OpenOptions::new()
128 .write(true)
129 .create(true)
130 .truncate(true)
131 .mode(0o600)
132 .open(path)?;
133 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
136 file.write_all(text.as_bytes())
137}
138
139#[cfg(not(unix))]
140fn write_private(path: &Path, text: &str) -> std::io::Result<()> {
141 std::fs::write(path, text)
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::draft::Kind;
148
149 fn sample() -> Session {
150 Session {
151 state: "Ticket: server room at 31C.".into(),
152 model: Some("small".into()),
153 calibration: Some(2.5),
154 questions: vec![QuestionDraft {
155 id: "urgent".into(),
156 kind: Kind::Noul,
157 question: "Is this urgent?".into(),
158 options: "IT, Facility".into(),
159 levels: "5".into(),
160 }],
161 }
162 }
163
164 #[test]
165 fn a_session_survives_a_round_trip() {
166 let dir = tempfile::tempdir().unwrap();
167 let path = dir.path().join("nested").join("last-session.json");
168
169 sample().save_to(&path).unwrap();
170
171 assert_eq!(Session::load_from(&path), sample());
172 }
173
174 #[cfg(unix)]
175 #[test]
176 fn a_saved_session_is_readable_by_its_owner_only() {
177 use std::os::unix::fs::PermissionsExt;
178 let dir = tempfile::tempdir().unwrap();
179 let path = dir.path().join("last-session.json");
180 std::fs::write(&path, "{}").unwrap();
182 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
183
184 sample().save_to(&path).unwrap();
185
186 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
187 assert_eq!(mode, 0o600, "{mode:o}");
188 assert_eq!(Session::load_from(&path), sample());
189 }
190
191 #[test]
193 fn saving_leaves_no_staging_file_behind() {
194 let dir = tempfile::tempdir().unwrap();
195 let path = dir.path().join("last-session.json");
196
197 sample().save_to(&path).unwrap();
198 sample().save_to(&path).unwrap();
199
200 let names: Vec<_> = std::fs::read_dir(dir.path())
201 .unwrap()
202 .map(|entry| entry.unwrap().file_name())
203 .collect();
204 assert_eq!(names, ["last-session.json"]);
205 }
206
207 #[test]
209 fn a_missing_file_loads_as_an_empty_session() {
210 let dir = tempfile::tempdir().unwrap();
211
212 let loaded = Session::load_from(&dir.path().join("absent.json"));
213
214 assert_eq!(loaded, Session::default());
215 assert!(loaded.is_empty());
216 }
217
218 #[test]
220 fn a_corrupt_file_loads_as_an_empty_session() {
221 let dir = tempfile::tempdir().unwrap();
222 let path = dir.path().join("broken.json");
223 std::fs::write(&path, "{ this is not json").unwrap();
224
225 assert_eq!(Session::load_from(&path), Session::default());
226 }
227
228 #[test]
230 fn unknown_and_missing_fields_are_tolerated() {
231 let dir = tempfile::tempdir().unwrap();
232 let path = dir.path().join("partial.json");
233 std::fs::write(&path, r#"{"state":"kept","future_field":42}"#).unwrap();
234
235 let loaded = Session::load_from(&path);
236
237 assert_eq!(loaded.state, "kept");
238 assert!(loaded.questions.is_empty());
239 assert_eq!(loaded.model, None);
240 }
241
242 #[test]
244 fn clearing_forgets_the_saved_session() {
245 let dir = tempfile::tempdir().unwrap();
246 let path = dir.path().join("last-session.json");
247 sample().save_to(&path).unwrap();
248
249 Session::clear_at(&path).unwrap();
250
251 assert!(!path.exists());
252 assert_eq!(Session::load_from(&path), Session::default());
253 Session::clear_at(&path).unwrap();
255 }
256
257 #[test]
258 fn an_untouched_form_is_empty() {
259 assert!(Session::default().is_empty());
260 assert!(!sample().is_empty());
261
262 let only_state = Session {
263 state: "something".into(),
264 ..Default::default()
265 };
266 assert!(!only_state.is_empty());
267 }
268
269 #[test]
270 fn the_default_path_follows_xdg_then_home() {
271 if let Some(path) = default_path() {
274 assert!(path.ends_with("cerno/last-session.json"), "{path:?}");
275 }
276 }
277}