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