1use std::{
18 path::{Path, PathBuf},
19 time::{Duration, SystemTime, UNIX_EPOCH},
20};
21
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25use crate::delegate::{
26 records::write_private_json,
27 request::{valid_effort, valid_model_name},
28};
29
30pub(crate) const MAX_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
34
35pub(crate) const EFFORT_OPTIONS: [&str; 3] = ["effort", "reasoning_effort", "thought_level"];
37
38const DEFAULT_VALUE: &str = "default";
41
42const FORMAT_VERSION: u32 = 1;
43const MAX_FILE_BYTES: u64 = 64 * 1024;
44const MAX_VALUES: usize = 64;
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Choice {
49 pub values: Vec<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub current: Option<String>,
53}
54
55impl Choice {
56 pub fn shown(&self) -> Vec<&str> {
58 self.values
59 .iter()
60 .map(String::as_str)
61 .filter(|value| *value != DEFAULT_VALUE)
62 .collect()
63 }
64
65 pub fn named_default(&self) -> Option<&str> {
67 self.current
68 .as_deref()
69 .filter(|value| *value != DEFAULT_VALUE)
70 }
71
72 pub fn offers(&self, value: &str) -> bool {
73 self.values.iter().any(|offered| offered == value)
74 }
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
80pub struct AgentOptions {
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub model: Option<Choice>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub effort: Option<Choice>,
85}
86
87impl AgentOptions {
88 pub(crate) fn from_acp(result: &Value) -> Option<Self> {
92 let options = result.get("configOptions")?.as_array()?;
93 let find = |ids: &[&str], valid: fn(&str) -> bool| {
94 options
95 .iter()
96 .find(|option| {
97 option
98 .get("id")
99 .and_then(Value::as_str)
100 .is_some_and(|id| ids.contains(&id))
101 })
102 .and_then(|option| choice(option, valid))
103 };
104 let found = Self {
105 model: find(&["model"], valid_model_name),
106 effort: find(&EFFORT_OPTIONS, valid_effort),
107 };
108 (found.model.is_some() || found.effort.is_some()).then_some(found)
109 }
110
111 fn sanitized(self) -> Self {
112 let clean = |choice: Option<Choice>, valid: fn(&str) -> bool| {
113 choice.and_then(|choice| {
114 let values: Vec<String> = choice
115 .values
116 .into_iter()
117 .filter(|value| valid(value))
118 .take(MAX_VALUES)
119 .collect();
120 (!values.is_empty()).then(|| Choice {
121 current: choice.current.filter(|value| valid(value)),
122 values,
123 })
124 })
125 };
126 Self {
127 model: clean(self.model, valid_model_name),
128 effort: clean(self.effort, valid_effort),
129 }
130 }
131}
132
133fn choice(option: &Value, valid: fn(&str) -> bool) -> Option<Choice> {
134 let values: Vec<String> = select_values(option)
135 .into_iter()
136 .map(|value| value.shown)
137 .filter(|shown| valid(shown))
138 .take(MAX_VALUES)
139 .collect();
140 if values.is_empty() {
141 return None;
142 }
143 let current = option
144 .get("currentValue")
145 .and_then(Value::as_str)
146 .map(shown)
147 .filter(|value| valid(value));
148 Some(Choice { values, current })
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub(crate) struct SelectValue {
154 pub(crate) shown: String,
156 pub(crate) value: String,
158}
159
160pub(crate) fn select_values(option: &Value) -> Vec<SelectValue> {
163 let entries = option.get("options").and_then(Value::as_array);
164 let mut values = Vec::new();
165 for entry in entries.into_iter().flatten() {
166 match entry.get("options").and_then(Value::as_array) {
167 Some(group) => values.extend(group.iter().filter_map(select_value)),
168 None => values.extend(select_value(entry)),
169 }
170 }
171 values
172}
173
174fn select_value(entry: &Value) -> Option<SelectValue> {
175 let value = entry.get("value")?.as_str()?;
176 Some(SelectValue {
177 shown: shown(value),
178 value: value.to_owned(),
179 })
180}
181
182fn shown(value: &str) -> String {
187 match serde_json::from_str::<Vec<String>>(value) {
188 Ok(parts) if !parts.is_empty() && parts.iter().all(|part| !part.is_empty()) => {
189 parts.join("/")
190 }
191 _ => value.to_owned(),
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198struct Installed {
199 path: PathBuf,
200 size: u64,
201 modified_unix_ms: u64,
202}
203
204impl Installed {
205 fn of(executable: &Path) -> Option<Self> {
206 let path = std::fs::canonicalize(executable).ok()?;
207 let metadata = std::fs::metadata(&path).ok()?;
208 let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
209 Some(Self {
210 path,
211 size: metadata.len(),
212 modified_unix_ms: u64::try_from(modified.as_millis()).unwrap_or(u64::MAX),
213 })
214 }
215}
216
217#[derive(Debug, Serialize, Deserialize)]
219struct Saved {
220 version: u32,
221 agent: String,
222 executable: Installed,
223 seen_unix: u64,
224 #[serde(flatten)]
225 options: AgentOptions,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
230pub(crate) struct Listed {
231 pub(crate) options: AgentOptions,
232 pub(crate) seen: SystemTime,
233}
234
235impl Listed {
236 pub(crate) fn fresh(&self, now: SystemTime) -> bool {
238 now.duration_since(self.seen)
239 .map_or(true, |age| age <= MAX_AGE)
240 }
241}
242
243pub(crate) fn save(
246 file: &Path,
247 agent: &str,
248 executable: &Path,
249 options: &AgentOptions,
250) -> std::io::Result<()> {
251 let (Some(dir), Some(name)) = (
252 file.parent(),
253 file.file_name().and_then(|name| name.to_str()),
254 ) else {
255 return Err(std::io::Error::other("agent options file has no directory"));
256 };
257 let executable = Installed::of(executable)
258 .ok_or_else(|| std::io::Error::other("the ACP server's file could not be read"))?;
259 write_private_json(
260 dir,
261 name,
262 &Saved {
263 version: FORMAT_VERSION,
264 agent: agent.to_owned(),
265 executable,
266 seen_unix: unix_seconds(SystemTime::now()),
267 options: options.clone(),
268 },
269 )
270}
271
272pub(crate) fn load(file: &Path, agent: &str, executable: &Path, now: SystemTime) -> Option<Listed> {
276 let saved = read(file)?;
277 let listed = Listed {
278 options: saved.options,
279 seen: UNIX_EPOCH + Duration::from_secs(saved.seen_unix),
280 };
281 (saved.agent == agent
282 && listed.fresh(now)
283 && Installed::of(executable).as_ref() == Some(&saved.executable))
284 .then_some(listed)
285}
286
287pub fn read_saved(file: &Path, agent: &str) -> Option<(AgentOptions, u64)> {
290 read(file)
291 .filter(|saved| saved.agent == agent)
292 .map(|saved| (saved.options, saved.seen_unix))
293}
294
295fn read(file: &Path) -> Option<Saved> {
296 let mut bytes = Vec::new();
297 let opened = std::fs::File::open(file).ok()?;
298 std::io::Read::read_to_end(
299 &mut std::io::Read::take(opened, MAX_FILE_BYTES + 1),
300 &mut bytes,
301 )
302 .ok()?;
303 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES {
304 return None;
305 }
306 let saved: Saved = serde_json::from_slice(&bytes).ok()?;
307 (saved.version == FORMAT_VERSION).then(|| Saved {
308 options: saved.options.sanitized(),
309 ..saved
310 })
311}
312
313fn unix_seconds(time: SystemTime) -> u64 {
314 time.duration_since(UNIX_EPOCH)
315 .map_or(0, |elapsed| elapsed.as_secs())
316}
317
318#[cfg(test)]
319mod tests;