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),
}
}
fn set_json_pointer(root: &mut Value, pointer: &str, value: Value) {
if pointer.is_empty() || pointer == "/" {
*root = value;
return;
}
let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
let mut current = root;
for (i, part) in parts.iter().enumerate() {
if i == parts.len() - 1 {
if let Value::Object(map) = current {
map.insert(part.to_string(), value);
}
return;
}
if let Value::Object(map) = current {
if !map.contains_key(*part) {
map.insert(part.to_string(), Value::Object(Default::default()));
}
current = map.get_mut(*part).unwrap();
} else {
return; }
}
}
pub(crate) fn remove_json_pointer(root: &mut Value, pointer: &str) {
if pointer.is_empty() || pointer == "/" {
return;
}
let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
let mut current = root;
for (i, part) in parts.iter().enumerate() {
if i == parts.len() - 1 {
if let Value::Object(map) = current {
map.remove(*part);
}
return;
}
if let Value::Object(map) = current {
if let Some(next) = map.get_mut(*part) {
current = next;
} else {
return; }
} else {
return; }
}
}
fn find_changed_paths(old: &Value, new: &Value) -> std::collections::HashSet<String> {
let mut changed = std::collections::HashSet::new();
find_changed_paths_recursive(old, new, String::new(), &mut changed);
changed
}
fn find_changed_paths_recursive(
old: &Value,
new: &Value,
prefix: String,
changed: &mut std::collections::HashSet<String>,
) {
match (old, new) {
(Value::Object(old_map), Value::Object(new_map)) => {
let all_keys: std::collections::HashSet<_> =
old_map.keys().chain(new_map.keys()).collect();
for key in all_keys {
let path = if prefix.is_empty() {
format!("/{}", key)
} else {
format!("{}/{}", prefix, key)
};
let old_val = old_map.get(key).unwrap_or(&Value::Null);
let new_val = new_map.get(key).unwrap_or(&Value::Null);
find_changed_paths_recursive(old_val, new_val, path, changed);
}
}
(old_val, new_val) if old_val != new_val => {
if !prefix.is_empty() {
changed.insert(prefix);
}
}
_ => {} }
}
fn write_clean_value_to_path(path: &Path, value: Value) -> Result<(), ConfigError> {
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 stripped = strip_nulls(value).unwrap_or(Value::Object(Default::default()));
let clean = strip_empty_defaults(stripped).unwrap_or(Value::Object(Default::default()));
let output = render_config_text(path, &clean)?;
std::fs::write(path, output)
.map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
Ok(())
}
fn render_config_text(path: &Path, clean: &Value) -> Result<String, ConfigError> {
if let (Value::Object(_), Ok(existing)) = (clean, std::fs::read_to_string(path)) {
if let Some(text) = reconcile_preserving_comments(&existing, clean) {
return Ok(text);
}
}
serde_json::to_string_pretty(clean).map_err(|e| ConfigError::SerializeError(e.to_string()))
}
fn reconcile_preserving_comments(existing: &str, clean: &Value) -> Option<String> {
use jsonc_parser::cst::CstRootNode;
let Value::Object(target) = clean else {
return None;
};
let root = CstRootNode::parse(existing, &Default::default()).ok()?;
root.value()?.as_object()?;
let obj = root.object_value_or_set();
reconcile_cst_object(&obj, target);
Some(root.to_string())
}
fn reconcile_cst_object(
obj: &jsonc_parser::cst::CstObject,
target: &serde_json::Map<String, Value>,
) {
use jsonc_parser::cst::CstObjectProp;
let prop_name = |prop: &CstObjectProp| -> Option<String> {
prop.name().and_then(|n| n.decoded_value().ok())
};
for prop in obj.properties() {
match prop_name(&prop) {
Some(name) if target.contains_key(&name) => {}
_ => prop.remove(),
}
}
for (key, new_value) in target {
match obj.get(key) {
Some(prop) => {
let current = prop.value().and_then(|n| n.to_serde_value());
if current.as_ref() == Some(new_value) {
continue;
}
match (new_value, prop.value().and_then(|n| n.as_object())) {
(Value::Object(child_target), Some(child_obj)) => {
reconcile_cst_object(&child_obj, child_target);
}
_ => prop.set_value(json_value_to_cst_input(new_value)),
}
}
None => {
obj.append(key, json_value_to_cst_input(new_value));
}
}
}
}
fn json_value_to_cst_input(value: &Value) -> jsonc_parser::cst::CstInputValue {
use jsonc_parser::cst::CstInputValue;
match value {
Value::Null => CstInputValue::Null,
Value::Bool(b) => CstInputValue::Bool(*b),
Value::Number(n) => CstInputValue::Number(n.to_string()),
Value::String(s) => CstInputValue::String(s.clone()),
Value::Array(arr) => {
CstInputValue::Array(arr.iter().map(json_value_to_cst_input).collect())
}
Value::Object(map) => CstInputValue::Object(
map.iter()
.map(|(k, v)| (k.clone(), json_value_to_cst_input(v)))
.collect(),
),
}
}
fn read_existing_json(path: &Path) -> Result<Value, ConfigError> {
if !path.exists() {
return Ok(Value::Object(Default::default()));
}
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
if content.trim().is_empty() {
return Ok(Value::Object(Default::default()));
}
crate::config::parse_config_jsonc(&content)
.map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))
}
pub const CURRENT_CONFIG_VERSION: u32 = 2;
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)?;
}
if version < 2 {
value = migrate_v1_to_v2(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(Value::Object(ref mut editor_map)) = map.get_mut("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)
}
fn migrate_v1_to_v2(mut value: Value) -> Result<Value, ConfigError> {
if let Value::Object(ref mut map) = value {
map.insert("version".to_string(), Value::Number(2.into()));
let left = map
.get_mut("editor")
.and_then(|editor| editor.as_object_mut())
.and_then(|editor| editor.get_mut("status_bar"))
.and_then(|status_bar| status_bar.as_object_mut())
.and_then(|status_bar| status_bar.get_mut("left"))
.and_then(|left| left.as_array_mut());
if let Some(left) = left {
let already_present = left.iter().any(|v| v.as_str() == Some("{remote}"));
if !already_present {
left.insert(0, Value::String("{remote}".to_string()));
}
}
}
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 = crate::config::parse_config_jsonc(&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))
}
fn layer_write_path(&self, layer: ConfigLayer) -> Result<PathBuf, ConfigError> {
match layer {
ConfigLayer::User => Ok(self.user_config_path()),
ConfigLayer::Project => Ok(self.project_config_write_path()),
ConfigLayer::Session => Ok(self.session_config_path()),
ConfigLayer::System => Err(ConfigError::ValidationError(
"Cannot write to System layer".to_string(),
)),
}
}
pub fn save_to_layer(&self, config: &Config, layer: ConfigLayer) -> Result<(), ConfigError> {
let path = self.layer_write_path(layer)?;
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 existing: PartialConfig = if path.exists() {
let content = std::fs::read_to_string(&path)
.map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
if content.trim().is_empty() {
PartialConfig::default()
} else {
let value = crate::config::parse_config_jsonc(&content)
.map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
serde_json::from_value(value)
.map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?
}
} else {
PartialConfig::default()
};
let mut merged = delta;
merged.merge_from(&existing);
let merged_value = serde_json::to_value(&merged)
.map_err(|e| ConfigError::SerializeError(e.to_string()))?;
write_clean_value_to_path(&path, merged_value)
}
pub fn save_to_layer_with_baseline(
&self,
current: &Config,
baseline: &Config,
layer: ConfigLayer,
) -> Result<(), ConfigError> {
let path = self.layer_write_path(layer)?;
let parent_partial = self.resolve_up_to_layer(layer)?;
let parent = PartialConfig::from(&parent_partial.resolve());
let current_json = serde_json::to_value(current)
.map_err(|e| ConfigError::SerializeError(e.to_string()))?;
let baseline_json = serde_json::to_value(baseline)
.map_err(|e| ConfigError::SerializeError(e.to_string()))?;
let parent_json = serde_json::to_value(&parent)
.map_err(|e| ConfigError::SerializeError(e.to_string()))?;
let changed_paths = find_changed_paths(&baseline_json, ¤t_json);
let mut result = read_existing_json(&path)?;
for pointer in &changed_paths {
let current_val = current_json.pointer(pointer);
let parent_val = parent_json.pointer(pointer);
if current_val == parent_val {
remove_json_pointer(&mut result, pointer);
} else if let Some(val) = current_val {
set_json_pointer(&mut result, pointer, val.clone());
}
}
write_clean_value_to_path(&path, result)
}
pub fn save_changes_to_layer(
&self,
changes: &std::collections::HashMap<String, serde_json::Value>,
deletions: &std::collections::HashSet<String>,
layer: ConfigLayer,
) -> Result<(), ConfigError> {
let path = self.layer_write_path(layer)?;
let mut config_value = read_existing_json(&path)?;
for pointer in deletions {
remove_json_pointer(&mut config_value, pointer);
}
for (pointer, value) in changes {
set_json_pointer(&mut config_value, pointer, value.clone());
}
let _: PartialConfig = serde_json::from_value(config_value.clone()).map_err(|e| {
ConfigError::ValidationError(format!("Result config would be invalid: {}", e))
})?;
write_clean_value_to_path(&path, config_value)
}
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 crate::config::parse_config_jsonc(&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");
let config_dir = Self::default_config_dir().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not determine config directory",
)
})?;
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 workspaces_dir(&self) -> std::path::PathBuf {
self.data_dir.join("workspaces")
}
pub fn project_state_dir(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
let canonical = working_dir
.canonicalize()
.unwrap_or_else(|_| working_dir.to_path_buf());
self.workspaces_dir()
.join(crate::workspace::encode_path_for_filename(&canonical))
}
pub fn prompt_history_path(&self, history_name: &str) -> std::path::PathBuf {
let safe_name = history_name.replace(':', "_");
self.data_dir.join(format!("{}_history.json", safe_name))
}
pub fn search_history_path(&self) -> std::path::PathBuf {
self.prompt_history_path("search")
}
pub fn replace_history_path(&self) -> std::path::PathBuf {
self.prompt_history_path("replace")
}
pub fn goto_line_history_path(&self) -> std::path::PathBuf {
self.prompt_history_path("goto_line")
}
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::workspace::encode_path_for_filename(working_dir);
self.terminals_dir().join(encoded)
}
pub fn working_data_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
let encoded = crate::workspace::encode_path_for_filename(working_dir);
self.data_dir.join("workdirs").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")
}
fn default_config_dir() -> Option<std::path::PathBuf> {
#[cfg(target_os = "macos")]
{
dirs::home_dir().map(|p| p.join(".config").join("fresh"))
}
#[cfg(not(target_os = "macos"))]
{
dirs::config_dir().map(|p| p.join("fresh"))
}
}
}
#[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_loads_user_layer_with_comments() {
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#"{
// I like a 7-space tab in this project
"editor": {
"tab_size": 7, /* trailing comma below is allowed too */
"line_numbers": false,
}
}"#,
)
.unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(
config.editor.tab_size, 7,
"commented user config should still apply tab_size"
);
assert!(
!config.editor.line_numbers,
"commented user config should still apply line_numbers"
);
drop(temp);
}
#[test]
fn resolver_loads_project_layer_with_comments() {
let (temp, resolver) = create_test_resolver();
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,
"{\n // project override\n \"editor\": { \"tab_size\": 3 }\n}\n",
)
.unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 3);
drop(temp);
}
#[test]
fn save_preserves_external_commented_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#"{
// hand-edited by the user
"editor": {
"tab_size": 7
}
}"#,
)
.unwrap();
let mut config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 7);
config.editor.line_numbers = false;
resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
let reloaded = resolver.resolve().unwrap();
assert_eq!(
reloaded.editor.tab_size, 7,
"saving an unrelated field must not drop the commented tab_size"
);
assert!(!reloaded.editor.line_numbers);
drop(temp);
}
#[test]
fn load_from_file_accepts_comments() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("config-with-comments.json");
std::fs::write(
&path,
r#"{
// 2-space tabs, no gutter
"editor": {
"tab_size": 2,
"line_numbers": false /* turn off the gutter */
}
}"#,
)
.unwrap();
let config = Config::load_from_file(&path).expect("commented --config file should load");
assert_eq!(config.editor.tab_size, 2);
assert!(!config.editor.line_numbers);
}
#[test]
fn reconcile_preserves_comments_and_unchanged_inline_annotations() {
let existing = "{\n \
// top-of-file note\n \
\"editor\": {\n \
\"tab_size\": 7, // my preferred width\n \
\"line_numbers\": true\n \
}\n\
}\n";
let target = serde_json::json!({
"editor": { "tab_size": 7, "line_numbers": false }
});
let out =
reconcile_preserving_comments(existing, &target).expect("object root should reconcile");
assert!(
out.contains("// top-of-file note"),
"file comment lost:\n{out}"
);
assert!(
out.contains("\"tab_size\": 7, // my preferred width"),
"inline comment on the unchanged field should be untouched:\n{out}"
);
let reparsed = crate::config::parse_config_jsonc(&out).unwrap();
assert_eq!(
reparsed.pointer("/editor/line_numbers"),
Some(&serde_json::json!(false))
);
}
#[test]
fn reconcile_appends_new_key_without_disturbing_comments() {
let existing = "{\n // keep me\n \"editor\": { \"tab_size\": 2 }\n}\n";
let target = serde_json::json!({
"editor": { "tab_size": 2 },
"theme": "dark"
});
let out = reconcile_preserving_comments(existing, &target).unwrap();
assert!(out.contains("// keep me"), "comment lost:\n{out}");
let reparsed = crate::config::parse_config_jsonc(&out).unwrap();
assert_eq!(reparsed.pointer("/theme"), Some(&serde_json::json!("dark")));
assert_eq!(
reparsed.pointer("/editor/tab_size"),
Some(&serde_json::json!(2))
);
}
#[test]
fn save_changes_to_layer_preserves_user_comments() {
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,
"{\n \
// I like a 7-space tab in this project\n \
\"editor\": {\n \
\"tab_size\": 7 /* keep this */\n \
}\n\
}\n",
)
.unwrap();
let mut changes: std::collections::HashMap<String, serde_json::Value> =
std::collections::HashMap::new();
changes.insert("/editor/line_numbers".to_string(), serde_json::json!(false));
resolver
.save_changes_to_layer(
&changes,
&std::collections::HashSet::new(),
ConfigLayer::User,
)
.unwrap();
let saved = std::fs::read_to_string(&user_config_path).unwrap();
assert!(
saved.contains("// I like a 7-space tab in this project"),
"line comment must survive a settings save:\n{saved}"
);
assert!(
saved.contains("/* keep this */"),
"inline comment on the untouched field must survive:\n{saved}"
);
let config = resolver.resolve().unwrap();
assert_eq!(config.editor.tab_size, 7);
assert!(!config.editor.line_numbers);
drop(temp);
}
#[test]
fn reconcile_falls_back_for_non_object_root() {
assert!(reconcile_preserving_comments("[1, 2, 3]", &serde_json::json!({"a": 1})).is_none());
}
const REALISTIC_USER_CONFIG: &str = r#"{
"version": 2,
"theme": "builtin://dracula",
"editor": {
// stuff that's really thingy
"hide_current_line_on_selection": true,
"auto_read_only": false,
"indentation_guide": "all"
},
// file explorer hooray:
"file_explorer": {
"show_hidden": true,
"custom_ignore_patterns": ["*.log"]
},
"keybindings": [
{
"key": "=",
"modifiers": ["alt"],
"action": "next_window"
}
],
"languages": {
"go": {
"extensions": ["go"],
"grammar": "go",
"use_tabs": true,
"tab_size": 8,
"formatter": {
"command": "gofmt",
"stdin": true,
"timeout_ms": 10000
},
"format_on_save": true
}
},
"check_for_updates": false
}
"#;
#[test]
fn realistic_commented_config_is_rejected_by_strict_json_but_accepted_by_loader() {
assert!(
serde_json::from_str::<serde_json::Value>(REALISTIC_USER_CONFIG).is_err(),
"sanity: the sample must actually contain JSONC that strict JSON rejects"
);
let v = crate::config::parse_config_jsonc(REALISTIC_USER_CONFIG)
.expect("loader must accept legitimate JSON-with-comments");
assert_eq!(
v.pointer("/theme"),
Some(&serde_json::json!("builtin://dracula"))
);
assert_eq!(
v.pointer("/languages/go/tab_size"),
Some(&serde_json::json!(8))
);
assert_eq!(
v.pointer("/keybindings/0/action"),
Some(&serde_json::json!("next_window"))
);
assert_eq!(
v.pointer("/file_explorer/custom_ignore_patterns/0"),
Some(&serde_json::json!("*.log"))
);
}
#[test]
fn save_changes_does_not_clobber_unparseable_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();
let original = "{\n \"editor\": {\n \"tab_size\": 7\n";
std::fs::write(&user_config_path, original).unwrap();
let mut changes = std::collections::HashMap::new();
changes.insert("/editor/line_numbers".to_string(), serde_json::json!(false));
let result = resolver.save_changes_to_layer(
&changes,
&std::collections::HashSet::new(),
ConfigLayer::User,
);
assert!(
result.is_err(),
"saving onto an unparseable config must error, not silently succeed and clobber it"
);
let after = std::fs::read_to_string(&user_config_path).unwrap();
assert_eq!(
after, original,
"a failed save must leave the unparseable config file untouched"
);
drop(temp);
}
#[test]
fn save_with_baseline_does_not_clobber_unparseable_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();
let original = "{ \"editor\": { \"tab_size\": 7 oops not json";
std::fs::write(&user_config_path, original).unwrap();
let baseline = Config::default();
let mut current = Config::default();
current.editor.tab_size = 3;
let result = resolver.save_to_layer_with_baseline(¤t, &baseline, ConfigLayer::User);
assert!(result.is_err(), "must error on unparseable existing file");
assert_eq!(
std::fs::read_to_string(&user_config_path).unwrap(),
original,
"a failed save must leave the file untouched"
);
drop(temp);
}
#[test]
fn save_to_layer_does_not_clobber_unparseable_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();
let original = "{ broken";
std::fs::write(&user_config_path, original).unwrap();
let mut config = Config::default();
config.editor.tab_size = 3;
let result = resolver.save_to_layer(&config, ConfigLayer::User);
assert!(result.is_err(), "must error on unparseable existing file");
assert_eq!(
std::fs::read_to_string(&user_config_path).unwrap(),
original,
"a failed save must leave the file untouched"
);
drop(temp);
}
#[test]
fn settings_save_preserves_full_realistic_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, REALISTIC_USER_CONFIG).unwrap();
let mut changes = std::collections::HashMap::new();
changes.insert(
"/editor/auto_read_only".to_string(),
serde_json::json!(true),
);
resolver
.save_changes_to_layer(
&changes,
&std::collections::HashSet::new(),
ConfigLayer::User,
)
.unwrap();
let saved = std::fs::read_to_string(&user_config_path).unwrap();
assert!(
saved.contains("// stuff that's really thingy"),
"editor comment lost:\n{saved}"
);
assert!(
saved.contains("// file explorer hooray:"),
"file_explorer comment lost:\n{saved}"
);
let reparsed = crate::config::parse_config_jsonc(&saved).unwrap();
assert_eq!(
reparsed.pointer("/keybindings/0/action"),
Some(&serde_json::json!("next_window")),
"keybindings array lost:\n{saved}"
);
assert_eq!(
reparsed.pointer("/languages/go/formatter/command"),
Some(&serde_json::json!("gofmt")),
"languages map lost:\n{saved}"
);
assert_eq!(
reparsed.pointer("/theme"),
Some(&serde_json::json!("builtin://dracula"))
);
assert_eq!(
reparsed.pointer("/editor/auto_read_only"),
Some(&serde_json::json!(true))
);
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!(CURRENT_CONFIG_VERSION))
);
}
#[test]
fn migration_v1_to_v2_injects_remote_element() {
let input = serde_json::json!({
"version": 1,
"editor": {
"status_bar": {
"left": ["{filename}", "{cursor}"]
}
}
});
let migrated = migrate_config(input).unwrap();
assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
let left = migrated
.pointer("/editor/status_bar/left")
.and_then(|v| v.as_array())
.expect("status_bar.left should remain an array");
assert_eq!(left[0], serde_json::json!("{remote}"));
assert_eq!(left[1], serde_json::json!("{filename}"));
assert_eq!(left[2], serde_json::json!("{cursor}"));
}
#[test]
fn migration_v1_to_v2_is_idempotent() {
let input = serde_json::json!({
"version": 1,
"editor": {
"status_bar": {
"left": ["{filename}", "{remote}", "{cursor}"]
}
}
});
let migrated = migrate_config(input).unwrap();
let left = migrated
.pointer("/editor/status_bar/left")
.and_then(|v| v.as_array())
.unwrap();
let remote_count = left
.iter()
.filter(|v| v.as_str() == Some("{remote}"))
.count();
assert_eq!(
remote_count, 1,
"migration should never duplicate an existing {{remote}} entry; left = {:?}",
left
);
}
#[test]
fn migration_v1_to_v2_leaves_default_users_alone() {
let input = serde_json::json!({
"version": 1,
"editor": {"tab_size": 4}
});
let migrated = migrate_config(input).unwrap();
assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
assert!(
migrated.pointer("/editor/status_bar").is_none(),
"migration must not fabricate a status_bar object for users \
who never overrode the default; migrated = {:?}",
migrated
);
}
#[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 resolver_migrates_v1_status_bar_left_on_load() {
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#"{
"version": 1,
"editor": {
"status_bar": {
"left": ["{filename}", "{cursor}"],
"right": []
}
}
}"#,
)
.unwrap();
let config = resolver.resolve().unwrap();
let left = &config.editor.status_bar.left;
assert_eq!(
left.first().cloned(),
Some(crate::config::StatusBarElement::RemoteIndicator),
"resolver should inject RemoteIndicator at index 0 during v1→v2 \
migration; left = {:?}",
left
);
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]
#[ignore = "Known limitation: save_to_layer cannot remove values that match parent layer"]
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_configs) = config.lsp.get_mut("python") {
for c in lsp_configs.as_mut_slice().iter_mut() {
c.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"].as_slice()[0].enabled);
assert_eq!(reloaded.lsp["python"].as_slice()[0].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"].as_slice()[0].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().as_mut_slice()[0].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"].as_slice()[0].enabled);
config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = true;
resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
let config = resolver.resolve().unwrap();
assert_eq!(
config.lsp["python"].as_slice()[0].command,
original_command,
"Command should be preserved after toggling enabled. Got: '{}'",
config.lsp["python"].as_slice()[0].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"].as_slice()[0].command,
"rust-analyzer",
"Command should come from defaults when not in file. Got: '{}'",
config.lsp["rust"].as_slice()[0].command
);
assert!(
!config.lsp["rust"].as_slice()[0].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"].as_slice()[0].command,
"rust-analyzer",
"Default rust command should be rust-analyzer"
);
assert!(
config.lsp["rust"].as_slice()[0].enabled,
"Default rust enabled should be true"
);
let mut changes = std::collections::HashMap::new();
changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(false));
let deletions = std::collections::HashSet::new();
resolver
.save_changes_to_layer(&changes, &deletions, 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"].as_slice()[0].command,
"rust-analyzer",
"Command should be preserved after save/reload (disabled). Got: '{}'",
reloaded.lsp["rust"].as_slice()[0].command
);
assert!(
!reloaded.lsp["rust"].as_slice()[0].enabled,
"rust should be disabled"
);
let mut changes = std::collections::HashMap::new();
changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(true));
let deletions = std::collections::HashSet::new();
resolver
.save_changes_to_layer(&changes, &deletions, 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"].as_slice()[0].command,
"rust-analyzer",
"Command should be preserved after toggle cycle. Got: '{}'",
final_config.lsp["rust"].as_slice()[0].command
);
assert!(
final_config.lsp["rust"].as_slice()[0].enabled,
"rust should be enabled"
);
}
#[test]
fn issue_806_manual_config_edits_lost_when_saving_from_ui() {
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-analyzer": {
"enabled": true,
"command": "rust-analyzer",
"args": ["--log-file", "/tmp/rust-analyzer-{pid}.log"],
"languages": ["rust"]
}
}
}"#,
)
.unwrap();
let config = resolver.resolve().unwrap();
assert!(
config.lsp.contains_key("rust-analyzer"),
"Config should contain manually-added 'rust-analyzer' LSP entry"
);
let rust_analyzer = &config.lsp["rust-analyzer"].as_slice()[0];
assert!(rust_analyzer.enabled, "rust-analyzer should be enabled");
assert_eq!(
rust_analyzer.command, "rust-analyzer",
"rust-analyzer command should be preserved"
);
assert_eq!(
rust_analyzer.args,
vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
"rust-analyzer args should be preserved"
);
let mut config_json = serde_json::to_value(&config).unwrap();
*config_json
.pointer_mut("/editor/tab_size")
.expect("path should exist") = serde_json::json!(2);
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();
let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
eprintln!(
"Issue #806 - Saved config after changing tab_size:\n{}",
serde_json::to_string_pretty(&saved_json).unwrap()
);
assert!(
saved_json.get("lsp").is_some(),
"BUG #806: 'lsp' section should NOT be deleted when saving unrelated changes. \
File content: {}",
saved_content
);
assert!(
saved_json
.get("lsp")
.and_then(|l| l.get("rust-analyzer"))
.is_some(),
"BUG #806: 'lsp.rust-analyzer' should NOT be deleted when saving unrelated changes. \
File content: {}",
saved_content
);
let saved_args = saved_json
.get("lsp")
.and_then(|l| l.get("rust-analyzer"))
.and_then(|r| r.get("args"));
assert!(
saved_args.is_some(),
"BUG #806: 'lsp.rust-analyzer.args' should be preserved. File content: {}",
saved_content
);
assert_eq!(
saved_args.unwrap(),
&serde_json::json!(["--log-file", "/tmp/rust-analyzer-{pid}.log"]),
"BUG #806: Custom args should be preserved exactly"
);
assert_eq!(
saved_json
.get("editor")
.and_then(|e| e.get("tab_size"))
.and_then(|v| v.as_u64()),
Some(2),
"tab_size should be saved"
);
let reloaded = resolver.resolve().unwrap();
assert_eq!(
reloaded.editor.tab_size, 2,
"tab_size change should be persisted"
);
assert!(
reloaded.lsp.contains_key("rust-analyzer"),
"BUG #806: rust-analyzer should still exist after reload"
);
let reloaded_ra = &reloaded.lsp["rust-analyzer"].as_slice()[0];
assert_eq!(
reloaded_ra.args,
vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
"BUG #806: Custom args should survive save/reload cycle"
);
}
#[test]
fn issue_806_custom_lsp_entries_preserved_across_unrelated_changes() {
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",
"lsp": {
"my-custom-lsp": {
"enabled": true,
"command": "/usr/local/bin/my-custom-lsp",
"args": ["--verbose", "--config", "/etc/my-lsp.json"],
"languages": ["mycustomlang"]
}
},
"languages": {
"mycustomlang": {
"extensions": [".mcl"],
"grammar": "mycustomlang"
}
}
}"#,
)
.unwrap();
let config = resolver.resolve().unwrap();
assert!(
config.lsp.contains_key("my-custom-lsp"),
"Custom LSP entry should be loaded"
);
assert!(
config.languages.contains_key("mycustomlang"),
"Custom language should be loaded"
);
let mut config_json = serde_json::to_value(&config).unwrap();
*config_json
.pointer_mut("/editor/line_numbers")
.expect("path should exist") = serde_json::json!(false);
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();
let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
eprintln!(
"Saved config:\n{}",
serde_json::to_string_pretty(&saved_json).unwrap()
);
assert!(
saved_json
.get("lsp")
.and_then(|l| l.get("my-custom-lsp"))
.is_some(),
"BUG #806: Custom LSP 'my-custom-lsp' should be preserved. Got: {}",
saved_content
);
assert!(
saved_json
.get("languages")
.and_then(|l| l.get("mycustomlang"))
.is_some(),
"BUG #806: Custom language 'mycustomlang' should be preserved. Got: {}",
saved_content
);
let reloaded = resolver.resolve().unwrap();
assert!(
reloaded.lsp.contains_key("my-custom-lsp"),
"Custom LSP should survive save/reload"
);
assert!(
reloaded.languages.contains_key("mycustomlang"),
"Custom language should survive save/reload"
);
assert!(
!reloaded.editor.line_numbers,
"line_numbers change should be applied"
);
}
#[test]
fn issue_806_external_file_modification_lost_on_ui_save() {
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": "monokai"}"#).unwrap();
let config_at_startup = resolver.resolve().unwrap();
assert_eq!(config_at_startup.theme.0, "monokai");
assert!(
!config_at_startup.lsp.contains_key("rust-analyzer"),
"No custom LSP at startup"
);
std::fs::write(
&user_config_path,
r#"{
"theme": "monokai",
"lsp": {
"rust-analyzer": {
"enabled": true,
"command": "rust-analyzer",
"args": ["--log-file", "/tmp/ra.log"]
}
}
}"#,
)
.unwrap();
let mut config_json = serde_json::to_value(&config_at_startup).unwrap();
*config_json
.pointer_mut("/editor/tab_size")
.expect("path should exist") = serde_json::json!(2);
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();
let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
eprintln!(
"Issue #806 scenario 2 - After UI save (external edits should be preserved):\n{}",
serde_json::to_string_pretty(&saved_json).unwrap()
);
assert!(
saved_json.get("lsp").is_some(),
"BUG #806: External edits to config.json were lost! \
The 'lsp' section added while Fresh was running should be preserved. \
Saved content: {}",
saved_content
);
assert!(
saved_json
.get("lsp")
.and_then(|l| l.get("rust-analyzer"))
.is_some(),
"BUG #806: rust-analyzer config should be preserved"
);
}
#[test]
fn issue_806_concurrent_modification_scenario() {
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 mut config = resolver.resolve().unwrap();
config.editor.tab_size = 8;
std::fs::write(
&user_config_path,
r#"{
"lsp": {
"custom-lsp": {
"enabled": true,
"command": "/usr/bin/custom-lsp"
}
}
}"#,
)
.unwrap();
resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
eprintln!(
"Concurrent modification scenario result:\n{}",
serde_json::to_string_pretty(&saved_json).unwrap()
);
assert_eq!(
saved_json
.get("editor")
.and_then(|e| e.get("tab_size"))
.and_then(|v| v.as_u64()),
Some(8),
"Our tab_size change should be saved"
);
let lsp_preserved = saved_json.get("lsp").is_some();
if !lsp_preserved {
eprintln!(
"NOTE: Concurrent file modifications are lost with current implementation. \
This is expected behavior but could be improved with read-modify-write pattern."
);
}
}
#[test]
fn save_to_layer_changing_to_default_value_should_persist() {
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"}"#).unwrap();
let baseline = resolver.resolve().unwrap();
assert_eq!(
baseline.theme.0, "dracula",
"Theme should be 'dracula' from file"
);
let mut config = baseline.clone();
config.theme = crate::config::ThemeName::from("high-contrast");
resolver
.save_to_layer_with_baseline(&config, &baseline, ConfigLayer::User)
.unwrap();
let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
eprintln!(
"Saved config after changing to default theme:\n{}",
saved_content
);
let reloaded = resolver.resolve().unwrap();
assert_eq!(
reloaded.theme.0, "high-contrast",
"Theme should be 'high-contrast' after changing to default and saving. \
With save_to_layer_with_baseline, the theme field should be removed from file \
so the default applies. File content: {}",
saved_content
);
}
#[test]
fn universal_lsp_round_trip_via_config_resolver() {
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#"{
"universal_lsp": {
"quicklsp": { "enabled": true, "auto_start": true }
}
}"#,
)
.unwrap();
let config = resolver.resolve().unwrap();
assert!(config.universal_lsp.contains_key("quicklsp"));
let server = &config.universal_lsp["quicklsp"].as_slice()[0];
assert!(server.enabled, "User override should enable quicklsp");
assert!(server.auto_start, "User override should enable auto_start");
assert_eq!(
server.command, "quicklsp",
"Command should come from defaults"
);
}
#[test]
fn universal_lsp_custom_server_merges_with_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#"{
"universal_lsp": {
"my-universal-server": {
"command": "my-server-bin",
"enabled": true
}
}
}"#,
)
.unwrap();
let config = resolver.resolve().unwrap();
assert!(
config.universal_lsp.contains_key("my-universal-server"),
"Custom universal server should be loaded"
);
assert_eq!(
config.universal_lsp["my-universal-server"].as_slice()[0].command,
"my-server-bin"
);
assert!(
config.universal_lsp.contains_key("quicklsp"),
"Default quicklsp should be preserved when adding custom servers"
);
}
#[test]
fn universal_lsp_partial_config_round_trip() {
use crate::partial_config::PartialConfig;
let mut config = Config::default();
if let Some(quicklsp) = config.universal_lsp.get_mut("quicklsp") {
quicklsp.as_mut_slice()[0].enabled = true;
}
let partial = PartialConfig::from(&config);
let resolved = partial.resolve();
assert!(
resolved.universal_lsp.contains_key("quicklsp"),
"quicklsp should survive Config -> PartialConfig -> Config round trip"
);
assert!(
resolved.universal_lsp["quicklsp"].as_slice()[0].enabled,
"quicklsp enabled state should be preserved through round trip"
);
}
}