1use std::path::Path;
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum SetError {
8 #[error("io error reading {path:?}: {source}")]
9 Io {
10 path: std::path::PathBuf,
11 source: std::io::Error,
12 },
13
14 #[error("io error writing {path:?}: {source}")]
15 Write {
16 path: std::path::PathBuf,
17 source: std::io::Error,
18 },
19
20 #[error("could not parse existing file at {path:?}: {source}")]
21 ParseExistingFile {
22 path: std::path::PathBuf,
23 source: toml_edit::TomlError,
24 },
25
26 #[error("invalid value for {key}: expected {expected}, got {value}")]
27 Parse {
28 key: String,
29 value: String,
30 expected: String,
31 },
32
33 #[error("key `{key}` is not settable via `rover config set`; edit the file directly")]
34 Unsettable { key: String },
35
36 #[error("validation failed after writing {key} = {value}: {message}")]
37 Validation {
38 key: String,
39 value: String,
40 message: String,
41 },
42}
43
44struct SettableSpec {
45 key: &'static str,
46 parser: fn(&str) -> Result<toml_edit::Item, String>,
47 expected: &'static str,
48}
49
50fn settable() -> &'static [SettableSpec] {
51 &[
52 SettableSpec {
53 key: "ssrf.level",
54 parser: parse_ssrf_level,
55 expected: "one of: strict, loopback, project, lan, none",
56 },
57 SettableSpec {
58 key: "ssrf.project_root",
59 parser: parse_string,
60 expected: "string",
61 },
62 SettableSpec {
63 key: "fetch.user_agent",
64 parser: parse_string,
65 expected: "string",
66 },
67 SettableSpec {
68 key: "fetch.timeout_secs",
69 parser: parse_int,
70 expected: "integer (seconds)",
71 },
72 SettableSpec {
73 key: "cache.default_ttl",
74 parser: parse_string,
75 expected: "humantime string (e.g. \"1h\")",
76 },
77 SettableSpec {
78 key: "cache.min_ttl",
79 parser: parse_string,
80 expected: "humantime string",
81 },
82 SettableSpec {
83 key: "cache.max_ttl",
84 parser: parse_string,
85 expected: "humantime string",
86 },
87 SettableSpec {
88 key: "cache.override_no_store",
89 parser: parse_bool,
90 expected: "bool",
91 },
92 SettableSpec {
93 key: "cache.store_raw_html",
94 parser: parse_bool,
95 expected: "bool",
96 },
97 SettableSpec {
98 key: "robots.respect",
99 parser: parse_bool,
100 expected: "bool",
101 },
102 SettableSpec {
103 key: "robots.default_ttl",
104 parser: parse_string,
105 expected: "humantime string",
106 },
107 SettableSpec {
108 key: "rate_limit.requests_per_minute_per_domain",
109 parser: parse_int,
110 expected: "integer",
111 },
112 SettableSpec {
113 key: "rate_limit.per_domain_concurrency",
114 parser: parse_int,
115 expected: "integer",
116 },
117 SettableSpec {
118 key: "rate_limit.global_concurrency",
119 parser: parse_int,
120 expected: "integer",
121 },
122 SettableSpec {
123 key: "tokenizer.default",
124 parser: parse_string,
125 expected: "string",
126 },
127 SettableSpec {
128 key: "output.dir",
129 parser: parse_string,
130 expected: "string",
131 },
132 SettableSpec {
133 key: "summarization.default_backend",
134 parser: parse_string,
135 expected: "string",
136 },
137 SettableSpec {
138 key: "summarization.default_mode",
139 parser: parse_summarization_mode,
140 expected: "one of: abstractive, extractive, headlines",
141 },
142 SettableSpec {
143 key: "summarization.default_style",
144 parser: parse_summarization_style,
145 expected: "one of: bullet, prose, executive",
146 },
147 SettableSpec {
148 key: "summarization.fallback_to_extractive",
149 parser: parse_bool,
150 expected: "bool",
151 },
152 SettableSpec {
153 key: "summarization.tables.target_tokens",
154 parser: parse_int,
155 expected: "integer",
156 },
157 SettableSpec {
158 key: "summarization.tables.focus",
159 parser: parse_string,
160 expected: "string",
161 },
162 SettableSpec {
163 key: "debug.har_path",
164 parser: parse_string,
165 expected: "string",
166 },
167 SettableSpec {
168 key: "debug.har_body_cap",
169 parser: parse_string,
170 expected: "humansize string or integer",
171 },
172 SettableSpec {
173 key: "debug.log_level",
174 parser: parse_log_level,
175 expected: "one of: trace, debug, info, warn, error",
176 },
177 SettableSpec {
178 key: "headless.max_concurrent",
179 parser: parse_usize,
180 expected: "integer",
181 },
182 SettableSpec {
183 key: "headless.chrome_executable",
184 parser: parse_string,
185 expected: "string",
186 },
187 SettableSpec {
188 key: "image_captions.default",
189 parser: parse_string,
190 expected: "string",
191 },
192 SettableSpec {
193 key: "image_captions.max_tokens",
194 parser: parse_usize,
195 expected: "integer",
196 },
197 SettableSpec {
198 key: "image_captions.max_per_page",
199 parser: parse_usize,
200 expected: "integer",
201 },
202 SettableSpec {
203 key: "image_captions.min_width",
204 parser: parse_u32,
205 expected: "integer",
206 },
207 SettableSpec {
208 key: "image_captions.min_height",
209 parser: parse_u32,
210 expected: "integer",
211 },
212 SettableSpec {
213 key: "image_captions.max_bytes",
214 parser: parse_human_bytes_v,
215 expected: "humansize string or integer (e.g. \"10MiB\")",
216 },
217 SettableSpec {
218 key: "image_captions.max_concurrent",
219 parser: parse_usize,
220 expected: "integer",
221 },
222 ]
223}
224
225fn parse_string(s: &str) -> Result<toml_edit::Item, String> {
226 Ok(toml_edit::value(s.to_string()))
227}
228
229fn parse_int(s: &str) -> Result<toml_edit::Item, String> {
230 let n: i64 = s.parse().map_err(|_| format!("not an integer: {s}"))?;
231 Ok(toml_edit::value(n))
232}
233
234fn parse_usize(s: &str) -> Result<toml_edit::Item, String> {
235 let n: i64 = s
236 .parse::<usize>()
237 .map(|u| u as i64)
238 .map_err(|_| format!("not a non-negative integer: {s}"))?;
239 Ok(toml_edit::value(n))
240}
241
242fn parse_u32(s: &str) -> Result<toml_edit::Item, String> {
243 let n: i64 = s
244 .parse::<u32>()
245 .map(|u| u as i64)
246 .map_err(|_| format!("not a non-negative 32-bit integer: {s}"))?;
247 Ok(toml_edit::value(n))
248}
249
250fn parse_human_bytes_v(v: &str) -> Result<toml_edit::Item, String> {
251 crate::config::parse_human_bytes(v)?;
254 Ok(toml_edit::value(v.to_string()))
255}
256
257fn parse_bool(s: &str) -> Result<toml_edit::Item, String> {
258 let b = match s {
259 "true" | "1" | "yes" | "on" => true,
260 "false" | "0" | "no" | "off" => false,
261 _ => return Err(format!("not a bool: {s}")),
262 };
263 Ok(toml_edit::value(b))
264}
265
266fn parse_ssrf_level(s: &str) -> Result<toml_edit::Item, String> {
267 match s {
268 "strict" | "loopback" | "project" | "lan" | "none" => Ok(toml_edit::value(s.to_string())),
269 _ => Err(format!("not a valid ssrf level: {s}")),
270 }
271}
272
273fn parse_summarization_mode(s: &str) -> Result<toml_edit::Item, String> {
274 match s {
275 "abstractive" | "extractive" | "headlines" => Ok(toml_edit::value(s.to_string())),
276 _ => Err(format!("not a valid summarization mode: {s}")),
277 }
278}
279
280fn parse_summarization_style(s: &str) -> Result<toml_edit::Item, String> {
281 match s {
282 "bullet" | "prose" | "executive" => Ok(toml_edit::value(s.to_string())),
283 _ => Err(format!("not a valid summarization style: {s}")),
284 }
285}
286
287fn parse_log_level(s: &str) -> Result<toml_edit::Item, String> {
288 match s {
289 "trace" | "debug" | "info" | "warn" | "error" => Ok(toml_edit::value(s.to_string())),
290 _ => Err(format!("not a valid log level: {s}")),
291 }
292}
293
294pub fn apply_set(path: &Path, key: &str, value: &str) -> Result<(), SetError> {
295 let spec = settable()
296 .iter()
297 .find(|s| s.key == key)
298 .ok_or_else(|| SetError::Unsettable {
299 key: key.to_string(),
300 })?;
301 let item = (spec.parser)(value).map_err(|_e| SetError::Parse {
302 key: key.to_string(),
303 value: value.to_string(),
304 expected: spec.expected.to_string(),
305 })?;
306
307 let original = std::fs::read_to_string(path).map_err(|source| SetError::Io {
308 path: path.to_path_buf(),
309 source,
310 })?;
311 let mut doc: toml_edit::DocumentMut =
312 original
313 .parse()
314 .map_err(|source| SetError::ParseExistingFile {
315 path: path.to_path_buf(),
316 source,
317 })?;
318
319 let parts: Vec<&str> = key.split('.').collect();
321 let (leaf, parents) = parts.split_last().expect("non-empty key");
322 let mut cursor: &mut toml_edit::Table = doc.as_table_mut();
323 for p in parents {
324 if !cursor.contains_key(p) {
325 cursor.insert(p, toml_edit::Item::Table(toml_edit::Table::new()));
326 }
327 cursor = cursor
328 .get_mut(p)
329 .and_then(|i| i.as_table_mut())
330 .ok_or_else(|| SetError::Parse {
331 key: key.to_string(),
332 value: value.to_string(),
333 expected: format!("parent `{p}` is not a table"),
334 })?;
335 }
336 let mut new_item = item;
339 if let Some(existing) = cursor.get(leaf)
340 && let (Some(existing_val), Some(new_val)) = (existing.as_value(), new_item.as_value_mut())
341 {
342 let old_decor = existing_val.decor().clone();
343 *new_val.decor_mut() = old_decor;
344 }
345 cursor.insert(leaf, new_item);
346
347 let new_text = doc.to_string();
349 let _: crate::config::Config =
350 toml::from_str(&new_text).map_err(|source| SetError::Validation {
351 key: key.to_string(),
352 value: value.to_string(),
353 message: source.to_string(),
354 })?;
355
356 std::fs::write(path, new_text).map_err(|source| SetError::Write {
358 path: path.to_path_buf(),
359 source,
360 })?;
361 Ok(())
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use tempfile::tempdir;
368
369 #[test]
370 fn unknown_key_is_rejected() {
371 let r = apply_set(std::path::Path::new("/dev/null"), "bogus.key", "x");
372 assert!(matches!(r, Err(SetError::Unsettable { .. })));
373 }
374
375 #[test]
376 fn set_writes_value_and_preserves_comments() {
377 let tmp = tempdir().unwrap();
378 let p = tmp.path().join("rover.toml");
379 std::fs::write(
380 &p,
381 "# header comment\n[ssrf]\nlevel = \"strict\" # was strict\n",
382 )
383 .unwrap();
384 apply_set(&p, "ssrf.level", "loopback").unwrap();
385 let after = std::fs::read_to_string(&p).unwrap();
386 assert!(
387 after.contains("# header comment"),
388 "header dropped: {after}"
389 );
390 assert!(
391 after.contains("level = \"loopback\""),
392 "value not updated: {after}"
393 );
394 assert!(
395 after.contains("# was strict"),
396 "trailing comment dropped: {after}"
397 );
398 }
399
400 #[test]
401 fn set_invalid_value_does_not_modify_file() {
402 let tmp = tempdir().unwrap();
403 let p = tmp.path().join("rover.toml");
404 let original = "[ssrf]\nlevel = \"strict\"\n";
405 std::fs::write(&p, original).unwrap();
406 let r = apply_set(&p, "ssrf.level", "bogus");
407 assert!(matches!(r, Err(SetError::Parse { .. })), "{r:?}");
408 let after = std::fs::read_to_string(&p).unwrap();
409 assert_eq!(after, original, "file modified despite parse failure");
410 }
411
412 #[test]
413 fn set_creates_missing_section() {
414 let tmp = tempdir().unwrap();
415 let p = tmp.path().join("rover.toml");
416 std::fs::write(&p, "").unwrap();
417 apply_set(&p, "ssrf.level", "loopback").unwrap();
418 let after = std::fs::read_to_string(&p).unwrap();
419 assert!(after.contains("[ssrf]"));
420 assert!(after.contains("level = \"loopback\""));
421 }
422
423 #[test]
424 fn set_bool_value_parses_common_forms() {
425 let tmp = tempdir().unwrap();
426 let p = tmp.path().join("rover.toml");
427 std::fs::write(&p, "").unwrap();
428 apply_set(&p, "robots.respect", "false").unwrap();
429 let after = std::fs::read_to_string(&p).unwrap();
430 assert!(after.contains("respect = false"));
431 }
432}