Skip to main content

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