#![cfg(feature = "editor")]
use serde::{de::DeserializeOwned, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::{Editor, EditorError, LayerEditor, SettingsEditor, SettingsLoaderEditor};
use crate::provenance::{SettingSource, SourceMap};
#[derive(Debug, Default)]
pub struct ConfigEditor {
source_map: SourceMap,
editors: HashMap<PathBuf, Editor>,
default_target: Option<PathBuf>,
}
impl ConfigEditor {
pub fn new(source_map: SourceMap) -> Self {
Self {
source_map,
editors: HashMap::new(),
default_target: None,
}
}
pub fn set_default_target(&mut self, path: PathBuf) {
self.default_target = Some(path);
}
pub fn source_map(&self) -> &SourceMap {
&self.source_map
}
pub fn get<T: DeserializeOwned>(&mut self, key: &str) -> Result<Option<T>, EditorError> {
let path = {
let metadata = match self.source_map.source_of(key) {
Some(meta) => meta,
None => return Ok(None),
};
match &metadata.source {
SettingSource::File { path, .. } => path.clone(),
_ => return Ok(None),
}
};
let editor = self.get_or_open_editor(&path)?;
Ok(editor.get(key))
}
pub fn set<T: Serialize>(&mut self, key: &str, value: T) -> Result<(), EditorError> {
let (path, is_default_target) = if let Some(meta) = self.source_map.source_of(key) {
match &meta.source {
SettingSource::File { path, .. } => (path.clone(), false),
other => {
return Err(EditorError::InvalidPath(format!(
"Source for key '{}' is not a file: {:?}",
key, other
)));
},
}
} else if let Some(target) = &self.default_target {
(target.clone(), true)
} else {
return Err(EditorError::KeyNotFound(format!(
"Key '{}' not found and no default target set",
key
)));
};
let editor = if is_default_target {
self.get_or_create_editor(&path)?
} else {
self.get_or_open_editor(&path)?
};
editor.set(key, value)?;
Ok(())
}
pub fn unset(&mut self, key: &str) -> Result<(), EditorError> {
let path = {
let metadata = self
.source_map
.source_of(key)
.ok_or_else(|| EditorError::KeyNotFound(key.to_string()))?;
match &metadata.source {
SettingSource::File { path, .. } => path.clone(),
_ => {
return Err(EditorError::InvalidPath(format!(
"Source for key '{}' is not a file",
key
)));
},
}
};
let editor = self.get_or_open_editor(&path)?;
editor.unset(key)
}
pub fn save(&mut self) -> Result<(), EditorError> {
for editor in self.editors.values_mut() {
if editor.is_dirty() {
editor.save()?;
}
}
Ok(())
}
pub fn is_dirty(&self) -> bool {
self.editors.values().any(|e| e.is_dirty())
}
pub fn dirty_files(&self) -> Vec<PathBuf> {
self.editors
.iter()
.filter(|(_, e)| e.is_dirty())
.map(|(p, _)| p.clone())
.collect()
}
fn get_or_open_editor(&mut self, path: &Path) -> Result<&mut Editor, EditorError> {
if !self.editors.contains_key(path) {
let editor = SettingsLoaderEditor::open(path)?;
self.editors.insert(path.to_path_buf(), editor);
}
Ok(self.editors.get_mut(path).unwrap())
}
fn get_or_create_editor(&mut self, path: &Path) -> Result<&mut Editor, EditorError> {
if !self.editors.contains_key(path) {
let editor = if path.exists() {
SettingsLoaderEditor::open(path)?
} else {
let format = super::ConfigFormat::from_path(path).ok_or(EditorError::FormatMismatch)?;
SettingsLoaderEditor::create(path, format)?
};
self.editors.insert(path.to_path_buf(), editor);
}
Ok(self.editors.get_mut(path).unwrap())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provenance::{SourceMetadata, SourceScope};
use serde_json::json;
use std::fs;
use tempfile::{tempdir, TempDir};
struct TestEnv {
_temp_dir: TempDir,
file1_path: PathBuf,
file2_path: PathBuf,
file3_path: PathBuf, source_map: SourceMap,
}
fn setup_test_environment() -> TestEnv {
let temp_dir = tempdir().unwrap();
let file1_path = temp_dir.path().join("config1.toml");
let file2_path = temp_dir.path().join("config2.json");
let file3_path = temp_dir.path().join("config3.yaml");
fs::write(
&file1_path,
r#"
[database]
host = "localhost"
port = 5432
enabled = true
api_key = "secret_toml"
"#,
)
.unwrap();
fs::write(
&file2_path,
r#"{
"server": {
"port": 8080,
"timeout": 300
},
"database": {
"host": "remotehost"
},
"feature_flags": {
"new_ui": true
}
}"#,
)
.unwrap();
fs::write(&file3_path, "").unwrap();
let mut source_map = SourceMap::new();
source_map.insert(
"database.host".to_string(),
SourceMetadata::file(file1_path.clone(), Some(SourceScope::ProjectLocal), 0),
);
source_map.insert(
"database.port".to_string(),
SourceMetadata::file(file1_path.clone(), Some(SourceScope::ProjectLocal), 0),
);
source_map.insert(
"database.enabled".to_string(),
SourceMetadata::file(file1_path.clone(), Some(SourceScope::ProjectLocal), 0),
);
source_map.insert(
"api_key".to_string(),
SourceMetadata::file(file1_path.clone(), Some(SourceScope::ProjectLocal), 0),
);
source_map.insert(
"server.port".to_string(),
SourceMetadata::file(file2_path.clone(), Some(SourceScope::UserGlobal), 1),
);
source_map.insert(
"server.timeout".to_string(),
SourceMetadata::file(file2_path.clone(), Some(SourceScope::UserGlobal), 1),
);
source_map.insert(
"database.host".to_string(), SourceMetadata::file(file2_path.clone(), Some(SourceScope::UserGlobal), 1),
);
source_map.insert(
"feature_flags.new_ui".to_string(),
SourceMetadata::file(file2_path.clone(), Some(SourceScope::UserGlobal), 1),
);
TestEnv {
_temp_dir: temp_dir,
file1_path,
file2_path,
file3_path,
source_map,
}
}
#[test]
fn test_config_editor_new_and_source_map() {
let env = setup_test_environment();
let editor = ConfigEditor::new(env.source_map.clone());
assert_eq!(editor.source_map().entries().len(), env.source_map.entries().len());
}
#[test]
fn test_config_editor_set_default_target() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map.clone());
editor.set_default_target(env.file3_path.clone());
assert_eq!(editor.default_target, Some(env.file3_path));
}
#[test]
fn test_config_editor_get_existing_keys() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map);
assert_eq!(editor.get::<u16>("database.port").unwrap(), Some(5432));
assert_eq!(
editor.get::<String>("database.host").unwrap(),
Some("remotehost".to_string())
);
assert_eq!(editor.get::<u16>("server.port").unwrap(), Some(8080));
}
#[test]
fn test_config_editor_get_non_existent_key() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map);
assert_eq!(editor.get::<String>("non_existent.key").unwrap(), None);
}
#[test]
fn test_config_editor_get_key_from_non_file_source() {
let mut source_map = SourceMap::new();
source_map.insert("env_key".to_string(), SourceMetadata::env("ENV_VAR".to_string(), 0));
let mut editor = ConfigEditor::new(source_map);
assert_eq!(editor.get::<String>("env_key").unwrap(), None); }
#[test]
fn test_config_editor_set_existing_key_in_original_source() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map.clone());
editor.set("database.host", "new_remotehost.com").unwrap();
assert!(editor.is_dirty());
assert_eq!(editor.dirty_files(), vec![env.file2_path.clone()]);
editor.save().unwrap();
assert!(!editor.is_dirty());
let file2_content: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&env.file2_path).unwrap()).unwrap();
assert_eq!(file2_content["database"]["host"], json!("new_remotehost.com"));
let file1_content = fs::read_to_string(&env.file1_path).unwrap();
assert!(file1_content.contains("host = \"localhost\"")); }
#[test]
fn test_config_editor_set_new_key_with_default_target() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map.clone());
editor.set_default_target(env.file3_path.clone());
editor.set("new.setting", "new_value").unwrap();
assert!(editor.is_dirty());
assert_eq!(editor.dirty_files(), vec![env.file3_path.clone()]);
editor.save().unwrap();
assert!(!editor.is_dirty());
let file3_content: serde_yaml::Value =
serde_yaml::from_str(&fs::read_to_string(&env.file3_path).unwrap()).unwrap();
assert_eq!(
file3_content["new"]["setting"],
serde_yaml::Value::String("new_value".to_string())
);
}
#[test]
fn test_config_editor_set_new_key_without_default_target_fails() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map.clone());
let res = editor.set("new.setting", "new_value");
assert!(res.is_err());
if let Err(EditorError::KeyNotFound(msg)) = res {
assert!(msg.contains("Key 'new.setting' not found and no default target set"));
} else {
panic!("Expected KeyNotFound error, got {:?}", res);
}
}
#[test]
fn test_config_editor_set_key_from_non_file_source_fails() {
let mut source_map = SourceMap::new();
source_map.insert("env_key".to_string(), SourceMetadata::env("ENV_VAR".to_string(), 0));
let mut editor = ConfigEditor::new(source_map);
editor.set_default_target(PathBuf::from("dummy.yaml"));
let res = editor.set("env_key", "value");
assert!(res.is_err());
if let Err(EditorError::InvalidPath(msg)) = res {
assert!(msg.contains("Source for key 'env_key' is not a file"));
} else {
panic!("Expected InvalidPath error, got {:?}", res);
}
}
#[test]
fn test_config_editor_unset_existing_key_multi_layer() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map.clone());
editor.unset("database.port").unwrap();
assert!(editor.is_dirty());
assert_eq!(editor.dirty_files(), vec![env.file1_path.clone()]);
editor.save().unwrap();
assert!(!editor.is_dirty());
let file1_content = fs::read_to_string(&env.file1_path).unwrap();
assert!(!file1_content.contains("port = 5432")); }
#[test]
fn test_config_editor_unset_key_from_non_file_source_fails() {
let mut source_map = SourceMap::new();
source_map.insert("env_key".to_string(), SourceMetadata::env("ENV_VAR".to_string(), 0));
let mut editor = ConfigEditor::new(source_map);
let res = editor.unset("env_key");
assert!(res.is_err());
if let Err(EditorError::InvalidPath(msg)) = res {
assert!(msg.contains("Source for key 'env_key' is not a file"));
} else {
panic!("Expected InvalidPath error, got {:?}", res);
}
}
#[test]
fn test_config_editor_unset_non_existent_key_fails() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map);
let res = editor.unset("non_existent.key");
assert!(res.is_err());
if let Err(EditorError::KeyNotFound(msg)) = res {
assert_eq!(msg, "non_existent.key");
} else {
panic!("Expected KeyNotFound error, got {:?}", res);
}
}
#[test]
fn test_config_editor_dirty_tracking_and_files_aggregation() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map.clone());
editor.set_default_target(env.file3_path.clone());
editor.set("database.enabled", false).unwrap();
editor.set("server.timeout", 600).unwrap();
editor.set("new_key.sub", "value").unwrap();
assert!(editor.is_dirty());
let mut dirty_files = editor.dirty_files();
dirty_files.sort();
let mut expected_dirty_files = vec![env.file1_path.clone(), env.file2_path.clone(), env.file3_path.clone()];
expected_dirty_files.sort();
assert_eq!(dirty_files, expected_dirty_files);
editor.editors.get_mut(&env.file1_path).unwrap().save().unwrap();
let _editor_after_partial_save = ConfigEditor::new(env.source_map.clone());
let mut editor = ConfigEditor::new(env.source_map.clone());
editor.set_default_target(env.file3_path.clone());
editor.set("database.enabled", false).unwrap();
editor.set("server.timeout", 600).unwrap();
editor.set("new_key.sub", "value").unwrap();
editor.save().unwrap();
assert!(!editor.is_dirty());
assert!(editor.dirty_files().is_empty());
}
#[test]
fn test_config_editor_save_no_dirty_editors() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map);
editor.save().unwrap(); assert!(!editor.is_dirty());
assert!(editor.dirty_files().is_empty());
}
#[test]
fn test_config_editor_get_or_open_editor_new_file() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map.clone());
let new_file_path = env.file3_path.with_file_name("new_test_file.toml");
if new_file_path.exists() {
fs::remove_file(&new_file_path).unwrap();
}
let editor_ref = editor.get_or_open_editor(&new_file_path);
assert!(
editor_ref.is_err(),
"get_or_open_editor should fail on non-existent file"
);
}
#[test]
fn test_config_editor_get_or_open_editor_existing_file() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map.clone());
let editor_ref = editor.get_or_open_editor(&env.file1_path).unwrap();
if let Editor::Toml(toml_editor) = editor_ref {
assert!(!toml_editor.is_dirty());
assert_eq!(toml_editor.get::<u16>("database.port"), Some(5432));
} else {
panic!("Expected Toml editor");
}
assert!(editor.editors.contains_key(&env.file1_path));
}
#[test]
fn test_config_editor_get_or_open_editor_unrecognized_format() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map.clone());
let bad_path = env.file3_path.with_file_name("no_ext");
let res = editor.get_or_open_editor(&bad_path);
assert!(res.is_err());
if let Err(EditorError::FormatMismatch) = res {
} else {
panic!("Expected FormatMismatch, got {:?}", res);
}
}
#[test]
fn test_config_editor_get_or_open_editor_io_error_on_create() {
let env = setup_test_environment();
let mut editor = ConfigEditor::new(env.source_map.clone());
let bad_path = PathBuf::from("/nonexistent/dir/file.json");
let res = editor.get_or_open_editor(&bad_path);
assert!(res.is_err());
if let Err(EditorError::IoError(e)) = res {
assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
} else {
panic!("Expected IoError::NotFound, got {:?}", res);
}
}
#[test]
fn test_config_editor_unset_key_from_nonexistent_file() {
let mut source_map = SourceMap::new();
let non_existent_file = PathBuf::from("/nonexistent/dir/config.toml");
source_map.insert(
"key_in_nonexistent_file".to_string(),
SourceMetadata::file(non_existent_file.clone(), Some(SourceScope::ProjectLocal), 0),
);
let mut editor = ConfigEditor::new(source_map);
let res = editor.unset("key_in_nonexistent_file");
assert!(res.is_err());
if let Err(EditorError::IoError(e)) = res {
assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
} else {
panic!("Expected IoError::NotFound, got {:?}", res);
}
}
#[test]
fn test_config_editor_set_key_from_nonexistent_file() {
let mut source_map = SourceMap::new();
let non_existent_file = PathBuf::from("/nonexistent/dir/config.toml");
source_map.insert(
"key_in_nonexistent_file".to_string(),
SourceMetadata::file(non_existent_file.clone(), Some(SourceScope::ProjectLocal), 0),
);
let mut editor = ConfigEditor::new(source_map);
let res = editor.set("key_in_nonexistent_file", "new_value");
assert!(res.is_err());
if let Err(EditorError::IoError(e)) = res {
assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
} else {
panic!("Expected IoError::NotFound, got {:?}", res);
}
}
}