Skip to main content

newt_core/
settings.rs

1//! Sticky user preferences (`~/.newt/settings.toml`).
2//!
3//! A small, hand-editable file that records the *last-active* session
4//! selections so the next `newt` invocation starts where you left off
5//! (issue #545). Today that means two axes, both under `[session]`:
6//!
7//! ```toml
8//! # newt — sticky user preferences (hand-editable).
9//! [session]
10//! provider = "dgx1"      # last `/backends <name>`
11//! model = "gpt-4.1"      # last `/model <name>`
12//! ```
13//!
14//! It is written automatically when you switch the backend (`/backends
15//! <name>`) or the model (`/model <name>`) in the TUI, and read once at
16//! startup to seed `NEWT_PROVIDER` / `NEWT_DGX_MODEL`.
17//!
18//! # Design
19//!
20//! * **Lowest precedence on restore.** An explicit `NEWT_PROVIDER` /
21//!   `NEWT_DGX_MODEL` in the environment, or a `--loadout`'s provider/model
22//!   axis, always wins. `settings.toml` only fills an axis nothing else
23//!   pinned ([`Session::restore`]).
24//! * **Defensive.** A `provider` naming a `[[backends]]` entry that no longer
25//!   exists is ignored, so editing config out from under the file can never
26//!   break startup.
27//! * **Hand-editable + room to grow.** Reads/writes go through a dynamic
28//!   [`toml::Table`], so unknown keys and tables the user (or a future newt)
29//!   adds are preserved across an in-app rewrite. The one thing a rewrite
30//!   does *not* preserve is inline comments — keep durable notes in
31//!   `config.toml`; this file's header says so.
32//!
33//! This is deliberately separate from the hand-authored `~/.newt/config.toml`:
34//! config is where you *declare* backends/loadouts; `settings.toml` is the
35//! lean, machine-managed memory of what you last picked.
36
37use std::path::{Path, PathBuf};
38
39/// Path to `~/.newt/settings.toml` (sibling of `config.toml`).
40pub fn settings_path() -> Option<PathBuf> {
41    crate::config::Config::user_config_path().map(|p| p.with_file_name("settings.toml"))
42}
43
44/// The last-active selections read from the `[session]` table.
45#[derive(Debug, Default, Clone, PartialEq, Eq)]
46pub struct Session {
47    /// Last `[[backends]]` name chosen via `/backends <name>`.
48    pub provider: Option<String>,
49    /// Last model override chosen via `/model <name>` (the `NEWT_DGX_MODEL` axis).
50    pub model: Option<String>,
51}
52
53/// The startup environment a [`Session`] asks for: each field is `Some` only
54/// when that axis should actually be set (nothing already pinned it, and — for
55/// `provider` — the named backend still exists).
56#[derive(Debug, Default, Clone, PartialEq, Eq)]
57pub struct Restore {
58    pub provider: Option<String>,
59    pub model: Option<String>,
60}
61
62impl Session {
63    /// Extract `[session]` selections from a parsed settings document. Tolerant
64    /// of a missing table, missing keys, non-string values, and empty strings
65    /// (all read as `None`) — a malformed file must never break startup.
66    pub fn from_doc(doc: &toml::Table) -> Self {
67        let session = doc.get("session").and_then(toml::Value::as_table);
68        let read = |key: &str| {
69            session
70                .and_then(|t| t.get(key))
71                .and_then(toml::Value::as_str)
72                .map(str::to_string)
73                .filter(|s| !s.is_empty())
74        };
75        Self {
76            provider: read("provider"),
77            model: read("model"),
78        }
79    }
80
81    /// True when nothing is recorded (skip the whole restore/write dance).
82    pub fn is_empty(&self) -> bool {
83        self.provider.is_none() && self.model.is_none()
84    }
85
86    /// Decide what to restore. `current_provider` is the value `NEWT_PROVIDER`
87    /// already holds (an explicit env var or a `--loadout`'s provider) — `None`
88    /// if unset; a pinned provider is left untouched. `model_pinned` is whether
89    /// `NEWT_DGX_MODEL` is already set. `known_backend` guards the recorded
90    /// provider so a stale name (backend since removed from config) is dropped
91    /// rather than producing a dangling pin. Pure — the caller owns the env and
92    /// config lookups, keeping this fully unit-testable.
93    ///
94    /// A recorded model belongs to the provider it was chosen under
95    /// (`self.provider`), so it is restored only when that provider is the one
96    /// that will actually be active. This keeps a model picked for one backend
97    /// from bleeding onto a *different* backend selected by env or a `--loadout`
98    /// — matching the in-app rule that picking a provider clears the model
99    /// override. (When both are `None` the model was chosen on the default
100    /// backend, which is also what's active, so a bare `/model` choice still
101    /// sticks.)
102    pub fn restore(
103        &self,
104        current_provider: Option<&str>,
105        model_pinned: bool,
106        known_backend: impl Fn(&str) -> bool,
107    ) -> Restore {
108        // Provider: fill it only when nothing already pinned one and the
109        // recorded name still resolves to a configured backend.
110        let provider = match current_provider {
111            Some(_) => None,
112            None => self
113                .provider
114                .as_deref()
115                .filter(|name| known_backend(name))
116                .map(str::to_string),
117        };
118        // The provider that will be active once our decision lands: an existing
119        // pin, else the one we just restored, else the default resolution.
120        let effective = current_provider.or(provider.as_deref());
121        let model = self
122            .model
123            .as_deref()
124            .filter(|_| !model_pinned)
125            .filter(|_| effective == self.provider.as_deref())
126            .map(str::to_string);
127        Restore { provider, model }
128    }
129}
130
131/// Whether a `/backends`/`/model` switch should be persisted to
132/// `settings.toml`. `false` in an ephemeral session (`--ephemeral` /
133/// `NEWT_EPHEMERAL`), which must leave no trace on disk — the switch still
134/// applies live for the session, it just isn't recorded. Pure mirror of the
135/// `should_extract_on_close` rule; the caller computes `ephemeral` from the env.
136pub fn should_persist(ephemeral: bool) -> bool {
137    !ephemeral
138}
139
140/// The self-documenting header re-emitted on every write. (Comments inside the
141/// body are not round-tripped through [`toml::Table`]; this header always is.)
142const HEADER: &str = "\
143# newt — sticky user preferences (hand-editable).
144#
145# Auto-updated when you switch the backend (/backends <name>) or model
146# (/model <name>) in the TUI, so your last choice is restored on the next
147# start. Restore is the LOWEST precedence: an explicit NEWT_PROVIDER /
148# NEWT_DGX_MODEL in the environment, or a --loadout, always wins. Delete this
149# file to forget the last selection.
150#
151# Hand-editable, but note: an in-app /backends or /model write preserves other
152# keys/tables yet drops inline comments — keep durable notes in config.toml.
153";
154
155/// Set (`Some`) or clear (`None`) `session.<key>` in a settings document,
156/// preserving every other key and table. Clearing the last key in `[session]`
157/// drops the now-empty table so the file stays tidy. Pure (operates on the
158/// passed document) so it unit-tests without touching the filesystem.
159pub fn apply_session_key(doc: &mut toml::Table, key: &str, value: Option<&str>) {
160    match value {
161        Some(v) => {
162            let entry = doc
163                .entry("session".to_string())
164                .or_insert_with(|| toml::Value::Table(toml::Table::new()));
165            // Heal a `session` key the user mistyped as a non-table.
166            if !entry.is_table() {
167                *entry = toml::Value::Table(toml::Table::new());
168            }
169            if let Some(table) = entry.as_table_mut() {
170                table.insert(key.to_string(), toml::Value::String(v.to_string()));
171            }
172        }
173        None => {
174            if let Some(toml::Value::Table(table)) = doc.get_mut("session") {
175                table.remove(key);
176                if table.is_empty() {
177                    doc.remove("session");
178                }
179            }
180        }
181    }
182}
183
184/// Render a settings document to TOML text with the header. Returns `Err` (so
185/// the caller skips the write rather than truncating the file) if the document
186/// cannot be serialized.
187pub fn to_toml_string(doc: &toml::Table) -> Result<String, toml::ser::Error> {
188    Ok(format!("{HEADER}{}", toml::to_string_pretty(doc)?))
189}
190
191// ---------------------------------------------------------------------------
192// Filesystem seam (thin; integration-tier only — never exercised by unit tests
193// per the repo test-fs policy, mirroring `tuning.rs`).
194// ---------------------------------------------------------------------------
195
196/// Parse the settings document at `path` into a dynamic table. Any error
197/// (absent file, parse failure) yields an empty document.
198fn read_doc(path: &Path) -> toml::Table {
199    std::fs::read_to_string(path)
200        .ok()
201        .and_then(|text| toml::from_str::<toml::Table>(&text).ok())
202        .unwrap_or_default()
203}
204
205/// Write the document back, creating `~/.newt/` if needed. A serialization
206/// failure is surfaced as an error *before* any write, so a broken document
207/// never truncates the file.
208fn write_doc(path: &Path, doc: &toml::Table) -> std::io::Result<()> {
209    let text = to_toml_string(doc).map_err(|e| std::io::Error::other(e.to_string()))?;
210    if let Some(dir) = path.parent() {
211        std::fs::create_dir_all(dir)?;
212    }
213    std::fs::write(path, text)
214}
215
216/// Load the recorded session selections from `~/.newt/settings.toml`. Empty on
217/// any error.
218pub fn load() -> Session {
219    settings_path()
220        .map(|path| Session::from_doc(&read_doc(&path)))
221        .unwrap_or_default()
222}
223
224/// Read-modify-write `~/.newt/settings.toml`, preserving unknown content.
225/// Best-effort: a path we can't resolve or a write we can't perform is a no-op.
226fn update(mutate: impl FnOnce(&mut toml::Table)) {
227    let Some(path) = settings_path() else {
228        return;
229    };
230    let mut doc = read_doc(&path);
231    mutate(&mut doc);
232    let _ = write_doc(&path, &doc);
233}
234
235/// Persist the provider chosen via `/backends <name>`, clearing any stale model
236/// override (mirrors the session's `NEWT_DGX_MODEL` reset so the named backend's
237/// own default model applies on the next start). Best-effort.
238pub fn record_provider(name: &str) {
239    update(|doc| {
240        apply_session_key(doc, "provider", Some(name));
241        apply_session_key(doc, "model", None);
242    });
243}
244
245/// Persist the model chosen via `/model <name>` (leaving the provider as-is).
246/// Best-effort.
247pub fn record_model(name: &str) {
248    update(|doc| apply_session_key(doc, "model", Some(name)));
249}
250
251// ---------------------------------------------------------------------------
252// Tests — pure / in-memory only (no real filesystem, per the test-fs policy).
253// ---------------------------------------------------------------------------
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    fn doc(text: &str) -> toml::Table {
260        toml::from_str(text).expect("valid toml")
261    }
262
263    #[test]
264    fn from_doc_reads_session_axes() {
265        let d = doc("[session]\nprovider = \"dgx1\"\nmodel = \"gpt-4.1\"\n");
266        let s = Session::from_doc(&d);
267        assert_eq!(s.provider.as_deref(), Some("dgx1"));
268        assert_eq!(s.model.as_deref(), Some("gpt-4.1"));
269        assert!(!s.is_empty());
270    }
271
272    #[test]
273    fn from_doc_is_tolerant_of_missing_empty_and_wrong_types() {
274        assert!(Session::from_doc(&doc("")).is_empty());
275        assert!(Session::from_doc(&doc("[other]\nx = 1\n")).is_empty());
276        // Empty string and non-string value both read as None.
277        let d = doc("[session]\nprovider = \"\"\nmodel = 42\n");
278        assert!(Session::from_doc(&d).is_empty());
279    }
280
281    /// Regression for #545 (read half): a recorded provider/model is restored
282    /// when nothing else pinned the axis. Before the fix there was no such
283    /// restore path, so the last choice was silently forgotten on restart.
284    #[test]
285    fn restore_applies_recorded_axes_when_unpinned() {
286        let s = Session {
287            provider: Some("dgx1".into()),
288            model: Some("gpt-4.1".into()),
289        };
290        let r = s.restore(None, false, |n| n == "dgx1");
291        assert_eq!(r.provider.as_deref(), Some("dgx1"));
292        assert_eq!(r.model.as_deref(), Some("gpt-4.1"));
293    }
294
295    /// Regression for #545 (second reported case): a bare `/model` choice with
296    /// no recorded provider sticks on the default-resolved backend.
297    #[test]
298    fn restore_applies_a_bare_model_on_the_default_backend() {
299        let s = Session {
300            provider: None,
301            model: Some("gpt-4.1".into()),
302        };
303        let r = s.restore(None, false, |_| false);
304        assert_eq!(r.provider, None);
305        assert_eq!(r.model.as_deref(), Some("gpt-4.1"));
306    }
307
308    #[test]
309    fn restore_yields_to_an_explicit_pin() {
310        let s = Session {
311            provider: Some("dgx1".into()),
312            model: Some("gpt-4.1".into()),
313        };
314        // Provider pinned (to the SAME backend) + model pinned → both untouched.
315        assert_eq!(s.restore(Some("dgx1"), true, |_| true), Restore::default());
316        // Provider pinned to the same backend, model unpinned → model restores.
317        let r = s.restore(Some("dgx1"), false, |_| true);
318        assert_eq!(r.provider, None);
319        assert_eq!(r.model.as_deref(), Some("gpt-4.1"));
320    }
321
322    /// Regression for the cross-provider model bleed: a model chosen for one
323    /// backend must NOT be re-applied to a different backend that env or a
324    /// `--loadout` pinned this run.
325    #[test]
326    fn restore_does_not_bleed_model_onto_a_different_pinned_provider() {
327        let s = Session {
328            provider: Some("openai".into()),
329            model: Some("gpt-4.1".into()),
330        };
331        // A --loadout/env pinned dgx1; the openai-era model must not ride along.
332        let r = s.restore(Some("dgx1"), false, |_| true);
333        assert_eq!(r.provider, None);
334        assert_eq!(r.model, None);
335    }
336
337    #[test]
338    fn restore_drops_both_axes_when_the_provider_no_longer_exists() {
339        let s = Session {
340            provider: Some("ghost".into()),
341            model: Some("gpt-4.1".into()),
342        };
343        // Unknown backend → provider dropped; the model belonged to that gone
344        // backend, so it is dropped too rather than bleeding onto the default.
345        let r = s.restore(None, false, |_| false);
346        assert_eq!(r, Restore::default());
347    }
348
349    /// Regression for the ephemeral "leave no trace" invariant: switches in an
350    /// ephemeral session must not be persisted.
351    #[test]
352    fn ephemeral_sessions_do_not_persist() {
353        assert!(!should_persist(true));
354        assert!(should_persist(false));
355    }
356
357    #[test]
358    fn apply_sets_and_round_trips_through_text() {
359        let mut d = toml::Table::new();
360        apply_session_key(&mut d, "provider", Some("dgx1"));
361        apply_session_key(&mut d, "model", Some("gpt-4.1"));
362        let text = to_toml_string(&d).unwrap();
363        assert!(text.starts_with("# newt — sticky user preferences"));
364        let back = Session::from_doc(&doc(&text));
365        assert_eq!(back.provider.as_deref(), Some("dgx1"));
366        assert_eq!(back.model.as_deref(), Some("gpt-4.1"));
367    }
368
369    /// Regression for #545 (write half): `/backends` clears the model override.
370    #[test]
371    fn provider_switch_clears_the_model() {
372        let mut d = doc("[session]\nprovider = \"openai\"\nmodel = \"gpt-4.1\"\n");
373        apply_session_key(&mut d, "provider", Some("dgx1"));
374        apply_session_key(&mut d, "model", None);
375        let s = Session::from_doc(&d);
376        assert_eq!(s.provider.as_deref(), Some("dgx1"));
377        assert_eq!(s.model, None);
378    }
379
380    /// Hand-edit safety: an in-app rewrite preserves unknown keys/tables.
381    #[test]
382    fn rewrite_preserves_unknown_content() {
383        let mut d =
384            doc("top_pref = true\n\n[session]\nmodel = \"a\"\n\n[mine]\nnote = \"keep me\"\n");
385        // Simulate `/backends dgx1`: set provider, clear model.
386        apply_session_key(&mut d, "provider", Some("dgx1"));
387        apply_session_key(&mut d, "model", None);
388        let text = to_toml_string(&d).unwrap();
389        // Serializes without a ValueAfterTable error and keeps the foreign bits.
390        let back = doc(&text);
391        assert_eq!(
392            back.get("top_pref").and_then(toml::Value::as_bool),
393            Some(true)
394        );
395        assert_eq!(
396            back.get("mine")
397                .and_then(toml::Value::as_table)
398                .and_then(|t| t.get("note"))
399                .and_then(toml::Value::as_str),
400            Some("keep me")
401        );
402        assert_eq!(Session::from_doc(&back).provider.as_deref(), Some("dgx1"));
403    }
404
405    #[test]
406    fn clearing_the_last_session_key_drops_the_empty_table() {
407        let mut d = doc("[session]\nmodel = \"a\"\n");
408        apply_session_key(&mut d, "model", None);
409        assert!(!d.contains_key("session"));
410    }
411}