Skip to main content

whycodes_config/
edit.rs

1//! `whycodes config set`: write one dotted key to the global `config.toml`
2//! or the project overlay, after checking the result loads and validates.
3
4use std::path::{Path, PathBuf};
5
6use crate::dotted::{lookup, parse_key, set_in_document};
7use crate::load::{
8    ensure_parent_dir, parse_global_text, parse_project_text, toml_err, write_atomic,
9};
10use crate::types::Config;
11use whycodes_core::{Error, Result};
12
13/// The file [`Config::set_dotted`] writes.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ConfigTarget {
16    /// `config.toml` in the WhyCodes home.
17    Global,
18    /// `<project>/.whycodes/config.toml`.
19    Project,
20}
21
22impl Config {
23    /// The project overlay: `<project>/.whycodes/config.toml`.
24    pub fn project_config_path(project_dir: &Path) -> PathBuf {
25        whycodes_core::project_dir(project_dir).join("config.toml")
26    }
27
28    /// The whole config as a `toml::Value` tree, for dotted lookups.
29    pub fn to_toml_value(&self) -> Result<toml::Value> {
30        map_toml_value(toml::Value::try_from(self))
31    }
32
33    /// Set the dotted `key` to `raw` in one config file and return its path.
34    ///
35    /// `raw` is a TOML literal (bool, number, array, inline table); text
36    /// that is not one, or whose type does not fit the key, is tried as a
37    /// string. Only that key changes in the file: other keys, comments and
38    /// layout stay. Refused, with the file untouched, when the result does
39    /// not load as a config, adds a validation error, would not change the
40    /// effective value (unknown key, or a merge that keeps the lower layer),
41    /// or (project) sets a key the project layer may not set. The first
42    /// write to an existing file copies it to `<file>.bak`.
43    pub fn set_dotted(
44        project_dir: &Path,
45        target: ConfigTarget,
46        key: &str,
47        raw: &str,
48    ) -> Result<PathBuf> {
49        set_dotted_in(&Self::default_path()?, project_dir, target, key, raw)
50    }
51
52    /// Defaults, then `global`, then `project` (what `load_layered` merges
53    /// before its env overlay).
54    fn layered(global: &Config, project: Option<&Config>) -> Config {
55        let merged = Config::default().merge_with(global);
56        match project {
57            Some(project) => merged.merge_with(project),
58            None => merged,
59        }
60    }
61}
62
63pub(crate) fn set_dotted_in(
64    global_path: &Path,
65    project_dir: &Path,
66    target: ConfigTarget,
67    key: &str,
68    raw: &str,
69) -> Result<PathBuf> {
70    let path = parse_key(key)?;
71    let project_path = Config::project_config_path(project_dir);
72    let global_text = read_optional(global_path)?;
73    let project_text = read_optional(&project_path)?;
74    let (file, original) = match target {
75        ConfigTarget::Global => (global_path.to_path_buf(), &global_text),
76        ConfigTarget::Project => (project_path.clone(), &project_text),
77    };
78    // The target first: a malformed target fails here, the other file below.
79    let (text, layer) = patch_text(original.as_deref().unwrap_or_default(), &path, raw)?;
80    let global = parse_global_text(global_text.as_deref().unwrap_or_default())?;
81    let project = project_layer(project_text.as_deref(), &project_path)?;
82    // The layers up to the target, before and after the edit. A global
83    // edit is judged without the project overlay: it applies to every
84    // project, and this one may override it.
85    let (before, after) = match target {
86        ConfigTarget::Global => (
87            Config::layered(&global, None),
88            Config::layered(&layer, None),
89        ),
90        ConfigTarget::Project => {
91            let mut overlay = layer.clone();
92            crate::load::clear_unset_default_agent(&mut overlay, &text);
93            if !overlay.websearch.strip_project_only_keys().is_empty()
94                && path.first().is_some_and(|seg| seg == "websearch")
95            {
96                return Err(Error::Config(format!(
97                    "{key} cannot be set in the project config: the custom search endpoint, its headers and header_env are read only from the global config."
98                )));
99            }
100            (
101                Config::layered(&global, project.as_ref()),
102                Config::layered(&global, Some(&overlay)),
103            )
104        }
105    };
106
107    let wanted = lookup(&layer.to_toml_value()?, &path).cloned();
108    let Some(wanted) = wanted else {
109        return Err(Error::Config(format!("unknown config key: {key}")));
110    };
111    refuse_new_issues(&before, &after)?;
112    if lookup(&after.to_toml_value()?, &path) != Some(&wanted) {
113        return Err(Error::Config(format!(
114            "{key} in {} would have no effect: the merged config keeps the value from a lower layer.",
115            file.display()
116        )));
117    }
118    if original.is_some() {
119        backup_once(&file)?;
120    }
121    ensure_parent_dir(&file)?;
122    write_atomic(&file, text.as_bytes())?;
123    Ok(file)
124}
125
126/// Kept apart from `to_toml_value`: serializing a `Config` does not fail
127/// today, so only a test reaches the error arm.
128fn map_toml_value(r: std::result::Result<toml::Value, toml::ser::Error>) -> Result<toml::Value> {
129    match r {
130        Ok(value) => Ok(value),
131        Err(e) => Err(toml_err(e.to_string())),
132    }
133}
134
135fn read_optional(path: &Path) -> Result<Option<String>> {
136    if !path.exists() {
137        return Ok(None);
138    }
139    Ok(Some(std::fs::read_to_string(path)?))
140}
141
142fn project_layer(text: Option<&str>, path: &Path) -> Result<Option<Config>> {
143    text.map(|text| parse_project_text(text, path).map_err(|e| toml_err(e.to_string())))
144        .transpose()
145}
146
147/// `original` with `path` set from `raw`, and the file it makes, parsed.
148/// `raw` as a TOML literal first, then as a string; the first error wins.
149fn patch_text(original: &str, path: &[String], raw: &str) -> Result<(String, Config)> {
150    let doc: toml_edit::DocumentMut = original.parse().map_err(|e| toml_err(format!("{e}")))?;
151    let as_string = || try_value(&doc, path, toml_edit::Value::from(raw));
152    match raw.parse::<toml_edit::Value>().ok() {
153        Some(value) => {
154            try_value(&doc, path, value).or_else(|typed| as_string().map_err(|_s| typed))
155        }
156        None => as_string(),
157    }
158}
159
160fn try_value(
161    doc: &toml_edit::DocumentMut,
162    path: &[String],
163    value: toml_edit::Value,
164) -> Result<(String, Config)> {
165    let mut doc = doc.clone();
166    set_in_document(&mut doc, path, value)?;
167    let text = doc.to_string();
168    let config = parse_global_text(&text)?;
169    Ok((text, config))
170}
171
172/// Refuse validation errors `after` has and `before` did not. A config that
173/// already has an issue (a provider whose key lives outside the file) can
174/// still change unrelated keys.
175fn refuse_new_issues(before: &Config, after: &Config) -> Result<()> {
176    let with_env = |config: &Config| {
177        let mut config = config.clone();
178        config.apply_env_overrides();
179        config.validation_errors()
180    };
181    let old = with_env(before);
182    let new: Vec<String> = with_env(after)
183        .into_iter()
184        .filter(|issue| !old.contains(issue))
185        .collect();
186    if new.is_empty() {
187        return Ok(());
188    }
189    Err(Error::Config(new.join("; ")))
190}
191
192/// Copy `file` to `<file>.bak` unless that backup already exists, so the
193/// backup keeps the file as it was before the first edit.
194fn backup_once(file: &Path) -> Result<()> {
195    let mut name = file.as_os_str().to_owned();
196    name.push(".bak");
197    let backup = PathBuf::from(name);
198    if !backup.exists() {
199        std::fs::copy(file, &backup)?;
200    }
201    Ok(())
202}
203
204#[cfg(test)]
205#[path = "edit_tests.rs"]
206mod tests;