1use std::{
13 path::{Path, PathBuf},
14 time::{Duration, SystemTime, UNIX_EPOCH},
15};
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::delegate::{
21 records::write_private_json,
22 request::{valid_effort, valid_model_name},
23};
24
25pub(crate) const MAX_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
29
30pub(crate) const EFFORT_OPTIONS: [&str; 3] = ["effort", "reasoning_effort", "thought_level"];
32
33const DEFAULT_VALUE: &str = "default";
36
37const FORMAT_VERSION: u32 = 1;
38const MAX_FILE_BYTES: u64 = 64 * 1024;
39const MAX_VALUES: usize = 64;
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Choice {
44 pub values: Vec<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub current: Option<String>,
48}
49
50impl Choice {
51 pub fn shown(&self) -> Vec<&str> {
53 self.values
54 .iter()
55 .map(String::as_str)
56 .filter(|value| *value != DEFAULT_VALUE)
57 .collect()
58 }
59
60 pub fn named_default(&self) -> Option<&str> {
62 self.current
63 .as_deref()
64 .filter(|value| *value != DEFAULT_VALUE)
65 }
66
67 pub fn offers(&self, value: &str) -> bool {
68 self.values.iter().any(|offered| offered == value)
69 }
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
75pub struct AgentOptions {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub model: Option<Choice>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub effort: Option<Choice>,
80}
81
82impl AgentOptions {
83 pub(crate) fn from_acp(result: &Value) -> Option<Self> {
87 let options = result.get("configOptions")?.as_array()?;
88 let find = |ids: &[&str], valid: fn(&str) -> bool| {
89 options
90 .iter()
91 .find(|option| {
92 option
93 .get("id")
94 .and_then(Value::as_str)
95 .is_some_and(|id| ids.contains(&id))
96 })
97 .and_then(|option| choice(option, valid))
98 };
99 let found = Self {
100 model: find(&["model"], valid_model_name),
101 effort: find(&EFFORT_OPTIONS, valid_effort),
102 };
103 (found.model.is_some() || found.effort.is_some()).then_some(found)
104 }
105
106 fn sanitized(self) -> Self {
107 let clean = |choice: Option<Choice>, valid: fn(&str) -> bool| {
108 choice.and_then(|choice| {
109 let values: Vec<String> = choice
110 .values
111 .into_iter()
112 .filter(|value| valid(value))
113 .take(MAX_VALUES)
114 .collect();
115 (!values.is_empty()).then(|| Choice {
116 current: choice.current.filter(|value| valid(value)),
117 values,
118 })
119 })
120 };
121 Self {
122 model: clean(self.model, valid_model_name),
123 effort: clean(self.effort, valid_effort),
124 }
125 }
126}
127
128fn choice(option: &Value, valid: fn(&str) -> bool) -> Option<Choice> {
129 let values: Vec<String> = option
130 .get("options")?
131 .as_array()?
132 .iter()
133 .filter_map(|value| value.get("value")?.as_str())
134 .filter(|value| valid(value))
135 .take(MAX_VALUES)
136 .map(str::to_owned)
137 .collect();
138 if values.is_empty() {
139 return None;
140 }
141 let current = option
142 .get("currentValue")
143 .and_then(Value::as_str)
144 .filter(|value| valid(value))
145 .map(str::to_owned);
146 Some(Choice { values, current })
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152struct Installed {
153 path: PathBuf,
154 size: u64,
155 modified_unix_ms: u64,
156}
157
158impl Installed {
159 fn of(executable: &Path) -> Option<Self> {
160 let path = std::fs::canonicalize(executable).ok()?;
161 let metadata = std::fs::metadata(&path).ok()?;
162 let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
163 Some(Self {
164 path,
165 size: metadata.len(),
166 modified_unix_ms: u64::try_from(modified.as_millis()).unwrap_or(u64::MAX),
167 })
168 }
169}
170
171#[derive(Debug, Serialize, Deserialize)]
173struct Saved {
174 version: u32,
175 agent: String,
176 executable: Installed,
177 seen_unix: u64,
178 #[serde(flatten)]
179 options: AgentOptions,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub(crate) struct Listed {
185 pub(crate) options: AgentOptions,
186 pub(crate) seen: SystemTime,
187}
188
189impl Listed {
190 pub(crate) fn fresh(&self, now: SystemTime) -> bool {
192 now.duration_since(self.seen)
193 .map_or(true, |age| age <= MAX_AGE)
194 }
195}
196
197pub(crate) fn save(
200 file: &Path,
201 agent: &str,
202 executable: &Path,
203 options: &AgentOptions,
204) -> std::io::Result<()> {
205 let (Some(dir), Some(name)) = (
206 file.parent(),
207 file.file_name().and_then(|name| name.to_str()),
208 ) else {
209 return Err(std::io::Error::other("agent options file has no directory"));
210 };
211 let executable = Installed::of(executable)
212 .ok_or_else(|| std::io::Error::other("the ACP server's file could not be read"))?;
213 write_private_json(
214 dir,
215 name,
216 &Saved {
217 version: FORMAT_VERSION,
218 agent: agent.to_owned(),
219 executable,
220 seen_unix: unix_seconds(SystemTime::now()),
221 options: options.clone(),
222 },
223 )
224}
225
226pub(crate) fn load(file: &Path, agent: &str, executable: &Path, now: SystemTime) -> Option<Listed> {
230 let saved = read(file)?;
231 let listed = Listed {
232 options: saved.options,
233 seen: UNIX_EPOCH + Duration::from_secs(saved.seen_unix),
234 };
235 (saved.agent == agent
236 && listed.fresh(now)
237 && Installed::of(executable).as_ref() == Some(&saved.executable))
238 .then_some(listed)
239}
240
241pub fn read_saved(file: &Path, agent: &str) -> Option<(AgentOptions, u64)> {
244 read(file)
245 .filter(|saved| saved.agent == agent)
246 .map(|saved| (saved.options, saved.seen_unix))
247}
248
249fn read(file: &Path) -> Option<Saved> {
250 let mut bytes = Vec::new();
251 let opened = std::fs::File::open(file).ok()?;
252 std::io::Read::read_to_end(
253 &mut std::io::Read::take(opened, MAX_FILE_BYTES + 1),
254 &mut bytes,
255 )
256 .ok()?;
257 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES {
258 return None;
259 }
260 let saved: Saved = serde_json::from_slice(&bytes).ok()?;
261 (saved.version == FORMAT_VERSION).then(|| Saved {
262 options: saved.options.sanitized(),
263 ..saved
264 })
265}
266
267fn unix_seconds(time: SystemTime) -> u64 {
268 time.duration_since(UNIX_EPOCH)
269 .map_or(0, |elapsed| elapsed.as_secs())
270}
271
272#[cfg(test)]
273mod tests;