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