fresh/
config_io.rs

1//! Runtime configuration I/O operations.
2//!
3//! This module contains system directory detection and config loading utilities
4//! that require runtime dependencies (dirs, tracing).
5//! These are separated from config.rs to allow schema-only builds.
6
7use crate::config::{Config, ConfigError};
8use crate::partial_config::{Merge, PartialConfig, SessionConfig};
9use serde_json::Value;
10use std::path::{Path, PathBuf};
11
12// ============================================================================
13// JSON Utilities
14// ============================================================================
15
16/// Recursively strip null values and empty objects from a JSON value.
17/// This ensures that config layer files only contain the actual overridden values,
18/// not null placeholders for inherited fields.
19fn strip_nulls(value: Value) -> Option<Value> {
20    match value {
21        Value::Null => None,
22        Value::Object(map) => {
23            let filtered: serde_json::Map<String, Value> = map
24                .into_iter()
25                .filter_map(|(k, v)| strip_nulls(v).map(|v| (k, v)))
26                .collect();
27            if filtered.is_empty() {
28                None
29            } else {
30                Some(Value::Object(filtered))
31            }
32        }
33        Value::Array(arr) => {
34            let filtered: Vec<Value> = arr.into_iter().filter_map(strip_nulls).collect();
35            Some(Value::Array(filtered))
36        }
37        other => Some(other),
38    }
39}
40
41/// Recursively strip default values (empty strings, empty arrays) from a JSON value.
42/// This ensures that fields with default serde values don't get saved to config files.
43fn strip_empty_defaults(value: Value) -> Option<Value> {
44    match value {
45        Value::Null => None,
46        Value::String(s) if s.is_empty() => None,
47        Value::Array(arr) if arr.is_empty() => None,
48        Value::Object(map) => {
49            let filtered: serde_json::Map<String, Value> = map
50                .into_iter()
51                .filter_map(|(k, v)| strip_empty_defaults(v).map(|v| (k, v)))
52                .collect();
53            if filtered.is_empty() {
54                None
55            } else {
56                Some(Value::Object(filtered))
57            }
58        }
59        Value::Array(arr) => {
60            let filtered: Vec<Value> = arr.into_iter().filter_map(strip_empty_defaults).collect();
61            if filtered.is_empty() {
62                None
63            } else {
64                Some(Value::Array(filtered))
65            }
66        }
67        other => Some(other),
68    }
69}
70
71/// Set a value at a JSON pointer path, creating intermediate objects as needed.
72/// The pointer should be in JSON Pointer format (e.g., "/editor/tab_size").
73fn set_json_pointer(root: &mut Value, pointer: &str, value: Value) {
74    if pointer.is_empty() || pointer == "/" {
75        *root = value;
76        return;
77    }
78
79    let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
80
81    let mut current = root;
82    for (i, part) in parts.iter().enumerate() {
83        if i == parts.len() - 1 {
84            // Last part - set the value
85            if let Value::Object(map) = current {
86                map.insert(part.to_string(), value);
87            }
88            return;
89        }
90
91        // Intermediate part - ensure it exists as an object
92        if let Value::Object(map) = current {
93            if !map.contains_key(*part) {
94                map.insert(part.to_string(), Value::Object(Default::default()));
95            }
96            current = map.get_mut(*part).unwrap();
97        } else {
98            return; // Can't traverse non-object
99        }
100    }
101}
102
103/// Remove a value at a JSON pointer path.
104fn remove_json_pointer(root: &mut Value, pointer: &str) {
105    if pointer.is_empty() || pointer == "/" {
106        return;
107    }
108
109    let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
110
111    let mut current = root;
112    for (i, part) in parts.iter().enumerate() {
113        if i == parts.len() - 1 {
114            // Last part - remove the key
115            if let Value::Object(map) = current {
116                map.remove(*part);
117            }
118            return;
119        }
120
121        // Intermediate part - traverse
122        if let Value::Object(map) = current {
123            if let Some(next) = map.get_mut(*part) {
124                current = next;
125            } else {
126                return; // Path doesn't exist
127            }
128        } else {
129            return; // Can't traverse non-object
130        }
131    }
132}
133
134// ============================================================================
135// Configuration Migration System
136// ============================================================================
137
138/// Current config schema version.
139/// Increment this when making breaking changes to config structure.
140pub const CURRENT_CONFIG_VERSION: u32 = 1;
141
142/// Apply all necessary migrations to bring a config JSON to the current version.
143pub fn migrate_config(mut value: Value) -> Result<Value, ConfigError> {
144    let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
145
146    // Apply migrations sequentially
147    if version < 1 {
148        value = migrate_v0_to_v1(value)?;
149    }
150    // Future migrations:
151    // if version < 2 { value = migrate_v1_to_v2(value)?; }
152
153    Ok(value)
154}
155
156/// Migration from v0 (implicit/missing version) to v1.
157/// This is the initial migration that establishes the version field.
158fn migrate_v0_to_v1(mut value: Value) -> Result<Value, ConfigError> {
159    if let Value::Object(ref mut map) = value {
160        // Set version to 1
161        map.insert("version".to_string(), Value::Number(1.into()));
162
163        // Example: rename camelCase keys to snake_case if they exist
164        if let Some(Value::Object(ref mut editor_map)) = map.get_mut("editor") {
165            // tabSize -> tab_size (hypothetical legacy format)
166            if let Some(val) = editor_map.remove("tabSize") {
167                editor_map.entry("tab_size").or_insert(val);
168            }
169            // lineNumbers -> line_numbers
170            if let Some(val) = editor_map.remove("lineNumbers") {
171                editor_map.entry("line_numbers").or_insert(val);
172            }
173        }
174    }
175    Ok(value)
176}
177
178/// Represents a configuration layer in the 4-level hierarchy.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum ConfigLayer {
181    /// Hardcoded defaults embedded in binary (lowest precedence)
182    System,
183    /// User-global settings (~/.config/fresh/config.json)
184    User,
185    /// Project-local settings ($PROJECT_ROOT/.fresh/config.json)
186    Project,
187    /// Runtime/volatile session state (highest precedence)
188    Session,
189}
190
191impl ConfigLayer {
192    /// Get the precedence level (higher = takes priority)
193    pub fn precedence(self) -> u8 {
194        match self {
195            Self::System => 0,
196            Self::User => 1,
197            Self::Project => 2,
198            Self::Session => 3,
199        }
200    }
201}
202
203/// Manages loading and merging of all configuration layers.
204///
205/// Resolution order: System → User → Project → Session
206/// Higher precedence layers override lower precedence layers.
207pub struct ConfigResolver {
208    dir_context: DirectoryContext,
209    working_dir: PathBuf,
210}
211
212impl ConfigResolver {
213    /// Create a new ConfigResolver for a working directory.
214    pub fn new(dir_context: DirectoryContext, working_dir: PathBuf) -> Self {
215        Self {
216            dir_context,
217            working_dir,
218        }
219    }
220
221    /// Load all layers and merge them into a resolved Config.
222    ///
223    /// Layers are merged from highest to lowest precedence:
224    /// Session > Project > UserPlatform > User > System
225    ///
226    /// Each layer fills in values missing from higher precedence layers.
227    pub fn resolve(&self) -> Result<Config, ConfigError> {
228        // Start with highest precedence layer (Session)
229        let mut merged = self.load_session_layer()?.unwrap_or_default();
230
231        // Merge in Project layer (fills missing values)
232        if let Some(project_partial) = self.load_project_layer()? {
233            tracing::debug!("Loaded project config layer");
234            merged.merge_from(&project_partial);
235        }
236
237        // Merge in User Platform layer (e.g., config_linux.json)
238        if let Some(platform_partial) = self.load_user_platform_layer()? {
239            tracing::debug!("Loaded user platform config layer");
240            merged.merge_from(&platform_partial);
241        }
242
243        // Merge in User layer (fills remaining missing values)
244        if let Some(user_partial) = self.load_user_layer()? {
245            tracing::debug!("Loaded user config layer");
246            merged.merge_from(&user_partial);
247        }
248
249        // Resolve to concrete Config (applies system defaults for any remaining None values)
250        Ok(merged.resolve())
251    }
252
253    /// Get the path to user config file.
254    pub fn user_config_path(&self) -> PathBuf {
255        self.dir_context.config_path()
256    }
257
258    /// Get the path to project config file.
259    /// Checks new location first (.fresh/config.json), falls back to legacy (config.json).
260    pub fn project_config_path(&self) -> PathBuf {
261        let new_path = self.working_dir.join(".fresh").join("config.json");
262        if new_path.exists() {
263            return new_path;
264        }
265        // Fall back to legacy location for backward compatibility
266        let legacy_path = self.working_dir.join("config.json");
267        if legacy_path.exists() {
268            return legacy_path;
269        }
270        // Return new path as default for new projects
271        new_path
272    }
273
274    /// Get the preferred path for writing project config (new location).
275    pub fn project_config_write_path(&self) -> PathBuf {
276        self.working_dir.join(".fresh").join("config.json")
277    }
278
279    /// Get the path to session config file.
280    pub fn session_config_path(&self) -> PathBuf {
281        self.working_dir.join(".fresh").join("session.json")
282    }
283
284    /// Get the platform-specific config filename.
285    fn platform_config_filename() -> Option<&'static str> {
286        if cfg!(target_os = "linux") {
287            Some("config_linux.json")
288        } else if cfg!(target_os = "macos") {
289            Some("config_macos.json")
290        } else if cfg!(target_os = "windows") {
291            Some("config_windows.json")
292        } else {
293            None
294        }
295    }
296
297    /// Get the path to platform-specific user config file.
298    pub fn user_platform_config_path(&self) -> Option<PathBuf> {
299        Self::platform_config_filename().map(|filename| self.dir_context.config_dir.join(filename))
300    }
301
302    /// Load the user layer from disk.
303    pub fn load_user_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
304        self.load_layer_from_path(&self.user_config_path())
305    }
306
307    /// Load the platform-specific user layer from disk.
308    pub fn load_user_platform_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
309        if let Some(path) = self.user_platform_config_path() {
310            self.load_layer_from_path(&path)
311        } else {
312            Ok(None)
313        }
314    }
315
316    /// Load the project layer from disk.
317    pub fn load_project_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
318        self.load_layer_from_path(&self.project_config_path())
319    }
320
321    /// Load the session layer from disk.
322    pub fn load_session_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
323        self.load_layer_from_path(&self.session_config_path())
324    }
325
326    /// Load a layer from a specific path, applying migrations if needed.
327    fn load_layer_from_path(&self, path: &Path) -> Result<Option<PartialConfig>, ConfigError> {
328        if !path.exists() {
329            return Ok(None);
330        }
331
332        let content = std::fs::read_to_string(path)
333            .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
334
335        // Parse as raw JSON first
336        let value: Value = serde_json::from_str(&content)
337            .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
338
339        // Apply migrations
340        let migrated = migrate_config(value)?;
341
342        // Now deserialize to PartialConfig
343        let partial: PartialConfig = serde_json::from_value(migrated)
344            .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
345
346        Ok(Some(partial))
347    }
348
349    /// Save a config to a specific layer, writing only the delta from parent layers.
350    pub fn save_to_layer(&self, config: &Config, layer: ConfigLayer) -> Result<(), ConfigError> {
351        if layer == ConfigLayer::System {
352            return Err(ConfigError::ValidationError(
353                "Cannot write to System layer".to_string(),
354            ));
355        }
356
357        // Calculate parent config (merge all layers below target)
358        let parent_partial = self.resolve_up_to_layer(layer)?;
359
360        // Resolve parent to full config and convert back to get all values populated.
361        // This ensures proper comparison - both current and parent have all fields set,
362        // so the diff will correctly identify only the actual differences.
363        let parent = PartialConfig::from(&parent_partial.resolve());
364
365        // Convert current config to partial
366        let current = PartialConfig::from(config);
367
368        // Calculate delta - now both are fully populated, so only actual differences are captured
369        let delta = diff_partial_config(&current, &parent);
370
371        // Get path for target layer (use write paths for new configs)
372        let path = match layer {
373            ConfigLayer::User => self.user_config_path(),
374            ConfigLayer::Project => self.project_config_write_path(),
375            ConfigLayer::Session => self.session_config_path(),
376            ConfigLayer::System => unreachable!(),
377        };
378
379        // Ensure parent directory exists
380        if let Some(parent_dir) = path.parent() {
381            std::fs::create_dir_all(parent_dir)
382                .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
383        }
384
385        // Read existing file content (if any) as PartialConfig.
386        // This preserves any manual edits made externally while the editor was running.
387        let existing: PartialConfig = if path.exists() {
388            let content = std::fs::read_to_string(&path)
389                .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
390            serde_json::from_str(&content).unwrap_or_default()
391        } else {
392            PartialConfig::default()
393        };
394
395        // Merge: delta values take precedence, existing fills in gaps where delta is None
396        let mut merged = delta;
397        merged.merge_from(&existing);
398
399        // Serialize to JSON, stripping null values and empty defaults to keep configs minimal
400        let merged_value = serde_json::to_value(&merged)
401            .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
402        let stripped_nulls = strip_nulls(merged_value).unwrap_or(Value::Object(Default::default()));
403        let clean_merged =
404            strip_empty_defaults(stripped_nulls).unwrap_or(Value::Object(Default::default()));
405
406        let json = serde_json::to_string_pretty(&clean_merged)
407            .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
408        std::fs::write(&path, json)
409            .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
410
411        Ok(())
412    }
413
414    /// Save specific changes to a layer file using JSON pointer paths.
415    ///
416    /// This reads the existing file, applies only the specified changes,
417    /// and writes back. This preserves any manual edits not touched by the changes.
418    pub fn save_changes_to_layer(
419        &self,
420        changes: &std::collections::HashMap<String, serde_json::Value>,
421        deletions: &std::collections::HashSet<String>,
422        layer: ConfigLayer,
423    ) -> Result<(), ConfigError> {
424        if layer == ConfigLayer::System {
425            return Err(ConfigError::ValidationError(
426                "Cannot write to System layer".to_string(),
427            ));
428        }
429
430        // Get path for target layer
431        let path = match layer {
432            ConfigLayer::User => self.user_config_path(),
433            ConfigLayer::Project => self.project_config_write_path(),
434            ConfigLayer::Session => self.session_config_path(),
435            ConfigLayer::System => unreachable!(),
436        };
437
438        // Ensure parent directory exists
439        if let Some(parent_dir) = path.parent() {
440            std::fs::create_dir_all(parent_dir)
441                .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
442        }
443
444        // Read existing file content as JSON
445        let mut config_value: Value = if path.exists() {
446            let content = std::fs::read_to_string(&path)
447                .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
448            serde_json::from_str(&content).unwrap_or(Value::Object(Default::default()))
449        } else {
450            Value::Object(Default::default())
451        };
452
453        // Apply deletions first
454        for pointer in deletions {
455            remove_json_pointer(&mut config_value, pointer);
456        }
457
458        // Apply changes using JSON pointers
459        for (pointer, value) in changes {
460            set_json_pointer(&mut config_value, pointer, value.clone());
461        }
462
463        // Validate the result can be deserialized
464        let _: PartialConfig = serde_json::from_value(config_value.clone()).map_err(|e| {
465            ConfigError::ValidationError(format!("Result config would be invalid: {}", e))
466        })?;
467
468        // Strip null values and empty defaults to keep configs minimal
469        let stripped = strip_nulls(config_value).unwrap_or(Value::Object(Default::default()));
470        let clean = strip_empty_defaults(stripped).unwrap_or(Value::Object(Default::default()));
471
472        let json = serde_json::to_string_pretty(&clean)
473            .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
474        std::fs::write(&path, json)
475            .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
476
477        Ok(())
478    }
479
480    /// Save a SessionConfig to the session layer file.
481    pub fn save_session(&self, session: &SessionConfig) -> Result<(), ConfigError> {
482        let path = self.session_config_path();
483
484        // Ensure .fresh directory exists
485        if let Some(parent_dir) = path.parent() {
486            std::fs::create_dir_all(parent_dir)
487                .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
488        }
489
490        let json = serde_json::to_string_pretty(session)
491            .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
492        std::fs::write(&path, json)
493            .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
494
495        tracing::debug!("Saved session config to {}", path.display());
496        Ok(())
497    }
498
499    /// Load the session config from disk, or return an empty one if it doesn't exist.
500    pub fn load_session(&self) -> Result<SessionConfig, ConfigError> {
501        match self.load_session_layer()? {
502            Some(partial) => Ok(SessionConfig::from(partial)),
503            None => Ok(SessionConfig::new()),
504        }
505    }
506
507    /// Clear the session config file on editor exit.
508    pub fn clear_session(&self) -> Result<(), ConfigError> {
509        let path = self.session_config_path();
510        if path.exists() {
511            std::fs::remove_file(&path)
512                .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
513            tracing::debug!("Cleared session config at {}", path.display());
514        }
515        Ok(())
516    }
517
518    /// Resolve config by merging layers below the target layer.
519    /// Used to calculate the "parent" config for delta serialization.
520    fn resolve_up_to_layer(&self, layer: ConfigLayer) -> Result<PartialConfig, ConfigError> {
521        let mut merged = PartialConfig::default();
522
523        // Merge from highest precedence (just below target) to lowest
524        // Session layer: parent includes Project + UserPlatform + User
525        // Project layer: parent includes UserPlatform + User
526        // User layer: parent is empty (system defaults applied during resolve)
527
528        if layer == ConfigLayer::Session {
529            // Session's parent is Project + UserPlatform + User
530            if let Some(project) = self.load_project_layer()? {
531                merged = project;
532            }
533            if let Some(platform) = self.load_user_platform_layer()? {
534                merged.merge_from(&platform);
535            }
536            if let Some(user) = self.load_user_layer()? {
537                merged.merge_from(&user);
538            }
539        } else if layer == ConfigLayer::Project {
540            // Project's parent is UserPlatform + User
541            if let Some(platform) = self.load_user_platform_layer()? {
542                merged = platform;
543            }
544            if let Some(user) = self.load_user_layer()? {
545                merged.merge_from(&user);
546            }
547        }
548        // User layer's parent is empty (defaults handled during resolve)
549
550        Ok(merged)
551    }
552
553    /// Determine which layer each setting value comes from.
554    /// Returns a map of JSON pointer paths to their source layer.
555    pub fn get_layer_sources(
556        &self,
557    ) -> Result<std::collections::HashMap<String, ConfigLayer>, ConfigError> {
558        use std::collections::HashMap;
559
560        let mut sources: HashMap<String, ConfigLayer> = HashMap::new();
561
562        // Load each layer and mark which paths come from it
563        // Check layers in precedence order (highest first)
564        // Session layer takes priority, then Project, then User, then System defaults
565
566        if let Some(session) = self.load_session_layer()? {
567            let json = serde_json::to_value(&session).unwrap_or_default();
568            collect_paths(&json, "", &mut |path| {
569                sources.insert(path, ConfigLayer::Session);
570            });
571        }
572
573        if let Some(project) = self.load_project_layer()? {
574            let json = serde_json::to_value(&project).unwrap_or_default();
575            collect_paths(&json, "", &mut |path| {
576                sources.entry(path).or_insert(ConfigLayer::Project);
577            });
578        }
579
580        if let Some(user) = self.load_user_layer()? {
581            let json = serde_json::to_value(&user).unwrap_or_default();
582            collect_paths(&json, "", &mut |path| {
583                sources.entry(path).or_insert(ConfigLayer::User);
584            });
585        }
586
587        // Any path not in the map comes from System defaults (implicitly)
588
589        Ok(sources)
590    }
591}
592
593/// Recursively collect all non-null leaf paths in a JSON value.
594fn collect_paths<F>(value: &Value, prefix: &str, collector: &mut F)
595where
596    F: FnMut(String),
597{
598    match value {
599        Value::Object(map) => {
600            for (key, val) in map {
601                let path = if prefix.is_empty() {
602                    format!("/{}", key)
603                } else {
604                    format!("{}/{}", prefix, key)
605                };
606                collect_paths(val, &path, collector);
607            }
608        }
609        Value::Null => {} // Skip nulls (unset in partial config)
610        _ => {
611            // Leaf value - collect this path
612            collector(prefix.to_string());
613        }
614    }
615}
616
617/// Calculate the delta between a partial config and its parent.
618/// Returns a PartialConfig containing only values that differ from parent.
619fn diff_partial_config(current: &PartialConfig, parent: &PartialConfig) -> PartialConfig {
620    // Convert both to JSON values and diff them
621    let current_json = serde_json::to_value(current).unwrap_or_default();
622    let parent_json = serde_json::to_value(parent).unwrap_or_default();
623
624    let diff = json_diff(&parent_json, &current_json);
625
626    // Convert diff back to PartialConfig
627    serde_json::from_value(diff).unwrap_or_default()
628}
629
630impl Config {
631    /// Get the system config file paths (without local/working directory).
632    ///
633    /// On macOS, prioritizes `~/.config/fresh/config.json` if it exists.
634    /// Then checks the standard system config directory.
635    fn system_config_paths() -> Vec<PathBuf> {
636        let mut paths = Vec::with_capacity(2);
637
638        // macOS: Prioritize ~/.config/fresh/config.json
639        #[cfg(target_os = "macos")]
640        if let Some(home) = dirs::home_dir() {
641            let path = home.join(".config").join("fresh").join(Config::FILENAME);
642            if path.exists() {
643                paths.push(path);
644            }
645        }
646
647        // Standard system paths (XDG on Linux, AppSupport on macOS, Roaming on Windows)
648        if let Some(config_dir) = dirs::config_dir() {
649            let path = config_dir.join("fresh").join(Config::FILENAME);
650            if !paths.contains(&path) && path.exists() {
651                paths.push(path);
652            }
653        }
654
655        paths
656    }
657
658    /// Get all config search paths, checking local (working directory) first.
659    ///
660    /// Search order:
661    /// 1. `{working_dir}/config.json` (project-local config)
662    /// 2. System config paths (see `system_config_paths()`)
663    ///
664    /// Only returns paths that exist on disk.
665    fn config_search_paths(working_dir: &Path) -> Vec<PathBuf> {
666        let local = Self::local_config_path(working_dir);
667        let mut paths = Vec::with_capacity(3);
668
669        if local.exists() {
670            paths.push(local);
671        }
672
673        paths.extend(Self::system_config_paths());
674        paths
675    }
676
677    /// Find the first existing config file, checking local directory first.
678    ///
679    /// Returns `None` if no config file exists anywhere.
680    pub fn find_config_path(working_dir: &Path) -> Option<PathBuf> {
681        Self::config_search_paths(working_dir).into_iter().next()
682    }
683
684    /// Load configuration using the 4-level layer system.
685    ///
686    /// Merges layers in precedence order: Session > Project > User > System
687    /// Falls back to defaults for any unspecified values.
688    pub fn load_with_layers(dir_context: &DirectoryContext, working_dir: &Path) -> Self {
689        let resolver = ConfigResolver::new(dir_context.clone(), working_dir.to_path_buf());
690        match resolver.resolve() {
691            Ok(config) => {
692                tracing::info!("Loaded layered config for {}", working_dir.display());
693                config
694            }
695            Err(e) => {
696                tracing::warn!("Failed to load layered config: {}, using defaults", e);
697                Self::default()
698            }
699        }
700    }
701
702    /// Read the raw user config file content as JSON.
703    ///
704    /// This returns the sparse user config (only what's in the file, not merged
705    /// with defaults). Useful for plugins that need to distinguish between
706    /// user-set values and defaults.
707    ///
708    /// Checks working directory first, then system paths.
709    pub fn read_user_config_raw(working_dir: &Path) -> serde_json::Value {
710        for path in Self::config_search_paths(working_dir) {
711            if let Ok(contents) = std::fs::read_to_string(&path) {
712                match serde_json::from_str(&contents) {
713                    Ok(value) => return value,
714                    Err(e) => {
715                        tracing::warn!("Failed to parse config from {}: {}", path.display(), e);
716                    }
717                }
718            }
719        }
720        serde_json::Value::Object(serde_json::Map::new())
721    }
722}
723
724/// Compute the difference between two JSON values.
725/// Returns only the parts of `current` that differ from `defaults`.
726fn json_diff(defaults: &serde_json::Value, current: &serde_json::Value) -> serde_json::Value {
727    use serde_json::Value;
728
729    match (defaults, current) {
730        // Both are objects - recursively diff
731        (Value::Object(def_map), Value::Object(cur_map)) => {
732            let mut result = serde_json::Map::new();
733
734            for (key, cur_val) in cur_map {
735                if let Some(def_val) = def_map.get(key) {
736                    // Key exists in both - recurse
737                    let diff = json_diff(def_val, cur_val);
738                    // Only include if there's an actual difference
739                    if !is_empty_diff(&diff) {
740                        result.insert(key.clone(), diff);
741                    }
742                } else {
743                    // Key only in current - include it, but strip empty defaults
744                    if let Some(stripped) = strip_empty_defaults(cur_val.clone()) {
745                        result.insert(key.clone(), stripped);
746                    }
747                }
748            }
749
750            Value::Object(result)
751        }
752        // For arrays and primitives, include if different
753        _ => {
754            // Treat empty string as "not set" - don't include in diff
755            if let Value::String(s) = current {
756                if s.is_empty() {
757                    return Value::Object(serde_json::Map::new()); // No diff
758                }
759            }
760            if defaults == current {
761                Value::Object(serde_json::Map::new()) // Empty object signals "no diff"
762            } else {
763                current.clone()
764            }
765        }
766    }
767}
768
769/// Check if a diff result represents "no changes"
770fn is_empty_diff(value: &serde_json::Value) -> bool {
771    match value {
772        serde_json::Value::Object(map) => map.is_empty(),
773        _ => false,
774    }
775}
776
777/// Directory paths for editor state and configuration
778///
779/// This struct holds all directory paths that the editor needs.
780/// Only the top-level `main` function should use `dirs::*` to construct this;
781/// all other code should receive it by construction/parameter passing.
782///
783/// This design ensures:
784/// - Tests can use isolated temp directories
785/// - Parallel tests don't interfere with each other
786/// - No hidden global state dependencies
787#[derive(Debug, Clone)]
788pub struct DirectoryContext {
789    /// Data directory for persistent state (recovery, sessions, history)
790    /// e.g., ~/.local/share/fresh on Linux, ~/Library/Application Support/fresh on macOS
791    pub data_dir: std::path::PathBuf,
792
793    /// Config directory for user configuration
794    /// e.g., ~/.config/fresh on Linux, ~/Library/Application Support/fresh on macOS
795    pub config_dir: std::path::PathBuf,
796
797    /// User's home directory (for file open dialog shortcuts)
798    pub home_dir: Option<std::path::PathBuf>,
799
800    /// User's documents directory (for file open dialog shortcuts)
801    pub documents_dir: Option<std::path::PathBuf>,
802
803    /// User's downloads directory (for file open dialog shortcuts)
804    pub downloads_dir: Option<std::path::PathBuf>,
805}
806
807impl DirectoryContext {
808    /// Create a DirectoryContext from the system directories
809    /// This should ONLY be called from main()
810    pub fn from_system() -> std::io::Result<Self> {
811        let data_dir = dirs::data_dir()
812            .ok_or_else(|| {
813                std::io::Error::new(
814                    std::io::ErrorKind::NotFound,
815                    "Could not determine data directory",
816                )
817            })?
818            .join("fresh");
819
820        #[allow(unused_mut)] // mut needed on macOS only
821        let mut config_dir = dirs::config_dir()
822            .ok_or_else(|| {
823                std::io::Error::new(
824                    std::io::ErrorKind::NotFound,
825                    "Could not determine config directory",
826                )
827            })?
828            .join("fresh");
829
830        // macOS: Prioritize ~/.config/fresh
831        #[cfg(target_os = "macos")]
832        if let Some(home) = dirs::home_dir() {
833            config_dir = home.join(".config").join("fresh");
834        }
835
836        Ok(Self {
837            data_dir,
838            config_dir,
839            home_dir: dirs::home_dir(),
840            documents_dir: dirs::document_dir(),
841            downloads_dir: dirs::download_dir(),
842        })
843    }
844
845    /// Create a DirectoryContext for testing with a temp directory
846    /// All paths point to subdirectories within the provided temp_dir
847    pub fn for_testing(temp_dir: &std::path::Path) -> Self {
848        Self {
849            data_dir: temp_dir.join("data"),
850            config_dir: temp_dir.join("config"),
851            home_dir: Some(temp_dir.join("home")),
852            documents_dir: Some(temp_dir.join("documents")),
853            downloads_dir: Some(temp_dir.join("downloads")),
854        }
855    }
856
857    /// Get the recovery directory path
858    pub fn recovery_dir(&self) -> std::path::PathBuf {
859        self.data_dir.join("recovery")
860    }
861
862    /// Get the sessions directory path
863    pub fn sessions_dir(&self) -> std::path::PathBuf {
864        self.data_dir.join("sessions")
865    }
866
867    /// Get the history file path for a specific prompt type
868    /// This is the generic method used by prompt_histories HashMap.
869    /// history_name can be: "search", "replace", "goto_line", "plugin:custom_name", etc.
870    pub fn prompt_history_path(&self, history_name: &str) -> std::path::PathBuf {
871        // Sanitize the name for filesystem safety (replace : with _)
872        let safe_name = history_name.replace(':', "_");
873        self.data_dir.join(format!("{}_history.json", safe_name))
874    }
875
876    /// Get the search history file path (legacy, calls generic method)
877    pub fn search_history_path(&self) -> std::path::PathBuf {
878        self.prompt_history_path("search")
879    }
880
881    /// Get the replace history file path (legacy, calls generic method)
882    pub fn replace_history_path(&self) -> std::path::PathBuf {
883        self.prompt_history_path("replace")
884    }
885
886    /// Get the goto line history file path (legacy, calls generic method)
887    pub fn goto_line_history_path(&self) -> std::path::PathBuf {
888        self.prompt_history_path("goto_line")
889    }
890
891    /// Get the terminals root directory
892    pub fn terminals_dir(&self) -> std::path::PathBuf {
893        self.data_dir.join("terminals")
894    }
895
896    /// Get the terminal directory for a specific working directory
897    pub fn terminal_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
898        let encoded = crate::session::encode_path_for_filename(working_dir);
899        self.terminals_dir().join(encoded)
900    }
901
902    /// Get the config file path
903    pub fn config_path(&self) -> std::path::PathBuf {
904        self.config_dir.join(Config::FILENAME)
905    }
906
907    /// Get the themes directory path
908    pub fn themes_dir(&self) -> std::path::PathBuf {
909        self.config_dir.join("themes")
910    }
911
912    /// Get the grammars directory path
913    pub fn grammars_dir(&self) -> std::path::PathBuf {
914        self.config_dir.join("grammars")
915    }
916
917    /// Get the plugins directory path
918    pub fn plugins_dir(&self) -> std::path::PathBuf {
919        self.config_dir.join("plugins")
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use tempfile::TempDir;
927
928    fn create_test_resolver() -> (TempDir, ConfigResolver) {
929        let temp_dir = TempDir::new().unwrap();
930        let dir_context = DirectoryContext::for_testing(temp_dir.path());
931        let working_dir = temp_dir.path().join("project");
932        std::fs::create_dir_all(&working_dir).unwrap();
933        let resolver = ConfigResolver::new(dir_context, working_dir);
934        (temp_dir, resolver)
935    }
936
937    #[test]
938    fn resolver_returns_defaults_when_no_config_files() {
939        let (_temp, resolver) = create_test_resolver();
940        let config = resolver.resolve().unwrap();
941
942        // Should have system defaults
943        assert_eq!(config.editor.tab_size, 4);
944        assert!(config.editor.line_numbers);
945    }
946
947    #[test]
948    fn resolver_loads_user_layer() {
949        let (temp, resolver) = create_test_resolver();
950
951        // Create user config
952        let user_config_path = resolver.user_config_path();
953        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
954        std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
955
956        let config = resolver.resolve().unwrap();
957        assert_eq!(config.editor.tab_size, 2);
958        assert!(config.editor.line_numbers); // Still default
959        drop(temp);
960    }
961
962    #[test]
963    fn resolver_project_overrides_user() {
964        let (temp, resolver) = create_test_resolver();
965
966        // Create user config with tab_size=2
967        let user_config_path = resolver.user_config_path();
968        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
969        std::fs::write(
970            &user_config_path,
971            r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
972        )
973        .unwrap();
974
975        // Create project config with tab_size=8
976        let project_config_path = resolver.project_config_path();
977        std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
978        std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
979
980        let config = resolver.resolve().unwrap();
981        assert_eq!(config.editor.tab_size, 8); // Project wins
982        assert!(!config.editor.line_numbers); // User value preserved
983        drop(temp);
984    }
985
986    #[test]
987    fn resolver_session_overrides_all() {
988        let (temp, resolver) = create_test_resolver();
989
990        // Create user config
991        let user_config_path = resolver.user_config_path();
992        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
993        std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
994
995        // Create project config
996        let project_config_path = resolver.project_config_path();
997        std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
998        std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 4}}"#).unwrap();
999
1000        // Create session config
1001        let session_config_path = resolver.session_config_path();
1002        std::fs::write(&session_config_path, r#"{"editor": {"tab_size": 16}}"#).unwrap();
1003
1004        let config = resolver.resolve().unwrap();
1005        assert_eq!(config.editor.tab_size, 16); // Session wins
1006        drop(temp);
1007    }
1008
1009    #[test]
1010    fn layer_precedence_ordering() {
1011        assert!(ConfigLayer::Session.precedence() > ConfigLayer::Project.precedence());
1012        assert!(ConfigLayer::Project.precedence() > ConfigLayer::User.precedence());
1013        assert!(ConfigLayer::User.precedence() > ConfigLayer::System.precedence());
1014    }
1015
1016    #[test]
1017    fn save_to_system_layer_fails() {
1018        let (_temp, resolver) = create_test_resolver();
1019        let config = Config::default();
1020        let result = resolver.save_to_layer(&config, ConfigLayer::System);
1021        assert!(result.is_err());
1022    }
1023
1024    #[test]
1025    fn resolver_loads_legacy_project_config() {
1026        let (temp, resolver) = create_test_resolver();
1027
1028        // Create legacy project config at {working_dir}/config.json
1029        let working_dir = temp.path().join("project");
1030        let legacy_path = working_dir.join("config.json");
1031        std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1032
1033        let config = resolver.resolve().unwrap();
1034        assert_eq!(config.editor.tab_size, 3);
1035        drop(temp);
1036    }
1037
1038    #[test]
1039    fn resolver_prefers_new_config_over_legacy() {
1040        let (temp, resolver) = create_test_resolver();
1041
1042        // Create both legacy and new project configs
1043        let working_dir = temp.path().join("project");
1044
1045        // Legacy: tab_size=3
1046        let legacy_path = working_dir.join("config.json");
1047        std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1048
1049        // New: tab_size=5
1050        let new_path = working_dir.join(".fresh").join("config.json");
1051        std::fs::create_dir_all(new_path.parent().unwrap()).unwrap();
1052        std::fs::write(&new_path, r#"{"editor": {"tab_size": 5}}"#).unwrap();
1053
1054        let config = resolver.resolve().unwrap();
1055        assert_eq!(config.editor.tab_size, 5); // New path wins
1056        drop(temp);
1057    }
1058
1059    #[test]
1060    fn load_with_layers_works() {
1061        let temp = TempDir::new().unwrap();
1062        let dir_context = DirectoryContext::for_testing(temp.path());
1063        let working_dir = temp.path().join("project");
1064        std::fs::create_dir_all(&working_dir).unwrap();
1065
1066        // Create user config
1067        std::fs::create_dir_all(&dir_context.config_dir).unwrap();
1068        std::fs::write(dir_context.config_path(), r#"{"editor": {"tab_size": 2}}"#).unwrap();
1069
1070        let config = Config::load_with_layers(&dir_context, &working_dir);
1071        assert_eq!(config.editor.tab_size, 2);
1072    }
1073
1074    #[test]
1075    fn platform_config_overrides_user() {
1076        let (temp, resolver) = create_test_resolver();
1077
1078        // Create user config with tab_size=2
1079        let user_config_path = resolver.user_config_path();
1080        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1081        std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1082
1083        // Create platform config with tab_size=6
1084        if let Some(platform_path) = resolver.user_platform_config_path() {
1085            std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1086
1087            let config = resolver.resolve().unwrap();
1088            assert_eq!(config.editor.tab_size, 6); // Platform overrides user
1089        }
1090        drop(temp);
1091    }
1092
1093    #[test]
1094    fn project_overrides_platform() {
1095        let (temp, resolver) = create_test_resolver();
1096
1097        // Create user config
1098        let user_config_path = resolver.user_config_path();
1099        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1100        std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1101
1102        // Create platform config
1103        if let Some(platform_path) = resolver.user_platform_config_path() {
1104            std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1105        }
1106
1107        // Create project config with tab_size=10
1108        let project_config_path = resolver.project_config_path();
1109        std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1110        std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 10}}"#).unwrap();
1111
1112        let config = resolver.resolve().unwrap();
1113        assert_eq!(config.editor.tab_size, 10); // Project overrides platform
1114        drop(temp);
1115    }
1116
1117    #[test]
1118    fn migration_adds_version() {
1119        let input = serde_json::json!({
1120            "editor": {"tab_size": 2}
1121        });
1122
1123        let migrated = migrate_config(input).unwrap();
1124
1125        assert_eq!(migrated.get("version"), Some(&serde_json::json!(1)));
1126    }
1127
1128    #[test]
1129    fn migration_renames_camelcase_keys() {
1130        let input = serde_json::json!({
1131            "editor": {
1132                "tabSize": 8,
1133                "lineNumbers": false
1134            }
1135        });
1136
1137        let migrated = migrate_config(input).unwrap();
1138
1139        let editor = migrated.get("editor").unwrap();
1140        assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(8)));
1141        assert_eq!(editor.get("line_numbers"), Some(&serde_json::json!(false)));
1142        assert!(editor.get("tabSize").is_none());
1143        assert!(editor.get("lineNumbers").is_none());
1144    }
1145
1146    #[test]
1147    fn migration_preserves_existing_snake_case() {
1148        let input = serde_json::json!({
1149            "version": 1,
1150            "editor": {"tab_size": 4}
1151        });
1152
1153        let migrated = migrate_config(input).unwrap();
1154
1155        let editor = migrated.get("editor").unwrap();
1156        assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(4)));
1157    }
1158
1159    #[test]
1160    fn resolver_loads_legacy_camelcase_config() {
1161        let (temp, resolver) = create_test_resolver();
1162
1163        // Create config with legacy camelCase keys
1164        let user_config_path = resolver.user_config_path();
1165        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1166        std::fs::write(
1167            &user_config_path,
1168            r#"{"editor": {"tabSize": 3, "lineNumbers": false}}"#,
1169        )
1170        .unwrap();
1171
1172        let config = resolver.resolve().unwrap();
1173        assert_eq!(config.editor.tab_size, 3);
1174        assert!(!config.editor.line_numbers);
1175        drop(temp);
1176    }
1177
1178    #[test]
1179    fn save_and_load_session() {
1180        let (_temp, resolver) = create_test_resolver();
1181
1182        let mut session = SessionConfig::new();
1183        session.set_theme(crate::config::ThemeName::from("dark"));
1184        session.set_editor_option(|e| e.tab_size = Some(2));
1185
1186        // Save session
1187        resolver.save_session(&session).unwrap();
1188
1189        // Load session
1190        let loaded = resolver.load_session().unwrap();
1191        assert_eq!(loaded.theme, Some(crate::config::ThemeName::from("dark")));
1192        assert_eq!(loaded.editor.as_ref().unwrap().tab_size, Some(2));
1193    }
1194
1195    #[test]
1196    fn clear_session_removes_file() {
1197        let (_temp, resolver) = create_test_resolver();
1198
1199        let mut session = SessionConfig::new();
1200        session.set_theme(crate::config::ThemeName::from("dark"));
1201
1202        // Save then clear
1203        resolver.save_session(&session).unwrap();
1204        assert!(resolver.session_config_path().exists());
1205
1206        resolver.clear_session().unwrap();
1207        assert!(!resolver.session_config_path().exists());
1208    }
1209
1210    #[test]
1211    fn load_session_returns_empty_when_no_file() {
1212        let (_temp, resolver) = create_test_resolver();
1213
1214        let session = resolver.load_session().unwrap();
1215        assert!(session.is_empty());
1216    }
1217
1218    #[test]
1219    fn session_affects_resolved_config() {
1220        let (_temp, resolver) = create_test_resolver();
1221
1222        // Save a session with tab_size=16
1223        let mut session = SessionConfig::new();
1224        session.set_editor_option(|e| e.tab_size = Some(16));
1225        resolver.save_session(&session).unwrap();
1226
1227        // Resolve should pick up session value
1228        let config = resolver.resolve().unwrap();
1229        assert_eq!(config.editor.tab_size, 16);
1230    }
1231
1232    #[test]
1233    fn save_to_layer_writes_minimal_delta() {
1234        let (temp, resolver) = create_test_resolver();
1235
1236        // Create user config with tab_size=2
1237        let user_config_path = resolver.user_config_path();
1238        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1239        std::fs::write(
1240            &user_config_path,
1241            r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1242        )
1243        .unwrap();
1244
1245        // Resolve the full config (inherits user values)
1246        let mut config = resolver.resolve().unwrap();
1247        assert_eq!(config.editor.tab_size, 2);
1248        assert!(!config.editor.line_numbers);
1249
1250        // Change only tab_size in the project layer
1251        config.editor.tab_size = 8;
1252
1253        // Save to project layer
1254        resolver
1255            .save_to_layer(&config, ConfigLayer::Project)
1256            .unwrap();
1257
1258        // Read the project config file and verify it contains ONLY the delta
1259        let project_config_path = resolver.project_config_write_path();
1260        let content = std::fs::read_to_string(&project_config_path).unwrap();
1261        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1262
1263        // Should only have editor.tab_size = 8, nothing else
1264        assert_eq!(
1265            json.get("editor").and_then(|e| e.get("tab_size")),
1266            Some(&serde_json::json!(8)),
1267            "Project config should contain tab_size override"
1268        );
1269
1270        // Should NOT have line_numbers (inherited from user, not changed)
1271        assert!(
1272            json.get("editor")
1273                .and_then(|e| e.get("line_numbers"))
1274                .is_none(),
1275            "Project config should NOT contain line_numbers (it's inherited from user layer)"
1276        );
1277
1278        // Should NOT have other editor fields like scroll_offset (system default)
1279        assert!(
1280            json.get("editor")
1281                .and_then(|e| e.get("scroll_offset"))
1282                .is_none(),
1283            "Project config should NOT contain scroll_offset (it's a system default)"
1284        );
1285
1286        drop(temp);
1287    }
1288
1289    /// Known limitation of save_to_layer: when a value is set to match the parent layer,
1290    /// save_to_layer cannot distinguish this from "value unchanged" and may preserve
1291    /// the old file value due to the merge behavior.
1292    ///
1293    /// Use save_changes_to_layer with explicit deletions for workflows that need this.
1294    #[test]
1295    #[ignore = "Known limitation: save_to_layer cannot remove values that match parent layer"]
1296    fn save_to_layer_removes_inherited_values() {
1297        let (temp, resolver) = create_test_resolver();
1298
1299        // Create user config with tab_size=2
1300        let user_config_path = resolver.user_config_path();
1301        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1302        std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1303
1304        // Create project config with tab_size=8
1305        let project_config_path = resolver.project_config_write_path();
1306        std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1307        std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1308
1309        // Resolve config
1310        let mut config = resolver.resolve().unwrap();
1311        assert_eq!(config.editor.tab_size, 8);
1312
1313        // Set tab_size back to the user value (2)
1314        config.editor.tab_size = 2;
1315
1316        // Save to project layer
1317        resolver
1318            .save_to_layer(&config, ConfigLayer::Project)
1319            .unwrap();
1320
1321        // Read the project config - tab_size should be removed (same as parent)
1322        let content = std::fs::read_to_string(&project_config_path).unwrap();
1323        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1324
1325        // Should not have editor.tab_size since it matches the user value
1326        assert!(
1327            json.get("editor").and_then(|e| e.get("tab_size")).is_none(),
1328            "Project config should NOT contain tab_size when it matches user layer"
1329        );
1330
1331        drop(temp);
1332    }
1333
1334    /// Issue #630 FIX: save_to_layer saves only the delta, defaults are inherited.
1335    ///
1336    /// The save_to_layer method correctly:
1337    /// 1. Saves only settings that differ from defaults
1338    /// 2. Loads correctly because defaults are applied during resolve()
1339    ///
1340    /// This test verifies that modifying a config and saving works correctly.
1341    #[test]
1342    fn issue_630_save_to_file_strips_settings_matching_defaults() {
1343        let (_temp, resolver) = create_test_resolver();
1344
1345        // Create a config with some non-default settings
1346        let user_config_path = resolver.user_config_path();
1347        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1348        std::fs::write(
1349            &user_config_path,
1350            r#"{
1351                "theme": "dracula",
1352                "editor": {
1353                    "tab_size": 2
1354                }
1355            }"#,
1356        )
1357        .unwrap();
1358
1359        // Load the config
1360        let mut config = resolver.resolve().unwrap();
1361        assert_eq!(config.theme.0, "dracula");
1362        assert_eq!(config.editor.tab_size, 2);
1363
1364        // User disables LSP via UI
1365        if let Some(lsp_config) = config.lsp.get_mut("python") {
1366            lsp_config.enabled = false;
1367        }
1368
1369        // Save using save_to_layer
1370        resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1371
1372        // Read back the saved config file
1373        let content = std::fs::read_to_string(&user_config_path).unwrap();
1374        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1375
1376        eprintln!(
1377            "Saved config:\n{}",
1378            serde_json::to_string_pretty(&json).unwrap()
1379        );
1380
1381        // Verify the delta contains what we changed
1382        assert_eq!(
1383            json.get("theme").and_then(|v| v.as_str()),
1384            Some("dracula"),
1385            "Theme should be saved (differs from default)"
1386        );
1387        assert_eq!(
1388            json.get("editor")
1389                .and_then(|e| e.get("tab_size"))
1390                .and_then(|v| v.as_u64()),
1391            Some(2),
1392            "tab_size should be saved (differs from default)"
1393        );
1394        assert_eq!(
1395            json.get("lsp")
1396                .and_then(|l| l.get("python"))
1397                .and_then(|p| p.get("enabled"))
1398                .and_then(|v| v.as_bool()),
1399            Some(false),
1400            "lsp.python.enabled should be saved (differs from default)"
1401        );
1402
1403        // Reload and verify the full config is correct
1404        let reloaded = resolver.resolve().unwrap();
1405        assert_eq!(reloaded.theme.0, "dracula");
1406        assert_eq!(reloaded.editor.tab_size, 2);
1407        assert!(!reloaded.lsp["python"].enabled);
1408        // Command should come from defaults
1409        assert_eq!(reloaded.lsp["python"].command, "pylsp");
1410    }
1411
1412    /// Test that toggling LSP enabled/disabled preserves the command field.
1413    ///
1414    /// 1. Start with empty config (defaults apply, python has command "pylsp")
1415    /// 2. Disable python LSP, save
1416    /// 3. Load, enable python LSP, save
1417    /// 4. Load and verify command is still the default
1418    #[test]
1419    fn toggle_lsp_preserves_command() {
1420        let (_temp, resolver) = create_test_resolver();
1421        let user_config_path = resolver.user_config_path();
1422        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1423
1424        // Step 1: Empty config - defaults apply (python has command "pylsp")
1425        std::fs::write(&user_config_path, r#"{}"#).unwrap();
1426
1427        // Load and verify default command
1428        let config = resolver.resolve().unwrap();
1429        let original_command = config.lsp["python"].command.clone();
1430        assert!(
1431            !original_command.is_empty(),
1432            "Default python LSP should have a command"
1433        );
1434
1435        // Step 2: Disable python LSP, save
1436        let mut config = resolver.resolve().unwrap();
1437        config.lsp.get_mut("python").unwrap().enabled = false;
1438        resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1439
1440        // Verify saved file only has enabled:false, not empty command/args
1441        let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1442        assert!(
1443            !saved_content.contains(r#""command""#),
1444            "Saved config should not contain 'command' field. File content: {}",
1445            saved_content
1446        );
1447        assert!(
1448            !saved_content.contains(r#""args""#),
1449            "Saved config should not contain 'args' field. File content: {}",
1450            saved_content
1451        );
1452
1453        // Step 3: Load again, enable python LSP, save
1454        let mut config = resolver.resolve().unwrap();
1455        assert!(!config.lsp["python"].enabled);
1456        config.lsp.get_mut("python").unwrap().enabled = true;
1457        resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1458
1459        // Step 4: Load and verify command is still the same
1460        let config = resolver.resolve().unwrap();
1461        assert_eq!(
1462            config.lsp["python"].command, original_command,
1463            "Command should be preserved after toggling enabled. Got: '{}'",
1464            config.lsp["python"].command
1465        );
1466    }
1467
1468    /// Issue #631 REPRODUCTION: Config with disabled LSP (no command) should be valid.
1469    ///
1470    /// Users write configs like:
1471    /// ```json
1472    /// { "lsp": { "python": { "enabled": false } } }
1473    /// ```
1474    /// This SHOULD be valid - a disabled LSP doesn't need a command.
1475    /// But currently it FAILS because `command` is required.
1476    ///
1477    /// THIS TEST WILL FAIL until the bug is fixed.
1478    #[test]
1479    fn issue_631_disabled_lsp_without_command_should_be_valid() {
1480        let (_temp, resolver) = create_test_resolver();
1481
1482        // Create the exact config from issue #631 - disabled LSP without command field
1483        let user_config_path = resolver.user_config_path();
1484        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1485        std::fs::write(
1486            &user_config_path,
1487            r#"{
1488                "lsp": {
1489                    "json": { "enabled": false },
1490                    "python": { "enabled": false },
1491                    "toml": { "enabled": false }
1492                },
1493                "theme": "dracula"
1494            }"#,
1495        )
1496        .unwrap();
1497
1498        // Try to load this config - it SHOULD succeed
1499        let result = resolver.resolve();
1500
1501        // THIS ASSERTION FAILS - demonstrating bug #631
1502        // A disabled LSP config should NOT require a command field
1503        assert!(
1504            result.is_ok(),
1505            "BUG #631: Config with disabled LSP should be valid even without 'command' field. \
1506             Got parse error: {:?}",
1507            result.err()
1508        );
1509
1510        // Verify the theme was loaded (config parsed correctly)
1511        let config = result.unwrap();
1512        assert_eq!(
1513            config.theme.0, "dracula",
1514            "Theme should be 'dracula' from config file"
1515        );
1516    }
1517
1518    /// Test that loading a config without command field uses the default command.
1519    #[test]
1520    fn loading_lsp_without_command_uses_default() {
1521        let (_temp, resolver) = create_test_resolver();
1522        let user_config_path = resolver.user_config_path();
1523        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1524
1525        // Write config with rust LSP but no command field
1526        std::fs::write(
1527            &user_config_path,
1528            r#"{ "lsp": { "rust": { "enabled": false } } }"#,
1529        )
1530        .unwrap();
1531
1532        // Load and check that command comes from defaults
1533        let config = resolver.resolve().unwrap();
1534        assert_eq!(
1535            config.lsp["rust"].command, "rust-analyzer",
1536            "Command should come from defaults when not in file. Got: '{}'",
1537            config.lsp["rust"].command
1538        );
1539        assert!(
1540            !config.lsp["rust"].enabled,
1541            "enabled should be false from file"
1542        );
1543    }
1544
1545    /// Test simulating the Settings UI flow using save_changes_to_layer:
1546    /// 1. Load config with defaults
1547    /// 2. Apply change (toggle enabled) via JSON pointer (like Settings UI does)
1548    /// 3. Save via save_changes_to_layer with explicit changes
1549    /// 4. Reload and verify command is preserved
1550    #[test]
1551    fn settings_ui_toggle_lsp_preserves_command() {
1552        let (_temp, resolver) = create_test_resolver();
1553        let user_config_path = resolver.user_config_path();
1554        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1555
1556        // Step 1: Start with empty config
1557        std::fs::write(&user_config_path, r#"{}"#).unwrap();
1558
1559        // Load resolved config - should have rust with command="rust-analyzer"
1560        let config = resolver.resolve().unwrap();
1561        assert_eq!(
1562            config.lsp["rust"].command, "rust-analyzer",
1563            "Default rust command should be rust-analyzer"
1564        );
1565        assert!(
1566            config.lsp["rust"].enabled,
1567            "Default rust enabled should be true"
1568        );
1569
1570        // Step 2: Simulate Settings UI applying a change to disable rust LSP
1571        // Using save_changes_to_layer with explicit change tracking
1572        let mut changes = std::collections::HashMap::new();
1573        changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(false));
1574        let deletions = std::collections::HashSet::new();
1575
1576        // Step 3: Save via save_changes_to_layer
1577        resolver
1578            .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1579            .unwrap();
1580
1581        // Check what was saved to file
1582        let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1583        eprintln!("After disable, file contains:\n{}", saved_content);
1584
1585        // Step 4: Reload and verify command is preserved
1586        let reloaded = resolver.resolve().unwrap();
1587        assert_eq!(
1588            reloaded.lsp["rust"].command, "rust-analyzer",
1589            "Command should be preserved after save/reload (disabled). Got: '{}'",
1590            reloaded.lsp["rust"].command
1591        );
1592        assert!(!reloaded.lsp["rust"].enabled, "rust should be disabled");
1593
1594        // Step 5: Re-enable rust LSP (simulating Settings UI)
1595        let mut changes = std::collections::HashMap::new();
1596        changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(true));
1597        let deletions = std::collections::HashSet::new();
1598
1599        // Step 6: Save via save_changes_to_layer
1600        resolver
1601            .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1602            .unwrap();
1603
1604        // Check what was saved to file
1605        let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1606        eprintln!("After re-enable, file contains:\n{}", saved_content);
1607
1608        // Step 7: Reload and verify command is STILL preserved
1609        let final_config = resolver.resolve().unwrap();
1610        assert_eq!(
1611            final_config.lsp["rust"].command, "rust-analyzer",
1612            "Command should be preserved after toggle cycle. Got: '{}'",
1613            final_config.lsp["rust"].command
1614        );
1615        assert!(final_config.lsp["rust"].enabled, "rust should be enabled");
1616    }
1617
1618    /// Issue #806 REPRODUCTION: Manual config.json edits are lost when saving from Settings UI.
1619    ///
1620    /// Scenario:
1621    /// 1. User manually edits config.json to add custom LSP settings (e.g., rust-analyzer with custom args)
1622    /// 2. User opens Settings UI and changes a simple setting (e.g., tab_size)
1623    /// 3. User saves the settings
1624    /// 4. Result: The manually-added LSP settings are GONE
1625    ///
1626    /// Expected behavior: Only the changed setting (tab_size) should be modified;
1627    /// the manually-added LSP settings should be preserved.
1628    #[test]
1629    fn issue_806_manual_config_edits_lost_when_saving_from_ui() {
1630        let (_temp, resolver) = create_test_resolver();
1631        let user_config_path = resolver.user_config_path();
1632        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1633
1634        // Step 1: User manually creates config.json with custom LSP settings
1635        // This is the EXACT example from issue #806
1636        std::fs::write(
1637            &user_config_path,
1638            r#"{
1639                "lsp": {
1640                    "rust-analyzer": {
1641                        "enabled": true,
1642                        "command": "rust-analyzer",
1643                        "args": ["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1644                        "languages": ["rust"]
1645                    }
1646                }
1647            }"#,
1648        )
1649        .unwrap();
1650
1651        // Step 2: Load the config (simulating Fresh startup)
1652        let config = resolver.resolve().unwrap();
1653
1654        // Verify the custom LSP settings were loaded
1655        assert!(
1656            config.lsp.contains_key("rust-analyzer"),
1657            "Config should contain manually-added 'rust-analyzer' LSP entry"
1658        );
1659        let rust_analyzer = &config.lsp["rust-analyzer"];
1660        assert!(rust_analyzer.enabled, "rust-analyzer should be enabled");
1661        assert_eq!(
1662            rust_analyzer.command, "rust-analyzer",
1663            "rust-analyzer command should be preserved"
1664        );
1665        assert_eq!(
1666            rust_analyzer.args,
1667            vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1668            "rust-analyzer args should be preserved"
1669        );
1670
1671        // Step 3: User opens Settings UI and changes tab_size
1672        // This simulates what SettingsState::apply_changes does
1673        let mut config_json = serde_json::to_value(&config).unwrap();
1674        *config_json
1675            .pointer_mut("/editor/tab_size")
1676            .expect("path should exist") = serde_json::json!(2);
1677        let modified_config: crate::config::Config =
1678            serde_json::from_value(config_json).expect("should deserialize");
1679
1680        // Step 4: Save via save_to_layer (what save_settings() does)
1681        resolver
1682            .save_to_layer(&modified_config, ConfigLayer::User)
1683            .unwrap();
1684
1685        // Step 5: Check what was saved to file
1686        let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1687        let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1688
1689        eprintln!(
1690            "Issue #806 - Saved config after changing tab_size:\n{}",
1691            serde_json::to_string_pretty(&saved_json).unwrap()
1692        );
1693
1694        // CRITICAL ASSERTION: The "lsp" section with "rust-analyzer" MUST still be present
1695        assert!(
1696            saved_json.get("lsp").is_some(),
1697            "BUG #806: 'lsp' section should NOT be deleted when saving unrelated changes. \
1698             File content: {}",
1699            saved_content
1700        );
1701
1702        assert!(
1703            saved_json
1704                .get("lsp")
1705                .and_then(|l| l.get("rust-analyzer"))
1706                .is_some(),
1707            "BUG #806: 'lsp.rust-analyzer' should NOT be deleted when saving unrelated changes. \
1708             File content: {}",
1709            saved_content
1710        );
1711
1712        // Verify the custom args are preserved
1713        let saved_args = saved_json
1714            .get("lsp")
1715            .and_then(|l| l.get("rust-analyzer"))
1716            .and_then(|r| r.get("args"));
1717        assert!(
1718            saved_args.is_some(),
1719            "BUG #806: 'lsp.rust-analyzer.args' should be preserved. File content: {}",
1720            saved_content
1721        );
1722        assert_eq!(
1723            saved_args.unwrap(),
1724            &serde_json::json!(["--log-file", "/tmp/rust-analyzer-{pid}.log"]),
1725            "BUG #806: Custom args should be preserved exactly"
1726        );
1727
1728        // Verify the tab_size change was saved
1729        assert_eq!(
1730            saved_json
1731                .get("editor")
1732                .and_then(|e| e.get("tab_size"))
1733                .and_then(|v| v.as_u64()),
1734            Some(2),
1735            "tab_size should be saved"
1736        );
1737
1738        // Step 6: Reload and verify everything is intact
1739        let reloaded = resolver.resolve().unwrap();
1740        assert_eq!(
1741            reloaded.editor.tab_size, 2,
1742            "tab_size change should be persisted"
1743        );
1744        assert!(
1745            reloaded.lsp.contains_key("rust-analyzer"),
1746            "BUG #806: rust-analyzer should still exist after reload"
1747        );
1748        let reloaded_ra = &reloaded.lsp["rust-analyzer"];
1749        assert_eq!(
1750            reloaded_ra.args,
1751            vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1752            "BUG #806: Custom args should survive save/reload cycle"
1753        );
1754    }
1755
1756    /// Issue #806 - Variant: Test with multiple custom settings that don't exist in defaults.
1757    ///
1758    /// This tests a broader scenario where the user has added multiple custom
1759    /// configurations that are not part of the default config structure.
1760    #[test]
1761    fn issue_806_custom_lsp_entries_preserved_across_unrelated_changes() {
1762        let (_temp, resolver) = create_test_resolver();
1763        let user_config_path = resolver.user_config_path();
1764        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1765
1766        // User creates config with a completely custom LSP server not in defaults
1767        std::fs::write(
1768            &user_config_path,
1769            r#"{
1770                "theme": "dracula",
1771                "lsp": {
1772                    "my-custom-lsp": {
1773                        "enabled": true,
1774                        "command": "/usr/local/bin/my-custom-lsp",
1775                        "args": ["--verbose", "--config", "/etc/my-lsp.json"],
1776                        "languages": ["mycustomlang"]
1777                    }
1778                },
1779                "languages": {
1780                    "mycustomlang": {
1781                        "extensions": [".mcl"],
1782                        "grammar": "mycustomlang"
1783                    }
1784                }
1785            }"#,
1786        )
1787        .unwrap();
1788
1789        // Load and verify custom settings exist
1790        let config = resolver.resolve().unwrap();
1791        assert!(
1792            config.lsp.contains_key("my-custom-lsp"),
1793            "Custom LSP entry should be loaded"
1794        );
1795        assert!(
1796            config.languages.contains_key("mycustomlang"),
1797            "Custom language should be loaded"
1798        );
1799
1800        // User changes only line_numbers in Settings UI
1801        let mut config_json = serde_json::to_value(&config).unwrap();
1802        *config_json
1803            .pointer_mut("/editor/line_numbers")
1804            .expect("path should exist") = serde_json::json!(false);
1805        let modified_config: crate::config::Config =
1806            serde_json::from_value(config_json).expect("should deserialize");
1807
1808        // Save
1809        resolver
1810            .save_to_layer(&modified_config, ConfigLayer::User)
1811            .unwrap();
1812
1813        // Verify file still contains custom LSP
1814        let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1815        let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1816
1817        eprintln!(
1818            "Saved config:\n{}",
1819            serde_json::to_string_pretty(&saved_json).unwrap()
1820        );
1821
1822        // Custom LSP must be preserved
1823        assert!(
1824            saved_json
1825                .get("lsp")
1826                .and_then(|l| l.get("my-custom-lsp"))
1827                .is_some(),
1828            "BUG #806: Custom LSP 'my-custom-lsp' should be preserved. Got: {}",
1829            saved_content
1830        );
1831
1832        // Custom language must be preserved
1833        assert!(
1834            saved_json
1835                .get("languages")
1836                .and_then(|l| l.get("mycustomlang"))
1837                .is_some(),
1838            "BUG #806: Custom language 'mycustomlang' should be preserved. Got: {}",
1839            saved_content
1840        );
1841
1842        // Reload and verify
1843        let reloaded = resolver.resolve().unwrap();
1844        assert!(
1845            reloaded.lsp.contains_key("my-custom-lsp"),
1846            "Custom LSP should survive save/reload"
1847        );
1848        assert!(
1849            reloaded.languages.contains_key("mycustomlang"),
1850            "Custom language should survive save/reload"
1851        );
1852        assert!(
1853            !reloaded.editor.line_numbers,
1854            "line_numbers change should be applied"
1855        );
1856    }
1857
1858    /// Issue #806 - Scenario 2: External file modification after Fresh is running.
1859    ///
1860    /// This is the most likely real-world scenario:
1861    /// 1. User starts Fresh with default/existing config (loaded into memory)
1862    /// 2. User manually edits config.json WHILE Fresh is running (external edit)
1863    /// 3. User opens Settings UI in Fresh and changes a simple setting
1864    /// 4. User saves from Settings UI
1865    /// 5. BUG: The external edits are LOST because Fresh's in-memory config
1866    ///    doesn't have them
1867    ///
1868    /// This test verifies that even if the file was modified externally,
1869    /// the save operation should preserve those external changes.
1870    #[test]
1871    fn issue_806_external_file_modification_lost_on_ui_save() {
1872        let (_temp, resolver) = create_test_resolver();
1873        let user_config_path = resolver.user_config_path();
1874        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1875
1876        // Step 1: User starts Fresh with a simple config
1877        std::fs::write(&user_config_path, r#"{"theme": "monokai"}"#).unwrap();
1878
1879        // Step 2: Fresh loads the config (simulating startup)
1880        let config_at_startup = resolver.resolve().unwrap();
1881        assert_eq!(config_at_startup.theme.0, "monokai");
1882        assert!(
1883            !config_at_startup.lsp.contains_key("rust-analyzer"),
1884            "No custom LSP at startup"
1885        );
1886
1887        // Step 3: User externally edits config.json (e.g., with another editor)
1888        // to add custom LSP settings. Fresh doesn't see this change yet.
1889        std::fs::write(
1890            &user_config_path,
1891            r#"{
1892                "theme": "monokai",
1893                "lsp": {
1894                    "rust-analyzer": {
1895                        "enabled": true,
1896                        "command": "rust-analyzer",
1897                        "args": ["--log-file", "/tmp/ra.log"]
1898                    }
1899                }
1900            }"#,
1901        )
1902        .unwrap();
1903
1904        // Step 4: User opens Settings UI and changes tab_size
1905        // The Settings UI works with the IN-MEMORY config (config_at_startup)
1906        // which does NOT have the external LSP changes
1907        let mut config_json = serde_json::to_value(&config_at_startup).unwrap();
1908        *config_json
1909            .pointer_mut("/editor/tab_size")
1910            .expect("path should exist") = serde_json::json!(2);
1911        let modified_config: crate::config::Config =
1912            serde_json::from_value(config_json).expect("should deserialize");
1913
1914        // Step 5: User saves from Settings UI
1915        // This is where the bug occurs - the in-memory config (without LSP)
1916        // is saved, overwriting the external changes
1917        resolver
1918            .save_to_layer(&modified_config, ConfigLayer::User)
1919            .unwrap();
1920
1921        // Step 6: Check what was saved
1922        let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1923        let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1924
1925        eprintln!(
1926            "Issue #806 scenario 2 - After UI save (external edits should be preserved):\n{}",
1927            serde_json::to_string_pretty(&saved_json).unwrap()
1928        );
1929
1930        // This assertion will FAIL if the bug exists
1931        // The LSP section added externally should be preserved
1932        // BUT with current implementation, it will be LOST because
1933        // save_to_layer computes delta from in-memory config (which has no LSP)
1934        // vs system defaults, NOT from the current file contents
1935        assert!(
1936            saved_json.get("lsp").is_some(),
1937            "BUG #806: External edits to config.json were lost! \
1938             The 'lsp' section added while Fresh was running should be preserved. \
1939             Saved content: {}",
1940            saved_content
1941        );
1942
1943        assert!(
1944            saved_json
1945                .get("lsp")
1946                .and_then(|l| l.get("rust-analyzer"))
1947                .is_some(),
1948            "BUG #806: rust-analyzer config should be preserved"
1949        );
1950    }
1951
1952    /// Issue #806 - Scenario 3: Multiple users/processes editing config
1953    ///
1954    /// Even more edge case: Config is modified by another process right before save.
1955    /// This demonstrates that save_to_layer() should ideally do a read-modify-write
1956    /// operation, not just a write.
1957    #[test]
1958    fn issue_806_concurrent_modification_scenario() {
1959        let (_temp, resolver) = create_test_resolver();
1960        let user_config_path = resolver.user_config_path();
1961        std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1962
1963        // Start with empty config
1964        std::fs::write(&user_config_path, r#"{}"#).unwrap();
1965
1966        // Load config
1967        let mut config = resolver.resolve().unwrap();
1968
1969        // Modify in memory: change tab_size
1970        config.editor.tab_size = 8;
1971
1972        // Meanwhile, another process adds LSP config to the file
1973        std::fs::write(
1974            &user_config_path,
1975            r#"{
1976                "lsp": {
1977                    "custom-lsp": {
1978                        "enabled": true,
1979                        "command": "/usr/bin/custom-lsp"
1980                    }
1981                }
1982            }"#,
1983        )
1984        .unwrap();
1985
1986        // Now save our in-memory config
1987        // With current implementation, this will OVERWRITE the concurrent changes
1988        resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1989
1990        // Check result
1991        let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1992        let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1993
1994        eprintln!(
1995            "Concurrent modification scenario result:\n{}",
1996            serde_json::to_string_pretty(&saved_json).unwrap()
1997        );
1998
1999        // Verify our change was saved
2000        assert_eq!(
2001            saved_json
2002                .get("editor")
2003                .and_then(|e| e.get("tab_size"))
2004                .and_then(|v| v.as_u64()),
2005            Some(8),
2006            "Our tab_size change should be saved"
2007        );
2008
2009        // The concurrent LSP change will be lost with current implementation
2010        // This is a known limitation - documenting it here
2011        // A proper fix would involve read-modify-write with conflict detection
2012        //
2013        // For now, we just document that this scenario loses concurrent changes:
2014        let lsp_preserved = saved_json.get("lsp").is_some();
2015        if !lsp_preserved {
2016            eprintln!(
2017                "NOTE: Concurrent file modifications are lost with current implementation. \
2018                 This is expected behavior but could be improved with read-modify-write pattern."
2019            );
2020        }
2021    }
2022}