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>,
model_from_project: bool,
}
#[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"),
model_from_project: false,
}
}
pub fn is_empty(&self) -> bool {
self.provider.is_none() && self.model.is_none()
}
pub fn for_project(doc: &toml::Table, project: &str) -> Self {
let global = Self::from_doc(doc);
let Some(entry) = project_entry(doc, project) else {
return global;
};
let (provider, model) = (entry.provider, entry.model);
let model_from_project = model.is_some() && provider.is_none();
Self {
model: match (&provider, model) {
(_, Some(m)) => Some(m),
(Some(_), None) => None,
(None, None) => global.model,
},
provider: provider.or(global.provider),
model_from_project,
}
}
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(|_| self.model_from_project || effective == self.provider.as_deref())
.map(str::to_string);
Restore { provider, model }
}
}
pub fn should_persist(ephemeral: bool) -> bool {
!ephemeral
}
pub const MAX_PROJECTS: usize = 32;
struct Entry {
provider: Option<String>,
model: Option<String>,
}
fn project_entry(doc: &toml::Table, project: &str) -> Option<Entry> {
let t = doc.get("projects")?.as_table()?.get(project)?.as_table()?;
let read = |k: &str| {
t.get(k)
.and_then(toml::Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
Some(Entry {
provider: read("provider"),
model: read("model"),
})
}
#[derive(Debug, Clone, Copy)]
pub enum Choice<'a> {
Provider(&'a str),
Model(&'a str),
}
pub fn apply_project_choice(
doc: &mut toml::Table,
project: &str,
choice: Choice<'_>,
default_provider: Option<&str>,
exists: impl Fn(&str) -> bool,
) {
let global = Session::from_doc(doc);
let old = project_entry(doc, project);
let old_provider = old.and_then(|e| e.provider);
let (mut provider, mut model) = match choice {
Choice::Provider(name) => (Some(name.to_string()), None),
Choice::Model(name) => (old_provider, Some(name.to_string())),
};
let baseline = global.provider.as_deref().or(default_provider);
if provider.as_deref() == baseline {
provider = None;
}
if provider.is_none() && model == global.model {
model = None;
}
let projects = doc
.entry("projects".to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if !projects.is_table() {
*projects = toml::Value::Table(toml::Table::new());
}
let Some(projects) = projects.as_table_mut() else {
return;
};
projects.retain(|path, _| exists(path));
let used = |v: &toml::Value| v.get("used").and_then(toml::Value::as_integer).unwrap_or(0);
let tick = projects.values().map(used).max().unwrap_or(0) + 1;
projects.remove(project);
if provider.is_some() || model.is_some() {
let mut t = toml::Table::new();
for (k, v) in [("provider", provider), ("model", model)] {
if let Some(v) = v {
t.insert(k.into(), toml::Value::String(v));
}
}
t.insert("used".into(), toml::Value::Integer(tick));
projects.insert(project.to_string(), toml::Value::Table(t));
}
while projects.len() > MAX_PROJECTS {
let Some(lru) = projects
.iter()
.min_by_key(|(_, v)| used(v))
.map(|(k, _)| k.clone())
else {
break;
};
projects.remove(&lru);
}
if projects.is_empty() {
doc.remove("projects");
}
}
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: the choice is remembered PER PROJECT, under
# [projects.\"<repo root>\"], and restored the next time newt starts there.
# Only choices that differ from [session] (your own default, hand-set) are
# kept; at most 32 projects, least recently SWITCHED dropped (reads never touch it), missing (or unmounted) paths pruned.
# Restore is the LOWEST precedence: an explicit NEWT_PROVIDER / NEWT_DGX_MODEL
# in the environment, a --backend-* flag or a --loadout, or a resumed
# conversation's pin, always wins. Delete this file to forget every choice.
#
# 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)
}
fn root_for(cwd: &Path, exists: impl Fn(&Path) -> bool) -> PathBuf {
crate::config::find_ancestor_dir(cwd, Path::new(".git"), exists)
.and_then(|git| git.parent().map(Path::to_path_buf))
.unwrap_or_else(|| cwd.to_path_buf())
}
static WORKSPACE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
pub fn set_workspace(dir: &Path) {
if let Ok(mut w) = WORKSPACE.lock() {
*w = Some(dir.to_path_buf());
}
}
fn key_for(workspace: Option<&Path>, cwd: &Path, exists: impl Fn(&Path) -> bool) -> PathBuf {
root_for(workspace.unwrap_or(cwd), exists)
}
fn project_key() -> Option<String> {
let cwd = std::env::current_dir().ok()?;
let ws = WORKSPACE.lock().ok().and_then(|w| w.clone());
let root = key_for(ws.as_deref(), &cwd, |p| p.exists());
Some(
root.canonicalize()
.unwrap_or(root)
.to_string_lossy()
.into_owned(),
)
}
pub fn load() -> Session {
let Some(path) = settings_path() else {
return Session::default();
};
let doc = read_doc(&path);
match project_key() {
Some(key) => Session::for_project(&doc, &key),
None => Session::from_doc(&doc),
}
}
fn update(choice: Choice<'_>, default_provider: Option<&str>) {
let (Some(path), Some(key)) = (settings_path(), project_key()) else {
return;
};
let mut doc = read_doc(&path);
apply_project_choice(&mut doc, &key, choice, default_provider, |p| {
Path::new(p).exists()
});
let _ = write_doc(&path, &doc);
}
pub fn record_provider(name: &str, default_provider: Option<&str>) {
update(Choice::Provider(name), default_provider);
}
pub fn record_model(name: &str, default_provider: Option<&str>) {
update(Choice::Model(name), default_provider);
}
#[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()),
model_from_project: false,
};
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()),
model_from_project: false,
};
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()),
model_from_project: false,
};
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()),
model_from_project: false,
};
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()),
model_from_project: false,
};
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"));
}
fn all_exist(_: &str) -> bool {
true
}
fn switch(d: &mut toml::Table, proj: &str, c: Choice<'_>) {
apply_project_choice(d, proj, c, Some("dflt"), all_exist);
}
#[test]
fn a_switch_in_one_project_does_not_move_another() {
let mut d = doc("[session]\nprovider = \"g\"\n");
switch(&mut d, "/a", Choice::Provider("y"));
assert_eq!(
Session::for_project(&d, "/a").provider.as_deref(),
Some("y")
);
assert_eq!(
Session::for_project(&d, "/b").provider.as_deref(),
Some("g")
);
assert_eq!(Session::from_doc(&d).provider.as_deref(), Some("g"));
}
#[test]
fn switching_back_removes_the_entry() {
let mut d = doc("[session]\nprovider = \"g\"\n");
switch(&mut d, "/a", Choice::Provider("y"));
switch(&mut d, "/a", Choice::Provider("g"));
assert!(!d.contains_key("projects"));
let mut d = doc("");
switch(&mut d, "/a", Choice::Provider("y"));
switch(&mut d, "/a", Choice::Provider("dflt"));
assert!(!d.contains_key("projects"));
}
#[test]
fn model_is_per_project_and_a_provider_switch_clears_it() {
let mut d = doc("[session]\nprovider = \"g\"\nmodel = \"gm\"\n");
switch(&mut d, "/a", Choice::Model("m1"));
let s = Session::for_project(&d, "/a");
assert_eq!(
(s.provider.as_deref(), s.model.as_deref()),
(Some("g"), Some("m1"))
);
switch(&mut d, "/a", Choice::Provider("y"));
let s = Session::for_project(&d, "/a");
assert_eq!(
(s.provider.as_deref(), s.model.as_deref()),
(Some("y"), None)
);
}
#[test]
fn the_table_is_capped_and_evicts_the_lru() {
let mut d = doc("");
for i in 0..=MAX_PROJECTS {
switch(&mut d, &format!("/p{i}"), Choice::Provider("y"));
}
let t = d["projects"].as_table().expect("projects table");
assert_eq!(t.len(), MAX_PROJECTS);
assert!(!t.contains_key("/p0"), "the oldest entry is evicted");
assert!(t.contains_key(&format!("/p{MAX_PROJECTS}")));
}
#[test]
fn a_reused_project_is_not_the_lru() {
let mut d = doc("");
for i in 0..MAX_PROJECTS {
switch(&mut d, &format!("/p{i}"), Choice::Provider("y"));
}
switch(&mut d, "/p0", Choice::Provider("z"));
switch(&mut d, "/new", Choice::Provider("y"));
let t = d["projects"].as_table().expect("projects table");
assert!(t.contains_key("/p0") && !t.contains_key("/p1"));
}
#[test]
fn a_deleted_path_is_pruned_on_the_next_write() {
let mut d = doc("");
switch(&mut d, "/gone", Choice::Provider("y"));
apply_project_choice(&mut d, "/here", Choice::Provider("y"), Some("dflt"), |p| {
p != "/gone"
});
let t = d["projects"].as_table().expect("projects table");
assert!(!t.contains_key("/gone") && t.contains_key("/here"));
}
#[test]
fn ephemeral_writes_nothing() {
assert!(!should_persist(true));
}
#[test]
fn a_project_scoped_model_restores_despite_a_stale_global_provider() {
let mut d = doc("[session]\nprovider = \"ollama\"\n");
switch(&mut d, "/a", Choice::Model("ornith-1.5-35b"));
let s = Session::for_project(&d, "/a");
assert_eq!(s.model.as_deref(), Some("ornith-1.5-35b"));
let r = s.restore(Some("llama-router"), false, all_exist);
assert_eq!(
r.model.as_deref(),
Some("ornith-1.5-35b"),
"a project's own remembered model must not be gated on a stale \
global provider"
);
}
#[test]
fn explicit_project_provider_still_gates_the_model_on_a_mismatch() {
let mut d = doc("");
switch(&mut d, "/a", Choice::Provider("A"));
switch(&mut d, "/a", Choice::Model("A-model"));
let s = Session::for_project(&d, "/a");
assert_eq!(
(s.provider.as_deref(), s.model.as_deref()),
(Some("A"), Some("A-model"))
);
let r = s.restore(Some("B"), false, all_exist);
assert_eq!(
r.model, None,
"a model coupled to an explicit project provider must not \
follow onto a different active backend"
);
}
#[test]
fn a_pinned_provider_beats_the_project_entry() {
let mut d = doc("");
switch(&mut d, "/a", Choice::Provider("y"));
let r = Session::for_project(&d, "/a").restore(Some("flag"), false, |_| true);
assert_eq!(r.provider, None);
}
#[test]
fn project_write_preserves_unknown_content() {
let mut d = doc("[custom]\nk = 1\n");
switch(&mut d, "/a", Choice::Provider("y"));
assert!(d.contains_key("custom"));
assert!(to_toml_string(&d).expect("ser").contains("PER PROJECT"));
}
#[test]
fn root_is_the_git_ancestor_else_cwd() {
let repo = Path::new("/r");
let has_git = |p: &Path| p == repo.join(".git");
assert_eq!(root_for(Path::new("/r/a/b"), has_git), repo);
assert_eq!(root_for(Path::new("/r"), has_git), repo);
assert_eq!(root_for(Path::new("/x/y"), |_| false), Path::new("/x/y"));
}
#[test]
fn the_workspace_beats_the_process_cwd() {
let has_git = |p: &Path| p == Path::new("/a/.git") || p == Path::new("/b/.git");
assert_eq!(
key_for(Some(Path::new("/a/sub")), Path::new("/b"), has_git),
Path::new("/a")
);
assert_eq!(key_for(None, Path::new("/b"), has_git), Path::new("/b"));
let mut d = doc("");
switch(&mut d, "/b", Choice::Provider("y"));
assert_eq!(Session::for_project(&d, "/a").provider, None);
}
}