use std::path::{Path, PathBuf};
use crate::dotted::{lookup, parse_key, set_in_document};
use crate::load::{
ensure_parent_dir, parse_global_text, parse_project_text, toml_err, write_atomic,
};
use crate::types::Config;
use whycodes_core::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigTarget {
Global,
Project,
}
impl Config {
pub fn project_config_path(project_dir: &Path) -> PathBuf {
whycodes_core::project_dir(project_dir).join("config.toml")
}
pub fn to_toml_value(&self) -> Result<toml::Value> {
map_toml_value(toml::Value::try_from(self))
}
pub fn set_dotted(
project_dir: &Path,
target: ConfigTarget,
key: &str,
raw: &str,
) -> Result<PathBuf> {
set_dotted_in(&Self::default_path()?, project_dir, target, key, raw)
}
fn layered(global: &Config, project: Option<&Config>) -> Config {
let merged = Config::default().merge_with(global);
match project {
Some(project) => merged.merge_with(project),
None => merged,
}
}
}
pub(crate) fn set_dotted_in(
global_path: &Path,
project_dir: &Path,
target: ConfigTarget,
key: &str,
raw: &str,
) -> Result<PathBuf> {
let path = parse_key(key)?;
let project_path = Config::project_config_path(project_dir);
let global_text = read_optional(global_path)?;
let project_text = read_optional(&project_path)?;
let (file, original) = match target {
ConfigTarget::Global => (global_path.to_path_buf(), &global_text),
ConfigTarget::Project => (project_path.clone(), &project_text),
};
let (text, layer) = patch_text(original.as_deref().unwrap_or_default(), &path, raw)?;
let global = parse_global_text(global_text.as_deref().unwrap_or_default())?;
let project = project_layer(project_text.as_deref(), &project_path)?;
let (before, after) = match target {
ConfigTarget::Global => (
Config::layered(&global, None),
Config::layered(&layer, None),
),
ConfigTarget::Project => {
let mut overlay = layer.clone();
crate::load::clear_unset_default_agent(&mut overlay, &text);
if !overlay.websearch.strip_project_only_keys().is_empty()
&& path.first().is_some_and(|seg| seg == "websearch")
{
return Err(Error::Config(format!(
"{key} cannot be set in the project config: the custom search endpoint, its headers and header_env are read only from the global config."
)));
}
(
Config::layered(&global, project.as_ref()),
Config::layered(&global, Some(&overlay)),
)
}
};
let wanted = lookup(&layer.to_toml_value()?, &path).cloned();
let Some(wanted) = wanted else {
return Err(Error::Config(format!("unknown config key: {key}")));
};
refuse_new_issues(&before, &after)?;
if lookup(&after.to_toml_value()?, &path) != Some(&wanted) {
return Err(Error::Config(format!(
"{key} in {} would have no effect: the merged config keeps the value from a lower layer.",
file.display()
)));
}
if original.is_some() {
backup_once(&file)?;
}
ensure_parent_dir(&file)?;
write_atomic(&file, text.as_bytes())?;
Ok(file)
}
fn map_toml_value(r: std::result::Result<toml::Value, toml::ser::Error>) -> Result<toml::Value> {
match r {
Ok(value) => Ok(value),
Err(e) => Err(toml_err(e.to_string())),
}
}
fn read_optional(path: &Path) -> Result<Option<String>> {
if !path.exists() {
return Ok(None);
}
Ok(Some(std::fs::read_to_string(path)?))
}
fn project_layer(text: Option<&str>, path: &Path) -> Result<Option<Config>> {
text.map(|text| parse_project_text(text, path).map_err(|e| toml_err(e.to_string())))
.transpose()
}
fn patch_text(original: &str, path: &[String], raw: &str) -> Result<(String, Config)> {
let doc: toml_edit::DocumentMut = original.parse().map_err(|e| toml_err(format!("{e}")))?;
let as_string = || try_value(&doc, path, toml_edit::Value::from(raw));
match raw.parse::<toml_edit::Value>().ok() {
Some(value) => {
try_value(&doc, path, value).or_else(|typed| as_string().map_err(|_s| typed))
}
None => as_string(),
}
}
fn try_value(
doc: &toml_edit::DocumentMut,
path: &[String],
value: toml_edit::Value,
) -> Result<(String, Config)> {
let mut doc = doc.clone();
set_in_document(&mut doc, path, value)?;
let text = doc.to_string();
let config = parse_global_text(&text)?;
Ok((text, config))
}
fn refuse_new_issues(before: &Config, after: &Config) -> Result<()> {
let with_env = |config: &Config| {
let mut config = config.clone();
config.apply_env_overrides();
config.validation_errors()
};
let old = with_env(before);
let new: Vec<String> = with_env(after)
.into_iter()
.filter(|issue| !old.contains(issue))
.collect();
if new.is_empty() {
return Ok(());
}
Err(Error::Config(new.join("; ")))
}
fn backup_once(file: &Path) -> Result<()> {
let mut name = file.as_os_str().to_owned();
name.push(".bak");
let backup = PathBuf::from(name);
if !backup.exists() {
std::fs::copy(file, &backup)?;
}
Ok(())
}
#[cfg(test)]
#[path = "edit_tests.rs"]
mod tests;