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}