use std::path::{Path, PathBuf};
pub fn settings_path() -> Option<PathBuf> {
crate::config::Config::user_config_path().map(|p| p.with_file_name("settings.toml"))
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Session {
pub provider: Option<String>,
pub model: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Restore {
pub provider: Option<String>,
pub model: Option<String>,
}
impl Session {
pub fn from_doc(doc: &toml::Table) -> Self {
let session = doc.get("session").and_then(toml::Value::as_table);
let read = |key: &str| {
session
.and_then(|t| t.get(key))
.and_then(toml::Value::as_str)
.map(str::to_string)
.filter(|s| !s.is_empty())
};
Self {
provider: read("provider"),
model: read("model"),
}
}
pub fn is_empty(&self) -> bool {
self.provider.is_none() && self.model.is_none()
}
pub fn restore(
&self,
current_provider: Option<&str>,
model_pinned: bool,
known_backend: impl Fn(&str) -> bool,
) -> Restore {
let provider = match current_provider {
Some(_) => None,
None => self
.provider
.as_deref()
.filter(|name| known_backend(name))
.map(str::to_string),
};
let effective = current_provider.or(provider.as_deref());
let model = self
.model
.as_deref()
.filter(|_| !model_pinned)
.filter(|_| effective == self.provider.as_deref())
.map(str::to_string);
Restore { provider, model }
}
}
pub fn should_persist(ephemeral: bool) -> bool {
!ephemeral
}
const HEADER: &str = "\
# newt — sticky user preferences (hand-editable).
#
# Auto-updated when you switch the backend (/backends <name>) or model
# (/model <name>) in the TUI, so your last choice is restored on the next
# start. Restore is the LOWEST precedence: an explicit NEWT_PROVIDER /
# NEWT_DGX_MODEL in the environment, or a --loadout, always wins. Delete this
# file to forget the last selection.
#
# Hand-editable, but note: an in-app /backends or /model write preserves other
# keys/tables yet drops inline comments — keep durable notes in config.toml.
";
pub fn apply_session_key(doc: &mut toml::Table, key: &str, value: Option<&str>) {
match value {
Some(v) => {
let entry = doc
.entry("session".to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if !entry.is_table() {
*entry = toml::Value::Table(toml::Table::new());
}
if let Some(table) = entry.as_table_mut() {
table.insert(key.to_string(), toml::Value::String(v.to_string()));
}
}
None => {
if let Some(toml::Value::Table(table)) = doc.get_mut("session") {
table.remove(key);
if table.is_empty() {
doc.remove("session");
}
}
}
}
}
pub fn to_toml_string(doc: &toml::Table) -> Result<String, toml::ser::Error> {
Ok(format!("{HEADER}{}", toml::to_string_pretty(doc)?))
}
fn read_doc(path: &Path) -> toml::Table {
std::fs::read_to_string(path)
.ok()
.and_then(|text| toml::from_str::<toml::Table>(&text).ok())
.unwrap_or_default()
}
fn write_doc(path: &Path, doc: &toml::Table) -> std::io::Result<()> {
let text = to_toml_string(doc).map_err(|e| std::io::Error::other(e.to_string()))?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, text)
}
pub fn load() -> Session {
settings_path()
.map(|path| Session::from_doc(&read_doc(&path)))
.unwrap_or_default()
}
fn update(mutate: impl FnOnce(&mut toml::Table)) {
let Some(path) = settings_path() else {
return;
};
let mut doc = read_doc(&path);
mutate(&mut doc);
let _ = write_doc(&path, &doc);
}
pub fn record_provider(name: &str) {
update(|doc| {
apply_session_key(doc, "provider", Some(name));
apply_session_key(doc, "model", None);
});
}
pub fn record_model(name: &str) {
update(|doc| apply_session_key(doc, "model", Some(name)));
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(text: &str) -> toml::Table {
toml::from_str(text).expect("valid toml")
}
#[test]
fn from_doc_reads_session_axes() {
let d = doc("[session]\nprovider = \"dgx1\"\nmodel = \"gpt-4.1\"\n");
let s = Session::from_doc(&d);
assert_eq!(s.provider.as_deref(), Some("dgx1"));
assert_eq!(s.model.as_deref(), Some("gpt-4.1"));
assert!(!s.is_empty());
}
#[test]
fn from_doc_is_tolerant_of_missing_empty_and_wrong_types() {
assert!(Session::from_doc(&doc("")).is_empty());
assert!(Session::from_doc(&doc("[other]\nx = 1\n")).is_empty());
let d = doc("[session]\nprovider = \"\"\nmodel = 42\n");
assert!(Session::from_doc(&d).is_empty());
}
#[test]
fn restore_applies_recorded_axes_when_unpinned() {
let s = Session {
provider: Some("dgx1".into()),
model: Some("gpt-4.1".into()),
};
let r = s.restore(None, false, |n| n == "dgx1");
assert_eq!(r.provider.as_deref(), Some("dgx1"));
assert_eq!(r.model.as_deref(), Some("gpt-4.1"));
}
#[test]
fn restore_applies_a_bare_model_on_the_default_backend() {
let s = Session {
provider: None,
model: Some("gpt-4.1".into()),
};
let r = s.restore(None, false, |_| false);
assert_eq!(r.provider, None);
assert_eq!(r.model.as_deref(), Some("gpt-4.1"));
}
#[test]
fn restore_yields_to_an_explicit_pin() {
let s = Session {
provider: Some("dgx1".into()),
model: Some("gpt-4.1".into()),
};
assert_eq!(s.restore(Some("dgx1"), true, |_| true), Restore::default());
let r = s.restore(Some("dgx1"), false, |_| true);
assert_eq!(r.provider, None);
assert_eq!(r.model.as_deref(), Some("gpt-4.1"));
}
#[test]
fn restore_does_not_bleed_model_onto_a_different_pinned_provider() {
let s = Session {
provider: Some("openai".into()),
model: Some("gpt-4.1".into()),
};
let r = s.restore(Some("dgx1"), false, |_| true);
assert_eq!(r.provider, None);
assert_eq!(r.model, None);
}
#[test]
fn restore_drops_both_axes_when_the_provider_no_longer_exists() {
let s = Session {
provider: Some("ghost".into()),
model: Some("gpt-4.1".into()),
};
let r = s.restore(None, false, |_| false);
assert_eq!(r, Restore::default());
}
#[test]
fn ephemeral_sessions_do_not_persist() {
assert!(!should_persist(true));
assert!(should_persist(false));
}
#[test]
fn apply_sets_and_round_trips_through_text() {
let mut d = toml::Table::new();
apply_session_key(&mut d, "provider", Some("dgx1"));
apply_session_key(&mut d, "model", Some("gpt-4.1"));
let text = to_toml_string(&d).unwrap();
assert!(text.starts_with("# newt — sticky user preferences"));
let back = Session::from_doc(&doc(&text));
assert_eq!(back.provider.as_deref(), Some("dgx1"));
assert_eq!(back.model.as_deref(), Some("gpt-4.1"));
}
#[test]
fn provider_switch_clears_the_model() {
let mut d = doc("[session]\nprovider = \"openai\"\nmodel = \"gpt-4.1\"\n");
apply_session_key(&mut d, "provider", Some("dgx1"));
apply_session_key(&mut d, "model", None);
let s = Session::from_doc(&d);
assert_eq!(s.provider.as_deref(), Some("dgx1"));
assert_eq!(s.model, None);
}
#[test]
fn rewrite_preserves_unknown_content() {
let mut d =
doc("top_pref = true\n\n[session]\nmodel = \"a\"\n\n[mine]\nnote = \"keep me\"\n");
apply_session_key(&mut d, "provider", Some("dgx1"));
apply_session_key(&mut d, "model", None);
let text = to_toml_string(&d).unwrap();
let back = doc(&text);
assert_eq!(
back.get("top_pref").and_then(toml::Value::as_bool),
Some(true)
);
assert_eq!(
back.get("mine")
.and_then(toml::Value::as_table)
.and_then(|t| t.get("note"))
.and_then(toml::Value::as_str),
Some("keep me")
);
assert_eq!(Session::from_doc(&back).provider.as_deref(), Some("dgx1"));
}
#[test]
fn clearing_the_last_session_key_drops_the_empty_table() {
let mut d = doc("[session]\nmodel = \"a\"\n");
apply_session_key(&mut d, "model", None);
assert!(!d.contains_key("session"));
}
}