Skip to main content

cerno_tui/
session.rs

1//! Remembering the last request across restarts.
2//!
3//! Only a convenience: while trying things out you retype the same ticket text far too often.
4//! Everything here therefore fails soft — a missing, unreadable or malformed file starts an
5//! empty form. Refusing to launch over a convenience file would be the worse trade.
6
7use 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
24/// `$XDG_CONFIG_HOME/cerno/last-session.json`, falling back to `$HOME/.config/…`.
25///
26/// Resolved by hand rather than through a crate: it is two environment variables, and the TUI
27/// already earns its dependencies elsewhere.
28pub 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    /// Read a session, or return an empty one for any reason at all.
38    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    /// Write the session beside `path` and rename it into place, so the file is always either
52    /// the old session or the new one. Written in place, a crash between truncating and writing
53    /// would leave a broken file, which loads as an empty form without a word.
54    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    /// Forget the saved session. Emptying the form is a deliberate act, and leaving the old file
69    /// behind would bring back on the next start what was just cleared.
70    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    /// Save a form worth keeping, or forget the saved one when the form is empty. Failure is
78    /// reported rather than swallowed — the caller decides whether it is worth a message.
79    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    /// Assemble the request this form describes, through the SDK's own builder.
88    ///
89    /// The request is built from the session rather than from the live widgets for two reasons.
90    /// It makes what gets saved and what gets sent the same thing by construction — a reloaded
91    /// form cannot send something different from the one that was saved. And it hands the
92    /// background task an owned snapshot, so the request in flight is the form as it was when
93    /// you pressed send, not as you have edited it since.
94    ///
95    /// Nothing is validated here. `cerno-core` owns those rules, the OpenAPI document publishes
96    /// them, and the service names both the offending question and what to do instead. A fourth
97    /// copy in the client would be the one that drifts.
98    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    /// Whether there is anything worth writing. An empty form should not leave a file behind.
115    pub fn is_empty(&self) -> bool {
116        self.state.trim().is_empty() && self.questions.is_empty()
117    }
118}
119
120/// Write `text` readable by its owner only. The state is whatever ticket or message was being
121/// tried out, which is nobody else's business on a shared machine.
122#[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    // `mode` only applies to a file this call creates; one saved by an older version keeps its
134    // permissions unless they are tightened here, before anything new is written into it.
135    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        // As an older version left it.
181        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    /// Saving leaves the session and nothing else: the staging file is renamed, not left over.
192    #[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    /// The first ever launch has no file, and that is the normal case, not an error.
208    #[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    /// A half-written or hand-edited file must not stop the program starting.
219    #[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    /// A file from an older version missing fields should keep what it does have.
229    #[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    /// Clearing the form and quitting must not bring the old form back on the next start.
243    #[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        // Nothing saved yet is not a failure.
254        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        // Only the shape is asserted; the environment is process-wide and shared with other
272        // tests, so it is read here rather than set.
273        if let Some(path) = default_path() {
274            assert!(path.ends_with("cerno/last-session.json"), "{path:?}");
275        }
276    }
277}