Skip to main content

rover/config/
edit.rs

1//! `rover config set` — settable-key whitelist + value parsers + writer.
2
3use 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    // Validate that the string parses correctly, but persist it as a string
252    // so that the config file stays human-readable (e.g. "10MiB").
253    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    // Walk to the parent table, creating intermediates as needed.
320    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    // Preserve any existing decor (leading/trailing comments and whitespace)
337    // on the value being overwritten.
338    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    // Serialize, validate by round-trip through Config::deserialize.
348    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    // Write. Original is preserved by not touching the file on the failure path.
357    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}