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