1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
//! Lane defaults — the user/concierge's chosen default model per
//! use-case lane, optionally scoped to a project (Phase D1).
//!
//! There was no per-use-case default-model config anywhere before this;
//! routing was fully adaptive. The concierge's "set it up" action needs
//! a durable place to record "use model X for the coding lane (in this
//! project)", and routing can consult it as a strong preference. A
//! global default (`project = None`) applies everywhere; a project entry
//! overrides it for that project — model fit is frequently
//! project-specific (a heavy coder for a big Rust repo, something light
//! for notes).
//!
//! Persisted at `lane-defaults.json` under the CAR state root
//! (`~/.car/lane-defaults.json`; `CAR_HOME` moves it). Non-secret config. Pure
//! over its inputs; the path I/O is at the edges.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::intent::UseCase;
/// File name under the CAR state root (`~/.car/` by default).
pub const LANE_DEFAULTS_FILE: &str = "lane-defaults.json";
/// One default: model for a lane, optionally scoped to a project.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LaneDefault {
/// Project/workspace id this applies to; `None` is the global default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
pub use_case: UseCase,
pub model_id: String,
/// When it was set (unix secs) — for audit / last-write-wins display.
#[serde(default)]
pub set_at: u64,
}
/// The whole `lane-defaults.json` document.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LaneDefaults {
#[serde(default)]
pub defaults: Vec<LaneDefault>,
}
impl LaneDefaults {
/// Resolve the default model for `(project, use_case)`: a matching
/// project entry wins; otherwise the global (`None`) entry; otherwise
/// `None` (fall back to adaptive routing).
pub fn resolve(&self, project: Option<&str>, use_case: UseCase) -> Option<&str> {
// Project-specific first.
if let Some(p) = project {
if let Some(d) = self
.defaults
.iter()
.find(|d| d.use_case == use_case && d.project.as_deref() == Some(p))
{
return Some(&d.model_id);
}
}
// Global fallback.
self.defaults
.iter()
.find(|d| d.use_case == use_case && d.project.is_none())
.map(|d| d.model_id.as_str())
}
/// Set (or replace) the default for `(project, use_case)`.
pub fn set(&mut self, project: Option<String>, use_case: UseCase, model_id: String, now: u64) {
self.defaults
.retain(|d| !(d.use_case == use_case && d.project == project));
self.defaults.push(LaneDefault {
project,
use_case,
model_id,
set_at: now,
});
}
/// Remove the default for `(project, use_case)`. Returns whether one
/// was removed.
pub fn clear(&mut self, project: Option<&str>, use_case: UseCase) -> bool {
let before = self.defaults.len();
self.defaults
.retain(|d| !(d.use_case == use_case && d.project.as_deref() == project));
self.defaults.len() != before
}
}
/// Default path: `lane-defaults.json` under the CAR state root —
/// `~/.car/lane-defaults.json` unless `CAR_HOME` moves the root, in which case
/// the lane defaults move with it. Which model a lane routes to is per-daemon
/// configuration, not a shared cache, so two daemons can hold different
/// defaults without fighting over one file.
pub fn default_path() -> PathBuf {
car_home::root_or_relative().join(LANE_DEFAULTS_FILE)
}
/// Load, treating a missing/garbage file as empty (config is best-effort;
/// a corrupt file must not break routing).
pub fn load_from(path: &Path) -> LaneDefaults {
match std::fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => LaneDefaults::default(),
}
}
/// Save atomically (temp + rename), creating parent dirs.
pub fn save_to(path: &Path, defaults: &LaneDefaults) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(defaults).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_overrides_global_then_falls_back() {
let mut d = LaneDefaults::default();
d.set(None, UseCase::Coding, "global-coder".into(), 1);
d.set(
Some("repoA".into()),
UseCase::Coding,
"repoA-coder".into(),
2,
);
// Project-specific wins.
assert_eq!(
d.resolve(Some("repoA"), UseCase::Coding),
Some("repoA-coder")
);
// Unknown project → global fallback.
assert_eq!(
d.resolve(Some("repoB"), UseCase::Coding),
Some("global-coder")
);
// No project → global.
assert_eq!(d.resolve(None, UseCase::Coding), Some("global-coder"));
// Unset lane → None (adaptive).
assert_eq!(d.resolve(None, UseCase::Assistant), None);
}
#[test]
fn set_replaces_and_clear_removes() {
let mut d = LaneDefaults::default();
d.set(None, UseCase::Assistant, "a1".into(), 1);
d.set(None, UseCase::Assistant, "a2".into(), 2); // replace
assert_eq!(d.defaults.len(), 1);
assert_eq!(d.resolve(None, UseCase::Assistant), Some("a2"));
assert!(d.clear(None, UseCase::Assistant));
assert!(!d.clear(None, UseCase::Assistant)); // already gone
assert_eq!(d.resolve(None, UseCase::Assistant), None);
}
#[test]
fn roundtrip_to_disk() {
let dir = std::env::temp_dir().join("car-lane-defaults-test");
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join(LANE_DEFAULTS_FILE);
let mut d = LaneDefaults::default();
d.set(Some("p".into()), UseCase::Coding, "m".into(), 5);
save_to(&path, &d).unwrap();
let loaded = load_from(&path);
assert_eq!(loaded.resolve(Some("p"), UseCase::Coding), Some("m"));
let _ = std::fs::remove_dir_all(&dir);
}
}