1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ConfigTarget {
16 Global,
18 Project,
20}
21
22impl Config {
23 pub fn project_config_path(project_dir: &Path) -> PathBuf {
25 whycodes_core::project_dir(project_dir).join("config.toml")
26 }
27
28 pub fn to_toml_value(&self) -> Result<toml::Value> {
30 map_toml_value(toml::Value::try_from(self))
31 }
32
33 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 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 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 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
126fn 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
147fn 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
172fn 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
192fn 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;