use crate::config::{Config, ConfigError};
use crate::partial_config::{Merge, PartialConfig, SessionConfig};
use serde_json::Value;
use std::path::{Path, PathBuf};
fn strip_nulls(value: Value) -> Option<Value> {
match value {
Value::Null => None,
Value::Object(map) => {
let filtered: serde_json::Map<String, Value> = map
.into_iter()
.filter_map(|(k, v)| strip_nulls(v).map(|v| (k, v)))
.collect();
if filtered.is_empty() {
None
} else {
Some(Value::Object(filtered))
}
}
Value::Array(arr) => {
let filtered: Vec<Value> = arr.into_iter().filter_map(strip_nulls).collect();
Some(Value::Array(filtered))
}
other => Some(other),
}
}
fn strip_empty_defaults(value: Value) -> Option<Value> {
match value {
Value::Null => None,
Value::String(s) if s.is_empty() => None,
Value::Array(arr) if arr.is_empty() => None,
Value::Object(map) => {
let filtered: serde_json::Map<String, Value> = map
.into_iter()
.filter_map(|(k, v)| strip_empty_defaults(v).map(|v| (k, v)))
.collect();
if filtered.is_empty() {
None
} else {
Some(Value::Object(filtered))
}
}
Value::Array(arr) => {
let filtered: Vec<Value> = arr.into_iter().filter_map(strip_empty_defaults).collect();
if filtered.is_empty() {
None
} else {
Some(Value::Array(filtered))
}
}
other => Some(other),
}
}
pub const CURRENT_CONFIG_VERSION: u32 = 1;
pub fn migrate_config(mut value: Value) -> Result<Value, ConfigError> {
let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
if version < 1 {
value = migrate_v0_to_v1(value)?;
}
Ok(value)
}
fn migrate_v0_to_v1(mut value: Value) -> Result<Value, ConfigError> {
if let Value::Object(ref mut map) = value {
map.insert("version".to_string(), Value::Number(1.into()));
if let Some(editor) = map.get_mut("editor") {
if let Value::Object(ref mut editor_map) = editor {
if let Some(val) = editor_map.remove("tabSize") {
editor_map.entry("tab_size").or_insert(val);
}
if let Some(val) = editor_map.remove("lineNumbers") {
editor_map.entry("line_numbers").or_insert(val);
}
}
}
}
Ok(value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigLayer {
System,
User,
Project,
Session,
}
impl ConfigLayer {
pub fn precedence(self) -> u8 {
match self {
Self::System => 0,
Self::User => 1,
Self::Project => 2,
Self::Session => 3,
}
}
}
pub struct ConfigResolver {
dir_context: DirectoryContext,
working_dir: PathBuf,
}
impl ConfigResolver {
pub fn new(dir_context: DirectoryContext, working_dir: PathBuf) -> Self {
Self {
dir_context,
working_dir,
}
}
pub fn resolve(&self) -> Result<Config, ConfigError> {
let mut merged = self.load_session_layer()?.unwrap_or_default();
if let Some(project_partial) = self.load_project_layer()? {
tracing::debug!("Loaded project config layer");
merged.merge_from(&project_partial);
}
if let Some(platform_partial) = self.load_user_platform_layer()? {
tracing::debug!("Loaded user platform config layer");
merged.merge_from(&platform_partial);
}
if let Some(user_partial) = self.load_user_layer()? {
tracing::debug!("Loaded user config layer");
merged.merge_from(&user_partial);
}
Ok(merged.resolve())
}
pub fn user_config_path(&self) -> PathBuf {
self.dir_context.config_path()
}
pub fn project_config_path(&self) -> PathBuf {
let new_path = self.working_dir.join(".fresh").join("config.json");
if new_path.exists() {
return new_path;
}
let legacy_path = self.working_dir.join("config.json");
if legacy_path.exists() {
return legacy_path;
}
new_path
}
pub fn project_config_write_path(&self) -> PathBuf {
self.working_dir.join(".fresh").join("config.json")
}
pub fn session_config_path(&self) -> PathBuf {
self.working_dir.join(".fresh").join("session.json")
}
fn platform_config_filename() -> Option<&'static str> {
if cfg!(target_os = "linux") {
Some("config_linux.json")
} else if cfg!(target_os = "macos") {
Some("config_macos.json")
} else if cfg!(target_os = "windows") {
Some("config_windows.json")
} else {
None
}
}
pub fn user_platform_config_path(&self) -> Option<PathBuf> {
Self::platform_config_filename().map(|filename| self.dir_context.config_dir.join(filename))
}
pub fn load_user_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
self.load_layer_from_path(&self.user_config_path())
}
pub fn load_user_platform_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
if let Some(path) = self.user_platform_config_path() {
self.load_layer_from_path(&path)
} else {
Ok(None)
}
}
pub fn load_project_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
self.load_layer_from_path(&self.project_config_path())
}
pub fn load_session_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
self.load_layer_from_path(&self.session_config_path())
}
fn load_layer_from_path(&self, path: &Path) -> Result<Option<PartialConfig>, ConfigError> {
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
let value: Value = serde_json::from_str(&content)
.map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
let migrated = migrate_config(value)?;
let partial: PartialConfig = serde_json::from_value(migrated)
.map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
Ok(Some(partial))
}
pub fn save_to_layer(&self, config: &Config, layer: ConfigLayer) -> Result<(), ConfigError> {
if layer == ConfigLayer::System {
return Err(ConfigError::ValidationError(
"Cannot write to System layer".to_string(),
));
}
let parent_partial = self.resolve_up_to_layer(layer)?;
let parent = PartialConfig::from(&parent_partial.resolve());
let current = PartialConfig::from(config);
let delta = diff_partial_config(¤t, &parent);
let path = match layer {
ConfigLayer::User => self.user_config_path(),
ConfigLayer::Project => self.project_config_write_path(),
ConfigLayer::Session => self.session_config_path(),
ConfigLayer::System => unreachable!(),
};
if let Some(parent_dir) = path.parent() {
std::fs::create_dir_all(parent_dir)
.map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
}
let delta_value =
serde_json::to_value(&delta).map_err(|e| ConfigError::SerializeError(e.to_string()))?;
let stripped_nulls = strip_nulls(delta_value).unwrap_or(Value::Object(Default::default()));
let clean_delta =
strip_empty_defaults(stripped_nulls).unwrap_or(Value::Object(Default::default()));
let json = serde_json::to_string_pretty(&clean_delta)
.map_err(|e| ConfigError::SerializeError(e.to_string()))?;
std::fs::write(&path, json)
.map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
Ok(())
}
pub fn save_session(&self, session: &SessionConfig) -> Result<(), ConfigError> {
let path = self.session_config_path();
if let Some(parent_dir) = path.parent() {
std::fs::create_dir_all(parent_dir)
.map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
}
let json = serde_json::to_string_pretty(session)
.map_err(|e| ConfigError::SerializeError(e.to_string()))?;
std::fs::write(&path, json)
.map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
tracing::debug!("Saved session config to {}", path.display());
Ok(())
}
pub fn load_session(&self) -> Result<SessionConfig, ConfigError> {
match self.load_session_layer()? {
Some(partial) => Ok(SessionConfig::from(partial)),
None => Ok(SessionConfig::new()),
}
}
pub fn clear_session(&self) -> Result<(), ConfigError> {
let path = self.session_config_path();
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
tracing::debug!("Cleared session config at {}", path.display());
}
Ok(())
}
fn resolve_up_to_layer(&self, layer: ConfigLayer) -> Result<PartialConfig, ConfigError> {
let mut merged = PartialConfig::default();
if layer == ConfigLayer::Session {
if let Some(project) = self.load_project_layer()? {
merged = project;
}
if let Some(platform) = self.load_user_platform_layer()? {
merged.merge_from(&platform);
}
if let Some(user) = self.load_user_layer()? {
merged.merge_from(&user);
}
} else if layer == ConfigLayer::Project {
if let Some(platform) = self.load_user_platform_layer()? {
merged = platform;
}
if let Some(user) = self.load_user_layer()? {
merged.merge_from(&user);
}
}
Ok(merged)
}
pub fn get_layer_sources(
&self,
) -> Result<std::collections::HashMap<String, ConfigLayer>, ConfigError> {
use std::collections::HashMap;
let mut sources: HashMap<String, ConfigLayer> = HashMap::new();
if let Some(session) = self.load_session_layer()? {
let json = serde_json::to_value(&session).unwrap_or_default();
collect_paths(&json, "", &mut |path| {
sources.insert(path, ConfigLayer::Session);
});
}
if let Some(project) = self.load_project_layer()? {
let json = serde_json::to_value(&project).unwrap_or_default();
collect_paths(&json, "", &mut |path| {
sources.entry(path).or_insert(ConfigLayer::Project);
});
}
if let Some(user) = self.load_user_layer()? {
let json = serde_json::to_value(&user).unwrap_or_default();
collect_paths(&json, "", &mut |path| {
sources.entry(path).or_insert(ConfigLayer::User);
});
}
Ok(sources)
}
}
fn collect_paths<F>(value: &Value, prefix: &str, collector: &mut F)
where
F: FnMut(String),
{
match value {
Value::Object(map) => {
for (key, val) in map {
let path = if prefix.is_empty() {
format!("/{}", key)
} else {
format!("{}/{}", prefix, key)
};
collect_paths(val, &path, collector);
}
}
Value::Null => {} _ => {
collector(prefix.to_string());
}
}
}
fn diff_partial_config(current: &PartialConfig, parent: &PartialConfig) -> PartialConfig {
let current_json = serde_json::to_value(current).unwrap_or_default();
let parent_json = serde_json::to_value(parent).unwrap_or_default();
let diff = json_diff(&parent_json, ¤t_json);
serde_json::from_value(diff).unwrap_or_default()
}
impl Config {
fn system_config_paths() -> Vec<PathBuf> {
let mut paths = Vec::with_capacity(2);
#[cfg(target_os = "macos")]
if let Some(home) = dirs::home_dir() {
let path = home.join(".config").join("fresh").join(Config::FILENAME);
if path.exists() {
paths.push(path);
}
}
if let Some(config_dir) = dirs::config_dir() {
let path = config_dir.join("fresh").join(Config::FILENAME);
if !paths.contains(&path) && path.exists() {
paths.push(path);
}
}
paths
}
fn config_search_paths(working_dir: &Path) -> Vec<PathBuf> {
let local = Self::local_config_path(working_dir);
let mut paths = Vec::with_capacity(3);
if local.exists() {
paths.push(local);
}
paths.extend(Self::system_config_paths());
paths
}
pub fn find_config_path(working_dir: &Path) -> Option<PathBuf> {
Self::config_search_paths(working_dir).into_iter().next()
}
pub fn load_with_layers(dir_context: &DirectoryContext, working_dir: &Path) -> Self {
let resolver = ConfigResolver::new(dir_context.clone(), working_dir.to_path_buf());
match resolver.resolve() {
Ok(config) => {
tracing::info!("Loaded layered config for {}", working_dir.display());
config
}
Err(e) => {
tracing::warn!("Failed to load layered config: {}, using defaults", e);
Self::default()
}
}
}
pub fn read_user_config_raw(working_dir: &Path) -> serde_json::Value {
for path in Self::config_search_paths(working_dir) {
if let Ok(contents) = std::fs::read_to_string(&path) {
match serde_json::from_str(&contents) {
Ok(value) => return value,
Err(e) => {
tracing::warn!("Failed to parse config from {}: {}", path.display(), e);
}
}
}
}
serde_json::Value::Object(serde_json::Map::new())
}
}
fn json_diff(defaults: &serde_json::Value, current: &serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match (defaults, current) {
(Value::Object(def_map), Value::Object(cur_map)) => {
let mut result = serde_json::Map::new();
for (key, cur_val) in cur_map {
if let Some(def_val) = def_map.get(key) {
let diff = json_diff(def_val, cur_val);
if !is_empty_diff(&diff) {
result.insert(key.clone(), diff);
}
} else {
if let Some(stripped) = strip_empty_defaults(cur_val.clone()) {
result.insert(key.clone(), stripped);
}
}
}
Value::Object(result)
}
_ => {
if let Value::String(s) = current {
if s.is_empty() {
return Value::Object(serde_json::Map::new()); }
}
if defaults == current {
Value::Object(serde_json::Map::new()) } else {
current.clone()
}
}
}
}
fn is_empty_diff(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Object(map) => map.is_empty(),
_ => false,
}
}
#[derive(Debug, Clone)]
pub struct DirectoryContext {
pub data_dir: std::path::PathBuf,
pub config_dir: std::path::PathBuf,
pub home_dir: Option<std::path::PathBuf>,
pub documents_dir: Option<std::path::PathBuf>,
pub downloads_dir: Option<std::path::PathBuf>,
}
impl DirectoryContext {
pub fn from_system() -> std::io::Result<Self> {
let data_dir = dirs::data_dir()
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not determine data directory",
)
})?
.join("fresh");
#[allow(unused_mut)] let mut config_dir = dirs::config_dir()
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not determine config directory",
)
})?
.join("fresh");
#[cfg(target_os = "macos")]
if let Some(home) = dirs::home_dir() {
let xdg_config = home.join(".config").join("fresh");
if xdg_config.exists() {
config_dir = xdg_config;
}
}
Ok(Self {
data_dir,
config_dir,
home_dir: dirs::home_dir(),
documents_dir: dirs::document_dir(),
downloads_dir: dirs::download_dir(),
})
}
pub fn for_testing(temp_dir: &std::path::Path) -> Self {
Self {
data_dir: temp_dir.join("data"),
config_dir: temp_dir.join("config"),
home_dir: Some(temp_dir.join("home")),
documents_dir: Some(temp_dir.join("documents")),
downloads_dir: Some(temp_dir.join("downloads")),
}
}
pub fn recovery_dir(&self) -> std::path::PathBuf {
self.data_dir.join("recovery")
}
pub fn sessions_dir(&self) -> std::path::PathBuf {
self.data_dir.join("sessions")
}
pub fn search_history_path(&self) -> std::path::PathBuf {
self.data_dir.join("search_history.json")
}
pub fn replace_history_path(&self) -> std::path::PathBuf {
self.data_dir.join("replace_history.json")
}
pub fn terminals_dir(&self) -> std::path::PathBuf {
self.data_dir.join("terminals")
}
pub fn terminal_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
let encoded = crate::session::encode_path_for_filename(working_dir);
self.terminals_dir().join(encoded)
}
pub fn config_path(&self) -> std::path::PathBuf {
self.config_dir.join(Config::FILENAME)
}
pub fn themes_dir(&self) -> std::path::PathBuf {
self.config_dir.join("themes")
}
pub fn grammars_dir(&self) -> std::path::PathBuf {
self.config_dir.join("grammars")
}
pub fn plugins_dir(&self) -> std::path::PathBuf {
self.config_dir.join("plugins")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_resolver() -> (TempDir, ConfigResolver) {
let temp_dir = TempDir::new().unwrap();
let dir_context = DirectoryContext::for_testing(temp_dir.path());
let working_dir = temp_dir.path().join("project");
std::fs::create_dir_all(&working_dir).unwrap();
let resolver = ConfigResolver::new(dir_context, working_dir);
(temp_dir, resolver)
}
#[test]
fn resolver_returns_defaults_when_no_config_files() {
let (_temp, resolver) = create_test_resolver();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 4);
assert!(config.editor.line_numbers);
}
#[test]
fn resolver_loads_user_layer() {
let (temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 2);
assert!(config.editor.line_numbers); drop(temp);
}
#[test]
fn resolver_project_overrides_user() {
let (temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(
&user_config_path,
r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
)
.unwrap();
let project_config_path = resolver.project_config_path();
std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 8); assert!(!config.editor.line_numbers); drop(temp);
}
#[test]
fn resolver_session_overrides_all() {
let (temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
let project_config_path = resolver.project_config_path();
std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 4}}"#).unwrap();
let session_config_path = resolver.session_config_path();
std::fs::write(&session_config_path, r#"{"editor": {"tab_size": 16}}"#).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 16); drop(temp);
}
#[test]
fn layer_precedence_ordering() {
assert!(ConfigLayer::Session.precedence() > ConfigLayer::Project.precedence());
assert!(ConfigLayer::Project.precedence() > ConfigLayer::User.precedence());
assert!(ConfigLayer::User.precedence() > ConfigLayer::System.precedence());
}
#[test]
fn save_to_system_layer_fails() {
let (_temp, resolver) = create_test_resolver();
let config = Config::default();
let result = resolver.save_to_layer(&config, ConfigLayer::System);
assert!(result.is_err());
}
#[test]
fn resolver_loads_legacy_project_config() {
let (temp, resolver) = create_test_resolver();
let working_dir = temp.path().join("project");
let legacy_path = working_dir.join("config.json");
std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 3);
drop(temp);
}
#[test]
fn resolver_prefers_new_config_over_legacy() {
let (temp, resolver) = create_test_resolver();
let working_dir = temp.path().join("project");
let legacy_path = working_dir.join("config.json");
std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
let new_path = working_dir.join(".fresh").join("config.json");
std::fs::create_dir_all(new_path.parent().unwrap()).unwrap();
std::fs::write(&new_path, r#"{"editor": {"tab_size": 5}}"#).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 5); drop(temp);
}
#[test]
fn load_with_layers_works() {
let temp = TempDir::new().unwrap();
let dir_context = DirectoryContext::for_testing(temp.path());
let working_dir = temp.path().join("project");
std::fs::create_dir_all(&working_dir).unwrap();
std::fs::create_dir_all(&dir_context.config_dir).unwrap();
std::fs::write(dir_context.config_path(), r#"{"editor": {"tab_size": 2}}"#).unwrap();
let config = Config::load_with_layers(&dir_context, &working_dir);
assert_eq!(config.editor.tab_size, 2);
}
#[test]
fn platform_config_overrides_user() {
let (temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
if let Some(platform_path) = resolver.user_platform_config_path() {
std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 6); }
drop(temp);
}
#[test]
fn project_overrides_platform() {
let (temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
if let Some(platform_path) = resolver.user_platform_config_path() {
std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
}
let project_config_path = resolver.project_config_path();
std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 10}}"#).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 10); drop(temp);
}
#[test]
fn migration_adds_version() {
let input = serde_json::json!({
"editor": {"tab_size": 2}
});
let migrated = migrate_config(input).unwrap();
assert_eq!(migrated.get("version"), Some(&serde_json::json!(1)));
}
#[test]
fn migration_renames_camelcase_keys() {
let input = serde_json::json!({
"editor": {
"tabSize": 8,
"lineNumbers": false
}
});
let migrated = migrate_config(input).unwrap();
let editor = migrated.get("editor").unwrap();
assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(8)));
assert_eq!(editor.get("line_numbers"), Some(&serde_json::json!(false)));
assert!(editor.get("tabSize").is_none());
assert!(editor.get("lineNumbers").is_none());
}
#[test]
fn migration_preserves_existing_snake_case() {
let input = serde_json::json!({
"version": 1,
"editor": {"tab_size": 4}
});
let migrated = migrate_config(input).unwrap();
let editor = migrated.get("editor").unwrap();
assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(4)));
}
#[test]
fn resolver_loads_legacy_camelcase_config() {
let (temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(
&user_config_path,
r#"{"editor": {"tabSize": 3, "lineNumbers": false}}"#,
)
.unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 3);
assert!(!config.editor.line_numbers);
drop(temp);
}
#[test]
fn save_and_load_session() {
let (_temp, resolver) = create_test_resolver();
let mut session = SessionConfig::new();
session.set_theme(crate::config::ThemeName::from("dark"));
session.set_editor_option(|e| e.tab_size = Some(2));
resolver.save_session(&session).unwrap();
let loaded = resolver.load_session().unwrap();
assert_eq!(loaded.theme, Some(crate::config::ThemeName::from("dark")));
assert_eq!(loaded.editor.as_ref().unwrap().tab_size, Some(2));
}
#[test]
fn clear_session_removes_file() {
let (_temp, resolver) = create_test_resolver();
let mut session = SessionConfig::new();
session.set_theme(crate::config::ThemeName::from("dark"));
resolver.save_session(&session).unwrap();
assert!(resolver.session_config_path().exists());
resolver.clear_session().unwrap();
assert!(!resolver.session_config_path().exists());
}
#[test]
fn load_session_returns_empty_when_no_file() {
let (_temp, resolver) = create_test_resolver();
let session = resolver.load_session().unwrap();
assert!(session.is_empty());
}
#[test]
fn session_affects_resolved_config() {
let (_temp, resolver) = create_test_resolver();
let mut session = SessionConfig::new();
session.set_editor_option(|e| e.tab_size = Some(16));
resolver.save_session(&session).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 16);
}
#[test]
fn save_to_layer_writes_minimal_delta() {
let (temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(
&user_config_path,
r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
)
.unwrap();
let mut config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 2);
assert!(!config.editor.line_numbers);
config.editor.tab_size = 8;
resolver
.save_to_layer(&config, ConfigLayer::Project)
.unwrap();
let project_config_path = resolver.project_config_write_path();
let content = std::fs::read_to_string(&project_config_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
json.get("editor").and_then(|e| e.get("tab_size")),
Some(&serde_json::json!(8)),
"Project config should contain tab_size override"
);
assert!(
json.get("editor")
.and_then(|e| e.get("line_numbers"))
.is_none(),
"Project config should NOT contain line_numbers (it's inherited from user layer)"
);
assert!(
json.get("editor")
.and_then(|e| e.get("scroll_offset"))
.is_none(),
"Project config should NOT contain scroll_offset (it's a system default)"
);
drop(temp);
}
#[test]
fn save_to_layer_removes_inherited_values() {
let (temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
let project_config_path = resolver.project_config_write_path();
std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
let mut config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 8);
config.editor.tab_size = 2;
resolver
.save_to_layer(&config, ConfigLayer::Project)
.unwrap();
let content = std::fs::read_to_string(&project_config_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(
json.get("editor").and_then(|e| e.get("tab_size")).is_none(),
"Project config should NOT contain tab_size when it matches user layer"
);
drop(temp);
}
#[test]
fn issue_630_save_to_file_strips_settings_matching_defaults() {
let (_temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(
&user_config_path,
r#"{
"theme": "dracula",
"editor": {
"tab_size": 2
}
}"#,
)
.unwrap();
let mut config = resolver.resolve().unwrap();
assert_eq!(config.theme.0, "dracula");
assert_eq!(config.editor.tab_size, 2);
if let Some(lsp_config) = config.lsp.get_mut("python") {
lsp_config.enabled = false;
}
resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
let content = std::fs::read_to_string(&user_config_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
eprintln!(
"Saved config:\n{}",
serde_json::to_string_pretty(&json).unwrap()
);
assert_eq!(
json.get("theme").and_then(|v| v.as_str()),
Some("dracula"),
"Theme should be saved (differs from default)"
);
assert_eq!(
json.get("editor")
.and_then(|e| e.get("tab_size"))
.and_then(|v| v.as_u64()),
Some(2),
"tab_size should be saved (differs from default)"
);
assert_eq!(
json.get("lsp")
.and_then(|l| l.get("python"))
.and_then(|p| p.get("enabled"))
.and_then(|v| v.as_bool()),
Some(false),
"lsp.python.enabled should be saved (differs from default)"
);
let reloaded = resolver.resolve().unwrap();
assert_eq!(reloaded.theme.0, "dracula");
assert_eq!(reloaded.editor.tab_size, 2);
assert!(!reloaded.lsp["python"].enabled);
assert_eq!(reloaded.lsp["python"].command, "pylsp");
}
#[test]
fn toggle_lsp_preserves_command() {
let (_temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(&user_config_path, r#"{}"#).unwrap();
let config = resolver.resolve().unwrap();
let original_command = config.lsp["python"].command.clone();
assert!(
!original_command.is_empty(),
"Default python LSP should have a command"
);
let mut config = resolver.resolve().unwrap();
config.lsp.get_mut("python").unwrap().enabled = false;
resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
assert!(
!saved_content.contains(r#""command""#),
"Saved config should not contain 'command' field. File content: {}",
saved_content
);
assert!(
!saved_content.contains(r#""args""#),
"Saved config should not contain 'args' field. File content: {}",
saved_content
);
let mut config = resolver.resolve().unwrap();
assert!(!config.lsp["python"].enabled);
config.lsp.get_mut("python").unwrap().enabled = true;
resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(
config.lsp["python"].command, original_command,
"Command should be preserved after toggling enabled. Got: '{}'",
config.lsp["python"].command
);
}
#[test]
fn issue_631_disabled_lsp_without_command_should_be_valid() {
let (_temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(
&user_config_path,
r#"{
"lsp": {
"json": { "enabled": false },
"python": { "enabled": false },
"toml": { "enabled": false }
},
"theme": "dracula"
}"#,
)
.unwrap();
let result = resolver.resolve();
assert!(
result.is_ok(),
"BUG #631: Config with disabled LSP should be valid even without 'command' field. \
Got parse error: {:?}",
result.err()
);
let config = result.unwrap();
assert_eq!(
config.theme.0, "dracula",
"Theme should be 'dracula' from config file"
);
}
#[test]
fn loading_lsp_without_command_uses_default() {
let (_temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(
&user_config_path,
r#"{ "lsp": { "rust": { "enabled": false } } }"#,
)
.unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(
config.lsp["rust"].command, "rust-analyzer",
"Command should come from defaults when not in file. Got: '{}'",
config.lsp["rust"].command
);
assert!(
!config.lsp["rust"].enabled,
"enabled should be false from file"
);
}
#[test]
fn settings_ui_toggle_lsp_preserves_command() {
let (_temp, resolver) = create_test_resolver();
let user_config_path = resolver.user_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
std::fs::write(&user_config_path, r#"{}"#).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(
config.lsp["rust"].command, "rust-analyzer",
"Default rust command should be rust-analyzer"
);
assert!(
config.lsp["rust"].enabled,
"Default rust enabled should be true"
);
let mut config_json = serde_json::to_value(&config).unwrap();
*config_json
.pointer_mut("/lsp/rust/enabled")
.expect("path should exist") = serde_json::json!(false);
let modified_config: crate::config::Config =
serde_json::from_value(config_json).expect("should deserialize");
assert_eq!(
modified_config.lsp["rust"].command, "rust-analyzer",
"Command should be preserved after JSON modification"
);
resolver
.save_to_layer(&modified_config, ConfigLayer::User)
.unwrap();
let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
eprintln!("After disable, file contains:\n{}", saved_content);
let reloaded = resolver.resolve().unwrap();
assert_eq!(
reloaded.lsp["rust"].command, "rust-analyzer",
"Command should be preserved after save/reload (disabled). Got: '{}'",
reloaded.lsp["rust"].command
);
assert!(!reloaded.lsp["rust"].enabled, "rust should be disabled");
let mut config_json = serde_json::to_value(&reloaded).unwrap();
*config_json
.pointer_mut("/lsp/rust/enabled")
.expect("path should exist") = serde_json::json!(true);
let modified_config: crate::config::Config =
serde_json::from_value(config_json).expect("should deserialize");
resolver
.save_to_layer(&modified_config, ConfigLayer::User)
.unwrap();
let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
eprintln!("After re-enable, file contains:\n{}", saved_content);
let final_config = resolver.resolve().unwrap();
assert_eq!(
final_config.lsp["rust"].command, "rust-analyzer",
"Command should be preserved after toggle cycle. Got: '{}'",
final_config.lsp["rust"].command
);
assert!(final_config.lsp["rust"].enabled, "rust should be enabled");
}
}