use std::path::Path;
use crate::config::{Config, ConfigBase};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigEditError {
#[error("Unknown configuration key: {key}\nSupported keys:\n\t{supported_keys}")]
UnknownKey { key: String, supported_keys: String },
#[error("Invalid value for '{key}': {source}")]
InvalidValue {
key: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("TOML serialization error: {0}")]
TomlSerializeError(#[from] toml::ser::Error),
}
fn split_key(key: &str) -> Vec<String> {
let mut segments = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
for c in key.chars() {
match (c, quote) {
(q, Some(open)) if q == open => quote = None,
('"' | '\'', None) => quote = Some(c),
('.', None) => {
segments.push(std::mem::take(&mut current));
current.clear();
}
_ => current.push(c),
}
}
segments.push(current);
segments
}
fn json_to_toml(value: serde_json::Value) -> toml::Value {
match value {
serde_json::Value::Null => toml::Value::String("null".to_string()),
serde_json::Value::Bool(b) => toml::Value::Boolean(b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
toml::Value::Integer(i)
} else {
toml::Value::Float(n.as_f64().unwrap_or(f64::NAN))
}
}
serde_json::Value::String(s) => toml::Value::String(s),
serde_json::Value::Array(values) => {
toml::Value::Array(values.into_iter().map(json_to_toml).collect())
}
serde_json::Value::Object(map) => {
toml::Value::Table(map.into_iter().map(|(k, v)| (k, json_to_toml(v))).collect())
}
}
}
fn parse_value(raw: &str) -> toml::Value {
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(json) => json_to_toml(json),
Err(_) => toml::Value::String(raw.to_string()),
}
}
fn insert_path(table: &mut toml::Table, segments: &[String], value: toml::Value) {
let (last, intermediate) = segments
.split_last()
.expect("path has at least one segment");
let mut current = table;
for segment in intermediate {
let entry = current
.entry(segment.clone())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if !entry.is_table() {
*entry = toml::Value::Table(toml::Table::new());
}
current = entry.as_table_mut().expect("just ensured this is a table");
}
current.insert(last.clone(), value);
}
fn remove_path(table: &mut toml::Table, segments: &[String]) -> bool {
let (last, intermediate) = segments
.split_last()
.expect("path has at least one segment");
let mut current = table;
for segment in intermediate {
match current.get_mut(segment).and_then(toml::Value::as_table_mut) {
Some(next) => current = next,
None => return false,
}
}
current.remove(last).is_some()
}
fn is_known_key(key: &str, known: &[String]) -> bool {
known.iter().any(|k| {
k == key
|| k.strip_prefix(key)
.is_some_and(|rest| rest.starts_with('.'))
|| key
.strip_prefix(k.as_str())
.is_some_and(|rest| rest.starts_with('.'))
})
}
impl<T> ConfigBase<T>
where
T: Config,
{
pub fn set(&mut self, key: &str, value: Option<String>) -> Result<(), ConfigEditError> {
let segments = split_key(key);
let unknown_key = |config: &Self| ConfigEditError::UnknownKey {
key: key.to_string(),
supported_keys: config.keys().join(",\n\t"),
};
if segments.iter().any(String::is_empty) {
return Err(unknown_key(self));
}
let mut table = toml::Table::try_from(&*self)?;
if let Some(raw) = value {
let parsed = parse_value(&raw);
let retry_as_string = !matches!(parsed, toml::Value::String(_));
insert_path(&mut table, &segments, parsed);
match self.rebuild_from(&table, key) {
Ok(()) => Ok(()),
Err(first_error) if retry_as_string => {
insert_path(&mut table, &segments, toml::Value::String(raw));
self.rebuild_from(&table, key)
.map_err(|_retry_error| first_error)
}
Err(err) => Err(err),
}
} else {
if !is_known_key(key, &self.keys()) {
return Err(unknown_key(self));
}
remove_path(&mut table, &segments);
self.rebuild_from(&table, key)
}
}
fn rebuild_from(&mut self, table: &toml::Table, key: &str) -> Result<(), ConfigEditError> {
let rendered = toml::to_string(table)?;
let (mut new_config, unused) =
Self::from_toml_str(&rendered).map_err(|e| ConfigEditError::InvalidValue {
key: key.to_string(),
source: Box::new(e),
})?;
if !unused.is_empty() {
return Err(ConfigEditError::UnknownKey {
key: key.to_string(),
supported_keys: self.keys().join(",\n\t"),
});
}
new_config.loaded_from = std::mem::take(&mut self.loaded_from);
new_config.common.channel_config = self.common.channel_config.clone();
*self = new_config;
Ok(())
}
pub fn save(&self, to: &Path) -> Result<(), ConfigEditError> {
let contents = self.to_toml()?;
tracing::debug!("Saving config to: {}", to.display());
let parent = to.parent().expect("config path should have a parent");
fs_err::create_dir_all(parent)?;
fs_err::write(to, contents)?;
Ok(())
}
pub fn to_toml(&self) -> Result<String, ConfigEditError> {
toml::to_string_pretty(&self).map_err(ConfigEditError::TomlSerializeError)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_key_plain() {
assert_eq!(
split_key("concurrency.solves"),
vec!["concurrency", "solves"]
);
}
#[test]
fn split_key_quoted_segment_keeps_dots() {
assert_eq!(
split_key(r#"mirrors."https://conda.anaconda.org""#),
vec!["mirrors", "https://conda.anaconda.org"]
);
}
#[test]
fn parse_value_json_and_fallback() {
assert_eq!(parse_value("true"), toml::Value::Boolean(true));
assert_eq!(parse_value("5"), toml::Value::Integer(5));
assert_eq!(
parse_value("plain string"),
toml::Value::String("plain string".to_string())
);
assert!(matches!(parse_value("[1, 2]"), toml::Value::Array(_)));
assert!(matches!(parse_value(r#"{"a": 1}"#), toml::Value::Table(_)));
}
}