Skip to main content

fresh/
partial_config.rs

1//! Partial configuration types for layered config merging.
2//!
3//! This module provides `Option`-wrapped versions of all config structs,
4//! enabling a 4-level overlay architecture (System → User → Project → Session).
5
6use crate::config::{
7    AcceptSuggestionOnEnter, ClipboardConfig, CursorStyle, FileBrowserConfig, FileExplorerConfig,
8    FormatterConfig, HighlighterPreference, Keybinding, KeybindingMapName, KeymapConfig,
9    LanguageConfig, LineEndingOption, OnSaveAction, PluginConfig, TerminalConfig, ThemeName,
10    WarningsConfig,
11};
12use crate::types::LspServerConfig;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Trait for merging configuration layers.
17/// Higher precedence values (self) override lower precedence (other).
18pub trait Merge {
19    /// Merge values from a lower-precedence layer into this layer.
20    /// Values already set in self take precedence over values in other.
21    fn merge_from(&mut self, other: &Self);
22}
23
24impl<T: Clone> Merge for Option<T> {
25    fn merge_from(&mut self, other: &Self) {
26        if self.is_none() {
27            *self = other.clone();
28        }
29    }
30}
31
32/// Merge two HashMaps where self's entries take precedence.
33/// Entries from other are added if not present in self.
34fn merge_hashmap<K: Clone + Eq + std::hash::Hash, V: Clone>(
35    target: &mut Option<HashMap<K, V>>,
36    other: &Option<HashMap<K, V>>,
37) {
38    match (target, other) {
39        (Some(t), Some(o)) => {
40            for (key, value) in o {
41                t.entry(key.clone()).or_insert_with(|| value.clone());
42            }
43        }
44        (t @ None, Some(o)) => {
45            *t = Some(o.clone());
46        }
47        _ => {}
48    }
49}
50
51/// Merge two HashMaps where values implement Merge (for recursive merging).
52fn merge_hashmap_recursive<K, V>(target: &mut Option<HashMap<K, V>>, other: &Option<HashMap<K, V>>)
53where
54    K: Clone + Eq + std::hash::Hash,
55    V: Clone + Merge + Default,
56{
57    match (target, other) {
58        (Some(t), Some(o)) => {
59            for (key, value) in o {
60                t.entry(key.clone())
61                    .and_modify(|existing| existing.merge_from(value))
62                    .or_insert_with(|| value.clone());
63            }
64        }
65        (t @ None, Some(o)) => {
66            *t = Some(o.clone());
67        }
68        _ => {}
69    }
70}
71
72/// Partial configuration where all fields are optional.
73/// Represents a single configuration layer (User, Project, or Session).
74#[derive(Debug, Clone, Default, Deserialize, Serialize)]
75#[serde(default)]
76pub struct PartialConfig {
77    pub version: Option<u32>,
78    pub theme: Option<ThemeName>,
79    pub locale: Option<String>,
80    pub check_for_updates: Option<bool>,
81    pub editor: Option<PartialEditorConfig>,
82    pub file_explorer: Option<PartialFileExplorerConfig>,
83    pub file_browser: Option<PartialFileBrowserConfig>,
84    pub clipboard: Option<PartialClipboardConfig>,
85    pub terminal: Option<PartialTerminalConfig>,
86    pub keybindings: Option<Vec<Keybinding>>,
87    pub keybinding_maps: Option<HashMap<String, KeymapConfig>>,
88    pub active_keybinding_map: Option<KeybindingMapName>,
89    pub languages: Option<HashMap<String, PartialLanguageConfig>>,
90    pub lsp: Option<HashMap<String, LspServerConfig>>,
91    pub warnings: Option<PartialWarningsConfig>,
92    pub plugins: Option<HashMap<String, PartialPluginConfig>>,
93    pub packages: Option<PartialPackagesConfig>,
94}
95
96impl Merge for PartialConfig {
97    fn merge_from(&mut self, other: &Self) {
98        self.version.merge_from(&other.version);
99        self.theme.merge_from(&other.theme);
100        self.locale.merge_from(&other.locale);
101        self.check_for_updates.merge_from(&other.check_for_updates);
102
103        // Nested structs: merge recursively
104        merge_partial(&mut self.editor, &other.editor);
105        merge_partial(&mut self.file_explorer, &other.file_explorer);
106        merge_partial(&mut self.file_browser, &other.file_browser);
107        merge_partial(&mut self.clipboard, &other.clipboard);
108        merge_partial(&mut self.terminal, &other.terminal);
109        merge_partial(&mut self.warnings, &other.warnings);
110        merge_partial(&mut self.packages, &other.packages);
111
112        // Lists: higher precedence replaces (per design doc)
113        self.keybindings.merge_from(&other.keybindings);
114
115        // HashMaps: merge entries, higher precedence wins on key collision
116        merge_hashmap(&mut self.keybinding_maps, &other.keybinding_maps);
117        merge_hashmap_recursive(&mut self.languages, &other.languages);
118        merge_hashmap_recursive(&mut self.lsp, &other.lsp);
119        merge_hashmap_recursive(&mut self.plugins, &other.plugins);
120
121        self.active_keybinding_map
122            .merge_from(&other.active_keybinding_map);
123    }
124}
125
126/// Helper to merge nested partial structs.
127fn merge_partial<T: Merge + Clone>(target: &mut Option<T>, other: &Option<T>) {
128    match (target, other) {
129        (Some(t), Some(o)) => t.merge_from(o),
130        (t @ None, Some(o)) => *t = Some(o.clone()),
131        _ => {}
132    }
133}
134
135/// Partial editor configuration.
136#[derive(Debug, Clone, Default, Deserialize, Serialize)]
137#[serde(default)]
138pub struct PartialEditorConfig {
139    pub tab_size: Option<usize>,
140    pub auto_indent: Option<bool>,
141    pub auto_close: Option<bool>,
142    pub auto_surround: Option<bool>,
143    pub line_numbers: Option<bool>,
144    pub relative_line_numbers: Option<bool>,
145    pub scroll_offset: Option<usize>,
146    pub syntax_highlighting: Option<bool>,
147    pub line_wrap: Option<bool>,
148    pub wrap_indent: Option<bool>,
149    pub highlight_timeout_ms: Option<u64>,
150    pub snapshot_interval: Option<usize>,
151    pub large_file_threshold_bytes: Option<u64>,
152    pub estimated_line_length: Option<usize>,
153    pub enable_inlay_hints: Option<bool>,
154    pub enable_semantic_tokens_full: Option<bool>,
155    pub diagnostics_inline_text: Option<bool>,
156    pub recovery_enabled: Option<bool>,
157    pub auto_recovery_save_interval_secs: Option<u32>,
158    pub auto_save_enabled: Option<bool>,
159    pub auto_save_interval_secs: Option<u32>,
160    pub highlight_context_bytes: Option<usize>,
161    pub mouse_hover_enabled: Option<bool>,
162    pub mouse_hover_delay_ms: Option<u64>,
163    pub double_click_time_ms: Option<u64>,
164    pub auto_revert_poll_interval_ms: Option<u64>,
165    pub read_concurrency: Option<usize>,
166    pub file_tree_poll_interval_ms: Option<u64>,
167    pub default_line_ending: Option<LineEndingOption>,
168    pub trim_trailing_whitespace_on_save: Option<bool>,
169    pub ensure_final_newline_on_save: Option<bool>,
170    pub highlight_matching_brackets: Option<bool>,
171    pub rainbow_brackets: Option<bool>,
172    pub cursor_style: Option<CursorStyle>,
173    pub keyboard_disambiguate_escape_codes: Option<bool>,
174    pub keyboard_report_event_types: Option<bool>,
175    pub keyboard_report_alternate_keys: Option<bool>,
176    pub keyboard_report_all_keys_as_escape_codes: Option<bool>,
177    pub quick_suggestions: Option<bool>,
178    pub quick_suggestions_delay_ms: Option<u64>,
179    pub suggest_on_trigger_characters: Option<bool>,
180    pub accept_suggestion_on_enter: Option<AcceptSuggestionOnEnter>,
181    pub show_menu_bar: Option<bool>,
182    pub show_tab_bar: Option<bool>,
183    pub show_status_bar: Option<bool>,
184    pub show_vertical_scrollbar: Option<bool>,
185    pub show_horizontal_scrollbar: Option<bool>,
186    pub use_terminal_bg: Option<bool>,
187    pub rulers: Option<Vec<usize>>,
188    pub whitespace_show: Option<bool>,
189    pub whitespace_spaces_leading: Option<bool>,
190    pub whitespace_spaces_inner: Option<bool>,
191    pub whitespace_spaces_trailing: Option<bool>,
192    pub whitespace_tabs_leading: Option<bool>,
193    pub whitespace_tabs_inner: Option<bool>,
194    pub whitespace_tabs_trailing: Option<bool>,
195}
196
197impl Merge for PartialEditorConfig {
198    fn merge_from(&mut self, other: &Self) {
199        self.tab_size.merge_from(&other.tab_size);
200        self.auto_indent.merge_from(&other.auto_indent);
201        self.auto_close.merge_from(&other.auto_close);
202        self.auto_surround.merge_from(&other.auto_surround);
203        self.line_numbers.merge_from(&other.line_numbers);
204        self.relative_line_numbers
205            .merge_from(&other.relative_line_numbers);
206        self.scroll_offset.merge_from(&other.scroll_offset);
207        self.syntax_highlighting
208            .merge_from(&other.syntax_highlighting);
209        self.line_wrap.merge_from(&other.line_wrap);
210        self.wrap_indent.merge_from(&other.wrap_indent);
211        self.highlight_timeout_ms
212            .merge_from(&other.highlight_timeout_ms);
213        self.snapshot_interval.merge_from(&other.snapshot_interval);
214        self.large_file_threshold_bytes
215            .merge_from(&other.large_file_threshold_bytes);
216        self.estimated_line_length
217            .merge_from(&other.estimated_line_length);
218        self.enable_inlay_hints
219            .merge_from(&other.enable_inlay_hints);
220        self.enable_semantic_tokens_full
221            .merge_from(&other.enable_semantic_tokens_full);
222        self.diagnostics_inline_text
223            .merge_from(&other.diagnostics_inline_text);
224        self.recovery_enabled.merge_from(&other.recovery_enabled);
225        self.auto_recovery_save_interval_secs
226            .merge_from(&other.auto_recovery_save_interval_secs);
227        self.auto_save_enabled.merge_from(&other.auto_save_enabled);
228        self.auto_save_interval_secs
229            .merge_from(&other.auto_save_interval_secs);
230        self.highlight_context_bytes
231            .merge_from(&other.highlight_context_bytes);
232        self.mouse_hover_enabled
233            .merge_from(&other.mouse_hover_enabled);
234        self.mouse_hover_delay_ms
235            .merge_from(&other.mouse_hover_delay_ms);
236        self.double_click_time_ms
237            .merge_from(&other.double_click_time_ms);
238        self.auto_revert_poll_interval_ms
239            .merge_from(&other.auto_revert_poll_interval_ms);
240        self.read_concurrency.merge_from(&other.read_concurrency);
241        self.file_tree_poll_interval_ms
242            .merge_from(&other.file_tree_poll_interval_ms);
243        self.default_line_ending
244            .merge_from(&other.default_line_ending);
245        self.trim_trailing_whitespace_on_save
246            .merge_from(&other.trim_trailing_whitespace_on_save);
247        self.ensure_final_newline_on_save
248            .merge_from(&other.ensure_final_newline_on_save);
249        self.highlight_matching_brackets
250            .merge_from(&other.highlight_matching_brackets);
251        self.rainbow_brackets.merge_from(&other.rainbow_brackets);
252        self.cursor_style.merge_from(&other.cursor_style);
253        self.keyboard_disambiguate_escape_codes
254            .merge_from(&other.keyboard_disambiguate_escape_codes);
255        self.keyboard_report_event_types
256            .merge_from(&other.keyboard_report_event_types);
257        self.keyboard_report_alternate_keys
258            .merge_from(&other.keyboard_report_alternate_keys);
259        self.keyboard_report_all_keys_as_escape_codes
260            .merge_from(&other.keyboard_report_all_keys_as_escape_codes);
261        self.quick_suggestions.merge_from(&other.quick_suggestions);
262        self.quick_suggestions_delay_ms
263            .merge_from(&other.quick_suggestions_delay_ms);
264        self.suggest_on_trigger_characters
265            .merge_from(&other.suggest_on_trigger_characters);
266        self.accept_suggestion_on_enter
267            .merge_from(&other.accept_suggestion_on_enter);
268        self.show_menu_bar.merge_from(&other.show_menu_bar);
269        self.show_tab_bar.merge_from(&other.show_tab_bar);
270        self.show_status_bar.merge_from(&other.show_status_bar);
271        self.show_vertical_scrollbar
272            .merge_from(&other.show_vertical_scrollbar);
273        self.show_horizontal_scrollbar
274            .merge_from(&other.show_horizontal_scrollbar);
275        self.use_terminal_bg.merge_from(&other.use_terminal_bg);
276        self.rulers.merge_from(&other.rulers);
277        self.whitespace_show.merge_from(&other.whitespace_show);
278        self.whitespace_spaces_leading
279            .merge_from(&other.whitespace_spaces_leading);
280        self.whitespace_spaces_inner
281            .merge_from(&other.whitespace_spaces_inner);
282        self.whitespace_spaces_trailing
283            .merge_from(&other.whitespace_spaces_trailing);
284        self.whitespace_tabs_leading
285            .merge_from(&other.whitespace_tabs_leading);
286        self.whitespace_tabs_inner
287            .merge_from(&other.whitespace_tabs_inner);
288        self.whitespace_tabs_trailing
289            .merge_from(&other.whitespace_tabs_trailing);
290    }
291}
292
293/// Partial file explorer configuration.
294#[derive(Debug, Clone, Default, Deserialize, Serialize)]
295#[serde(default)]
296pub struct PartialFileExplorerConfig {
297    pub respect_gitignore: Option<bool>,
298    pub show_hidden: Option<bool>,
299    pub show_gitignored: Option<bool>,
300    pub custom_ignore_patterns: Option<Vec<String>>,
301    pub width: Option<f32>,
302}
303
304impl Merge for PartialFileExplorerConfig {
305    fn merge_from(&mut self, other: &Self) {
306        self.respect_gitignore.merge_from(&other.respect_gitignore);
307        self.show_hidden.merge_from(&other.show_hidden);
308        self.show_gitignored.merge_from(&other.show_gitignored);
309        self.custom_ignore_patterns
310            .merge_from(&other.custom_ignore_patterns);
311        self.width.merge_from(&other.width);
312    }
313}
314
315/// Partial file browser configuration.
316#[derive(Debug, Clone, Default, Deserialize, Serialize)]
317#[serde(default)]
318pub struct PartialFileBrowserConfig {
319    pub show_hidden: Option<bool>,
320}
321
322impl Merge for PartialFileBrowserConfig {
323    fn merge_from(&mut self, other: &Self) {
324        self.show_hidden.merge_from(&other.show_hidden);
325    }
326}
327
328/// Partial clipboard configuration.
329#[derive(Debug, Clone, Default, Deserialize, Serialize)]
330#[serde(default)]
331pub struct PartialClipboardConfig {
332    pub use_osc52: Option<bool>,
333    pub use_system_clipboard: Option<bool>,
334}
335
336impl Merge for PartialClipboardConfig {
337    fn merge_from(&mut self, other: &Self) {
338        self.use_osc52.merge_from(&other.use_osc52);
339        self.use_system_clipboard
340            .merge_from(&other.use_system_clipboard);
341    }
342}
343
344/// Partial terminal configuration.
345#[derive(Debug, Clone, Default, Deserialize, Serialize)]
346#[serde(default)]
347pub struct PartialTerminalConfig {
348    pub jump_to_end_on_output: Option<bool>,
349}
350
351impl Merge for PartialTerminalConfig {
352    fn merge_from(&mut self, other: &Self) {
353        self.jump_to_end_on_output
354            .merge_from(&other.jump_to_end_on_output);
355    }
356}
357
358/// Partial warnings configuration.
359#[derive(Debug, Clone, Default, Deserialize, Serialize)]
360#[serde(default)]
361pub struct PartialWarningsConfig {
362    pub show_status_indicator: Option<bool>,
363}
364
365impl Merge for PartialWarningsConfig {
366    fn merge_from(&mut self, other: &Self) {
367        self.show_status_indicator
368            .merge_from(&other.show_status_indicator);
369    }
370}
371
372/// Partial packages configuration for plugin/theme package management.
373#[derive(Debug, Clone, Default, Deserialize, Serialize)]
374#[serde(default)]
375pub struct PartialPackagesConfig {
376    pub sources: Option<Vec<String>>,
377}
378
379impl Merge for PartialPackagesConfig {
380    fn merge_from(&mut self, other: &Self) {
381        self.sources.merge_from(&other.sources);
382    }
383}
384
385/// Partial plugin configuration.
386#[derive(Debug, Clone, Default, Deserialize, Serialize)]
387#[serde(default)]
388pub struct PartialPluginConfig {
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub enabled: Option<bool>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub path: Option<std::path::PathBuf>,
393}
394
395impl Merge for PartialPluginConfig {
396    fn merge_from(&mut self, other: &Self) {
397        self.enabled.merge_from(&other.enabled);
398        self.path.merge_from(&other.path);
399    }
400}
401
402/// Partial language configuration.
403#[derive(Debug, Clone, Default, Deserialize, Serialize)]
404#[serde(default)]
405pub struct PartialLanguageConfig {
406    pub extensions: Option<Vec<String>>,
407    pub filenames: Option<Vec<String>>,
408    pub grammar: Option<String>,
409    pub comment_prefix: Option<String>,
410    pub auto_indent: Option<bool>,
411    pub auto_close: Option<bool>,
412    pub auto_surround: Option<bool>,
413    pub highlighter: Option<HighlighterPreference>,
414    pub textmate_grammar: Option<std::path::PathBuf>,
415    pub show_whitespace_tabs: Option<bool>,
416    pub use_tabs: Option<bool>,
417    pub tab_size: Option<usize>,
418    pub formatter: Option<FormatterConfig>,
419    pub format_on_save: Option<bool>,
420    pub on_save: Option<Vec<OnSaveAction>>,
421}
422
423impl Merge for PartialLanguageConfig {
424    fn merge_from(&mut self, other: &Self) {
425        self.extensions.merge_from(&other.extensions);
426        self.filenames.merge_from(&other.filenames);
427        self.grammar.merge_from(&other.grammar);
428        self.comment_prefix.merge_from(&other.comment_prefix);
429        self.auto_indent.merge_from(&other.auto_indent);
430        self.auto_close.merge_from(&other.auto_close);
431        self.auto_surround.merge_from(&other.auto_surround);
432        self.highlighter.merge_from(&other.highlighter);
433        self.textmate_grammar.merge_from(&other.textmate_grammar);
434        self.show_whitespace_tabs
435            .merge_from(&other.show_whitespace_tabs);
436        self.use_tabs.merge_from(&other.use_tabs);
437        self.tab_size.merge_from(&other.tab_size);
438        self.formatter.merge_from(&other.formatter);
439        self.format_on_save.merge_from(&other.format_on_save);
440        self.on_save.merge_from(&other.on_save);
441    }
442}
443
444impl Merge for LspServerConfig {
445    fn merge_from(&mut self, other: &Self) {
446        // If command is empty (serde default), use other's command
447        if self.command.is_empty() {
448            self.command = other.command.clone();
449        }
450        // If args is empty, use other's args
451        if self.args.is_empty() {
452            self.args = other.args.clone();
453        }
454        // For booleans, keep self's value (we can't tell if explicitly set)
455        // For process_limits, keep self's value
456        // For initialization_options, use self if Some, otherwise other
457        if self.initialization_options.is_none() {
458            self.initialization_options = other.initialization_options.clone();
459        }
460        // For env, merge: other's values first, then self's values override
461        if self.env.is_empty() {
462            self.env = other.env.clone();
463        } else if !other.env.is_empty() {
464            let mut merged = other.env.clone();
465            merged.extend(self.env.drain());
466            self.env = merged;
467        }
468        // For language_id_overrides, merge: other's values first, then self's values override
469        if self.language_id_overrides.is_empty() {
470            self.language_id_overrides = other.language_id_overrides.clone();
471        } else if !other.language_id_overrides.is_empty() {
472            let mut merged = other.language_id_overrides.clone();
473            merged.extend(self.language_id_overrides.drain());
474            self.language_id_overrides = merged;
475        }
476    }
477}
478
479// Conversion traits for resolving partial configs to concrete configs
480
481impl From<&crate::config::EditorConfig> for PartialEditorConfig {
482    fn from(cfg: &crate::config::EditorConfig) -> Self {
483        Self {
484            tab_size: Some(cfg.tab_size),
485            auto_indent: Some(cfg.auto_indent),
486            auto_close: Some(cfg.auto_close),
487            auto_surround: Some(cfg.auto_surround),
488            line_numbers: Some(cfg.line_numbers),
489            relative_line_numbers: Some(cfg.relative_line_numbers),
490            scroll_offset: Some(cfg.scroll_offset),
491            syntax_highlighting: Some(cfg.syntax_highlighting),
492            line_wrap: Some(cfg.line_wrap),
493            wrap_indent: Some(cfg.wrap_indent),
494            highlight_timeout_ms: Some(cfg.highlight_timeout_ms),
495            snapshot_interval: Some(cfg.snapshot_interval),
496            large_file_threshold_bytes: Some(cfg.large_file_threshold_bytes),
497            estimated_line_length: Some(cfg.estimated_line_length),
498            enable_inlay_hints: Some(cfg.enable_inlay_hints),
499            enable_semantic_tokens_full: Some(cfg.enable_semantic_tokens_full),
500            diagnostics_inline_text: Some(cfg.diagnostics_inline_text),
501            recovery_enabled: Some(cfg.recovery_enabled),
502            auto_recovery_save_interval_secs: Some(cfg.auto_recovery_save_interval_secs),
503            auto_save_enabled: Some(cfg.auto_save_enabled),
504            auto_save_interval_secs: Some(cfg.auto_save_interval_secs),
505            highlight_context_bytes: Some(cfg.highlight_context_bytes),
506            mouse_hover_enabled: Some(cfg.mouse_hover_enabled),
507            mouse_hover_delay_ms: Some(cfg.mouse_hover_delay_ms),
508            double_click_time_ms: Some(cfg.double_click_time_ms),
509            auto_revert_poll_interval_ms: Some(cfg.auto_revert_poll_interval_ms),
510            read_concurrency: Some(cfg.read_concurrency),
511            file_tree_poll_interval_ms: Some(cfg.file_tree_poll_interval_ms),
512            default_line_ending: Some(cfg.default_line_ending.clone()),
513            trim_trailing_whitespace_on_save: Some(cfg.trim_trailing_whitespace_on_save),
514            ensure_final_newline_on_save: Some(cfg.ensure_final_newline_on_save),
515            highlight_matching_brackets: Some(cfg.highlight_matching_brackets),
516            rainbow_brackets: Some(cfg.rainbow_brackets),
517            cursor_style: Some(cfg.cursor_style),
518            keyboard_disambiguate_escape_codes: Some(cfg.keyboard_disambiguate_escape_codes),
519            keyboard_report_event_types: Some(cfg.keyboard_report_event_types),
520            keyboard_report_alternate_keys: Some(cfg.keyboard_report_alternate_keys),
521            keyboard_report_all_keys_as_escape_codes: Some(
522                cfg.keyboard_report_all_keys_as_escape_codes,
523            ),
524            quick_suggestions: Some(cfg.quick_suggestions),
525            quick_suggestions_delay_ms: Some(cfg.quick_suggestions_delay_ms),
526            suggest_on_trigger_characters: Some(cfg.suggest_on_trigger_characters),
527            accept_suggestion_on_enter: Some(cfg.accept_suggestion_on_enter),
528            show_menu_bar: Some(cfg.show_menu_bar),
529            show_tab_bar: Some(cfg.show_tab_bar),
530            show_status_bar: Some(cfg.show_status_bar),
531            show_vertical_scrollbar: Some(cfg.show_vertical_scrollbar),
532            show_horizontal_scrollbar: Some(cfg.show_horizontal_scrollbar),
533            use_terminal_bg: Some(cfg.use_terminal_bg),
534            rulers: Some(cfg.rulers.clone()),
535            whitespace_show: Some(cfg.whitespace_show),
536            whitespace_spaces_leading: Some(cfg.whitespace_spaces_leading),
537            whitespace_spaces_inner: Some(cfg.whitespace_spaces_inner),
538            whitespace_spaces_trailing: Some(cfg.whitespace_spaces_trailing),
539            whitespace_tabs_leading: Some(cfg.whitespace_tabs_leading),
540            whitespace_tabs_inner: Some(cfg.whitespace_tabs_inner),
541            whitespace_tabs_trailing: Some(cfg.whitespace_tabs_trailing),
542        }
543    }
544}
545
546impl PartialEditorConfig {
547    /// Resolve this partial config to a concrete EditorConfig using defaults.
548    pub fn resolve(self, defaults: &crate::config::EditorConfig) -> crate::config::EditorConfig {
549        crate::config::EditorConfig {
550            tab_size: self.tab_size.unwrap_or(defaults.tab_size),
551            auto_indent: self.auto_indent.unwrap_or(defaults.auto_indent),
552            auto_close: self.auto_close.unwrap_or(defaults.auto_close),
553            auto_surround: self.auto_surround.unwrap_or(defaults.auto_surround),
554            line_numbers: self.line_numbers.unwrap_or(defaults.line_numbers),
555            relative_line_numbers: self
556                .relative_line_numbers
557                .unwrap_or(defaults.relative_line_numbers),
558            scroll_offset: self.scroll_offset.unwrap_or(defaults.scroll_offset),
559            syntax_highlighting: self
560                .syntax_highlighting
561                .unwrap_or(defaults.syntax_highlighting),
562            line_wrap: self.line_wrap.unwrap_or(defaults.line_wrap),
563            wrap_indent: self.wrap_indent.unwrap_or(defaults.wrap_indent),
564            highlight_timeout_ms: self
565                .highlight_timeout_ms
566                .unwrap_or(defaults.highlight_timeout_ms),
567            snapshot_interval: self.snapshot_interval.unwrap_or(defaults.snapshot_interval),
568            large_file_threshold_bytes: self
569                .large_file_threshold_bytes
570                .unwrap_or(defaults.large_file_threshold_bytes),
571            estimated_line_length: self
572                .estimated_line_length
573                .unwrap_or(defaults.estimated_line_length),
574            enable_inlay_hints: self
575                .enable_inlay_hints
576                .unwrap_or(defaults.enable_inlay_hints),
577            enable_semantic_tokens_full: self
578                .enable_semantic_tokens_full
579                .unwrap_or(defaults.enable_semantic_tokens_full),
580            diagnostics_inline_text: self
581                .diagnostics_inline_text
582                .unwrap_or(defaults.diagnostics_inline_text),
583            recovery_enabled: self.recovery_enabled.unwrap_or(defaults.recovery_enabled),
584            auto_recovery_save_interval_secs: self
585                .auto_recovery_save_interval_secs
586                .unwrap_or(defaults.auto_recovery_save_interval_secs),
587            auto_save_enabled: self.auto_save_enabled.unwrap_or(defaults.auto_save_enabled),
588            auto_save_interval_secs: self
589                .auto_save_interval_secs
590                .unwrap_or(defaults.auto_save_interval_secs),
591            highlight_context_bytes: self
592                .highlight_context_bytes
593                .unwrap_or(defaults.highlight_context_bytes),
594            mouse_hover_enabled: self
595                .mouse_hover_enabled
596                .unwrap_or(defaults.mouse_hover_enabled),
597            mouse_hover_delay_ms: self
598                .mouse_hover_delay_ms
599                .unwrap_or(defaults.mouse_hover_delay_ms),
600            double_click_time_ms: self
601                .double_click_time_ms
602                .unwrap_or(defaults.double_click_time_ms),
603            auto_revert_poll_interval_ms: self
604                .auto_revert_poll_interval_ms
605                .unwrap_or(defaults.auto_revert_poll_interval_ms),
606            read_concurrency: self.read_concurrency.unwrap_or(defaults.read_concurrency),
607            file_tree_poll_interval_ms: self
608                .file_tree_poll_interval_ms
609                .unwrap_or(defaults.file_tree_poll_interval_ms),
610            default_line_ending: self
611                .default_line_ending
612                .unwrap_or(defaults.default_line_ending.clone()),
613            trim_trailing_whitespace_on_save: self
614                .trim_trailing_whitespace_on_save
615                .unwrap_or(defaults.trim_trailing_whitespace_on_save),
616            ensure_final_newline_on_save: self
617                .ensure_final_newline_on_save
618                .unwrap_or(defaults.ensure_final_newline_on_save),
619            highlight_matching_brackets: self
620                .highlight_matching_brackets
621                .unwrap_or(defaults.highlight_matching_brackets),
622            rainbow_brackets: self.rainbow_brackets.unwrap_or(defaults.rainbow_brackets),
623            cursor_style: self.cursor_style.unwrap_or(defaults.cursor_style),
624            keyboard_disambiguate_escape_codes: self
625                .keyboard_disambiguate_escape_codes
626                .unwrap_or(defaults.keyboard_disambiguate_escape_codes),
627            keyboard_report_event_types: self
628                .keyboard_report_event_types
629                .unwrap_or(defaults.keyboard_report_event_types),
630            keyboard_report_alternate_keys: self
631                .keyboard_report_alternate_keys
632                .unwrap_or(defaults.keyboard_report_alternate_keys),
633            keyboard_report_all_keys_as_escape_codes: self
634                .keyboard_report_all_keys_as_escape_codes
635                .unwrap_or(defaults.keyboard_report_all_keys_as_escape_codes),
636            quick_suggestions: self.quick_suggestions.unwrap_or(defaults.quick_suggestions),
637            quick_suggestions_delay_ms: self
638                .quick_suggestions_delay_ms
639                .unwrap_or(defaults.quick_suggestions_delay_ms),
640            suggest_on_trigger_characters: self
641                .suggest_on_trigger_characters
642                .unwrap_or(defaults.suggest_on_trigger_characters),
643            accept_suggestion_on_enter: self
644                .accept_suggestion_on_enter
645                .unwrap_or(defaults.accept_suggestion_on_enter),
646            show_menu_bar: self.show_menu_bar.unwrap_or(defaults.show_menu_bar),
647            show_tab_bar: self.show_tab_bar.unwrap_or(defaults.show_tab_bar),
648            show_status_bar: self.show_status_bar.unwrap_or(defaults.show_status_bar),
649            show_vertical_scrollbar: self
650                .show_vertical_scrollbar
651                .unwrap_or(defaults.show_vertical_scrollbar),
652            show_horizontal_scrollbar: self
653                .show_horizontal_scrollbar
654                .unwrap_or(defaults.show_horizontal_scrollbar),
655            use_terminal_bg: self.use_terminal_bg.unwrap_or(defaults.use_terminal_bg),
656            rulers: self.rulers.unwrap_or_else(|| defaults.rulers.clone()),
657            whitespace_show: self.whitespace_show.unwrap_or(defaults.whitespace_show),
658            whitespace_spaces_leading: self
659                .whitespace_spaces_leading
660                .unwrap_or(defaults.whitespace_spaces_leading),
661            whitespace_spaces_inner: self
662                .whitespace_spaces_inner
663                .unwrap_or(defaults.whitespace_spaces_inner),
664            whitespace_spaces_trailing: self
665                .whitespace_spaces_trailing
666                .unwrap_or(defaults.whitespace_spaces_trailing),
667            whitespace_tabs_leading: self
668                .whitespace_tabs_leading
669                .unwrap_or(defaults.whitespace_tabs_leading),
670            whitespace_tabs_inner: self
671                .whitespace_tabs_inner
672                .unwrap_or(defaults.whitespace_tabs_inner),
673            whitespace_tabs_trailing: self
674                .whitespace_tabs_trailing
675                .unwrap_or(defaults.whitespace_tabs_trailing),
676        }
677    }
678}
679
680impl From<&FileExplorerConfig> for PartialFileExplorerConfig {
681    fn from(cfg: &FileExplorerConfig) -> Self {
682        Self {
683            respect_gitignore: Some(cfg.respect_gitignore),
684            show_hidden: Some(cfg.show_hidden),
685            show_gitignored: Some(cfg.show_gitignored),
686            custom_ignore_patterns: Some(cfg.custom_ignore_patterns.clone()),
687            width: Some(cfg.width),
688        }
689    }
690}
691
692impl PartialFileExplorerConfig {
693    pub fn resolve(self, defaults: &FileExplorerConfig) -> FileExplorerConfig {
694        FileExplorerConfig {
695            respect_gitignore: self.respect_gitignore.unwrap_or(defaults.respect_gitignore),
696            show_hidden: self.show_hidden.unwrap_or(defaults.show_hidden),
697            show_gitignored: self.show_gitignored.unwrap_or(defaults.show_gitignored),
698            custom_ignore_patterns: self
699                .custom_ignore_patterns
700                .unwrap_or_else(|| defaults.custom_ignore_patterns.clone()),
701            width: self.width.unwrap_or(defaults.width),
702        }
703    }
704}
705
706impl From<&FileBrowserConfig> for PartialFileBrowserConfig {
707    fn from(cfg: &FileBrowserConfig) -> Self {
708        Self {
709            show_hidden: Some(cfg.show_hidden),
710        }
711    }
712}
713
714impl PartialFileBrowserConfig {
715    pub fn resolve(self, defaults: &FileBrowserConfig) -> FileBrowserConfig {
716        FileBrowserConfig {
717            show_hidden: self.show_hidden.unwrap_or(defaults.show_hidden),
718        }
719    }
720}
721
722impl From<&ClipboardConfig> for PartialClipboardConfig {
723    fn from(cfg: &ClipboardConfig) -> Self {
724        Self {
725            use_osc52: Some(cfg.use_osc52),
726            use_system_clipboard: Some(cfg.use_system_clipboard),
727        }
728    }
729}
730
731impl PartialClipboardConfig {
732    pub fn resolve(self, defaults: &ClipboardConfig) -> ClipboardConfig {
733        ClipboardConfig {
734            use_osc52: self.use_osc52.unwrap_or(defaults.use_osc52),
735            use_system_clipboard: self
736                .use_system_clipboard
737                .unwrap_or(defaults.use_system_clipboard),
738        }
739    }
740}
741
742impl From<&TerminalConfig> for PartialTerminalConfig {
743    fn from(cfg: &TerminalConfig) -> Self {
744        Self {
745            jump_to_end_on_output: Some(cfg.jump_to_end_on_output),
746        }
747    }
748}
749
750impl PartialTerminalConfig {
751    pub fn resolve(self, defaults: &TerminalConfig) -> TerminalConfig {
752        TerminalConfig {
753            jump_to_end_on_output: self
754                .jump_to_end_on_output
755                .unwrap_or(defaults.jump_to_end_on_output),
756        }
757    }
758}
759
760impl From<&WarningsConfig> for PartialWarningsConfig {
761    fn from(cfg: &WarningsConfig) -> Self {
762        Self {
763            show_status_indicator: Some(cfg.show_status_indicator),
764        }
765    }
766}
767
768impl PartialWarningsConfig {
769    pub fn resolve(self, defaults: &WarningsConfig) -> WarningsConfig {
770        WarningsConfig {
771            show_status_indicator: self
772                .show_status_indicator
773                .unwrap_or(defaults.show_status_indicator),
774        }
775    }
776}
777
778impl From<&crate::config::PackagesConfig> for PartialPackagesConfig {
779    fn from(cfg: &crate::config::PackagesConfig) -> Self {
780        Self {
781            sources: Some(cfg.sources.clone()),
782        }
783    }
784}
785
786impl PartialPackagesConfig {
787    pub fn resolve(
788        self,
789        defaults: &crate::config::PackagesConfig,
790    ) -> crate::config::PackagesConfig {
791        crate::config::PackagesConfig {
792            sources: self.sources.unwrap_or_else(|| defaults.sources.clone()),
793        }
794    }
795}
796
797impl From<&PluginConfig> for PartialPluginConfig {
798    fn from(cfg: &PluginConfig) -> Self {
799        Self {
800            enabled: Some(cfg.enabled),
801            path: cfg.path.clone(),
802        }
803    }
804}
805
806impl PartialPluginConfig {
807    pub fn resolve(self, defaults: &PluginConfig) -> PluginConfig {
808        PluginConfig {
809            enabled: self.enabled.unwrap_or(defaults.enabled),
810            path: self.path.or_else(|| defaults.path.clone()),
811        }
812    }
813}
814
815impl From<&LanguageConfig> for PartialLanguageConfig {
816    fn from(cfg: &LanguageConfig) -> Self {
817        Self {
818            extensions: Some(cfg.extensions.clone()),
819            filenames: Some(cfg.filenames.clone()),
820            grammar: Some(cfg.grammar.clone()),
821            comment_prefix: cfg.comment_prefix.clone(),
822            auto_indent: Some(cfg.auto_indent),
823            auto_close: cfg.auto_close,
824            auto_surround: cfg.auto_surround,
825            highlighter: Some(cfg.highlighter),
826            textmate_grammar: cfg.textmate_grammar.clone(),
827            show_whitespace_tabs: Some(cfg.show_whitespace_tabs),
828            use_tabs: Some(cfg.use_tabs),
829            tab_size: cfg.tab_size,
830            formatter: cfg.formatter.clone(),
831            format_on_save: Some(cfg.format_on_save),
832            on_save: Some(cfg.on_save.clone()),
833        }
834    }
835}
836
837impl PartialLanguageConfig {
838    pub fn resolve(self, defaults: &LanguageConfig) -> LanguageConfig {
839        LanguageConfig {
840            extensions: self
841                .extensions
842                .unwrap_or_else(|| defaults.extensions.clone()),
843            filenames: self.filenames.unwrap_or_else(|| defaults.filenames.clone()),
844            grammar: self.grammar.unwrap_or_else(|| defaults.grammar.clone()),
845            comment_prefix: self
846                .comment_prefix
847                .or_else(|| defaults.comment_prefix.clone()),
848            auto_indent: self.auto_indent.unwrap_or(defaults.auto_indent),
849            auto_close: self.auto_close.or(defaults.auto_close),
850            auto_surround: self.auto_surround.or(defaults.auto_surround),
851            highlighter: self.highlighter.unwrap_or(defaults.highlighter),
852            textmate_grammar: self
853                .textmate_grammar
854                .or_else(|| defaults.textmate_grammar.clone()),
855            show_whitespace_tabs: self
856                .show_whitespace_tabs
857                .unwrap_or(defaults.show_whitespace_tabs),
858            use_tabs: self.use_tabs.unwrap_or(defaults.use_tabs),
859            tab_size: self.tab_size.or(defaults.tab_size),
860            formatter: self.formatter.or_else(|| defaults.formatter.clone()),
861            format_on_save: self.format_on_save.unwrap_or(defaults.format_on_save),
862            on_save: self.on_save.unwrap_or_else(|| defaults.on_save.clone()),
863        }
864    }
865}
866
867impl From<&crate::config::Config> for PartialConfig {
868    fn from(cfg: &crate::config::Config) -> Self {
869        Self {
870            version: Some(cfg.version),
871            theme: Some(cfg.theme.clone()),
872            locale: cfg.locale.0.clone(),
873            check_for_updates: Some(cfg.check_for_updates),
874            editor: Some(PartialEditorConfig::from(&cfg.editor)),
875            file_explorer: Some(PartialFileExplorerConfig::from(&cfg.file_explorer)),
876            file_browser: Some(PartialFileBrowserConfig::from(&cfg.file_browser)),
877            clipboard: Some(PartialClipboardConfig::from(&cfg.clipboard)),
878            terminal: Some(PartialTerminalConfig::from(&cfg.terminal)),
879            keybindings: Some(cfg.keybindings.clone()),
880            keybinding_maps: Some(cfg.keybinding_maps.clone()),
881            active_keybinding_map: Some(cfg.active_keybinding_map.clone()),
882            languages: Some(
883                cfg.languages
884                    .iter()
885                    .map(|(k, v)| (k.clone(), PartialLanguageConfig::from(v)))
886                    .collect(),
887            ),
888            lsp: Some(cfg.lsp.clone()),
889            warnings: Some(PartialWarningsConfig::from(&cfg.warnings)),
890            // Only include plugins that differ from defaults
891            // Path is auto-discovered at runtime and should never be saved
892            plugins: {
893                let default_plugin = crate::config::PluginConfig::default();
894                let non_default_plugins: HashMap<String, PartialPluginConfig> = cfg
895                    .plugins
896                    .iter()
897                    .filter(|(_, v)| v.enabled != default_plugin.enabled)
898                    .map(|(k, v)| {
899                        (
900                            k.clone(),
901                            PartialPluginConfig {
902                                enabled: Some(v.enabled),
903                                path: None, // Don't save path - it's auto-discovered
904                            },
905                        )
906                    })
907                    .collect();
908                if non_default_plugins.is_empty() {
909                    None
910                } else {
911                    Some(non_default_plugins)
912                }
913            },
914            packages: Some(PartialPackagesConfig::from(&cfg.packages)),
915        }
916    }
917}
918
919impl PartialConfig {
920    /// Resolve this partial config to a concrete Config using system defaults.
921    pub fn resolve(self) -> crate::config::Config {
922        let defaults = crate::config::Config::default();
923        self.resolve_with_defaults(&defaults)
924    }
925
926    /// Resolve this partial config to a concrete Config using provided defaults.
927    pub fn resolve_with_defaults(self, defaults: &crate::config::Config) -> crate::config::Config {
928        // Resolve languages HashMap - merge with defaults
929        let languages = {
930            let mut result = defaults.languages.clone();
931            if let Some(partial_langs) = self.languages {
932                for (key, partial_lang) in partial_langs {
933                    let default_lang = result.get(&key).cloned().unwrap_or_default();
934                    result.insert(key, partial_lang.resolve(&default_lang));
935                }
936            }
937            result
938        };
939
940        // Resolve lsp HashMap - merge with defaults
941        let lsp = {
942            let mut result = defaults.lsp.clone();
943            if let Some(partial_lsp) = self.lsp {
944                for (key, partial_config) in partial_lsp {
945                    if let Some(default_config) = result.get(&key) {
946                        result.insert(key, partial_config.merge_with_defaults(default_config));
947                    } else {
948                        // New language not in defaults - use as-is
949                        result.insert(key, partial_config);
950                    }
951                }
952            }
953            result
954        };
955
956        // Resolve keybinding_maps HashMap - merge with defaults
957        let keybinding_maps = {
958            let mut result = defaults.keybinding_maps.clone();
959            if let Some(partial_maps) = self.keybinding_maps {
960                for (key, config) in partial_maps {
961                    result.insert(key, config);
962                }
963            }
964            result
965        };
966
967        // Resolve plugins HashMap - merge with defaults
968        let plugins = {
969            let mut result = defaults.plugins.clone();
970            if let Some(partial_plugins) = self.plugins {
971                for (key, partial_plugin) in partial_plugins {
972                    let default_plugin = result.get(&key).cloned().unwrap_or_default();
973                    result.insert(key, partial_plugin.resolve(&default_plugin));
974                }
975            }
976            result
977        };
978
979        crate::config::Config {
980            version: self.version.unwrap_or(defaults.version),
981            theme: self.theme.unwrap_or_else(|| defaults.theme.clone()),
982            locale: crate::config::LocaleName::from(
983                self.locale.or_else(|| defaults.locale.0.clone()),
984            ),
985            check_for_updates: self.check_for_updates.unwrap_or(defaults.check_for_updates),
986            editor: self
987                .editor
988                .map(|e| e.resolve(&defaults.editor))
989                .unwrap_or_else(|| defaults.editor.clone()),
990            file_explorer: self
991                .file_explorer
992                .map(|e| e.resolve(&defaults.file_explorer))
993                .unwrap_or_else(|| defaults.file_explorer.clone()),
994            file_browser: self
995                .file_browser
996                .map(|e| e.resolve(&defaults.file_browser))
997                .unwrap_or_else(|| defaults.file_browser.clone()),
998            clipboard: self
999                .clipboard
1000                .map(|e| e.resolve(&defaults.clipboard))
1001                .unwrap_or_else(|| defaults.clipboard.clone()),
1002            terminal: self
1003                .terminal
1004                .map(|e| e.resolve(&defaults.terminal))
1005                .unwrap_or_else(|| defaults.terminal.clone()),
1006            keybindings: self
1007                .keybindings
1008                .unwrap_or_else(|| defaults.keybindings.clone()),
1009            keybinding_maps,
1010            active_keybinding_map: self
1011                .active_keybinding_map
1012                .unwrap_or_else(|| defaults.active_keybinding_map.clone()),
1013            languages,
1014            lsp,
1015            warnings: self
1016                .warnings
1017                .map(|e| e.resolve(&defaults.warnings))
1018                .unwrap_or_else(|| defaults.warnings.clone()),
1019            plugins,
1020            packages: self
1021                .packages
1022                .map(|e| e.resolve(&defaults.packages))
1023                .unwrap_or_else(|| defaults.packages.clone()),
1024        }
1025    }
1026}
1027
1028// Default implementation for LanguageConfig to support merge_hashmap_recursive
1029impl Default for LanguageConfig {
1030    fn default() -> Self {
1031        Self {
1032            extensions: Vec::new(),
1033            filenames: Vec::new(),
1034            grammar: String::new(),
1035            comment_prefix: None,
1036            auto_indent: true,
1037            auto_close: None,
1038            auto_surround: None,
1039            highlighter: HighlighterPreference::default(),
1040            textmate_grammar: None,
1041            show_whitespace_tabs: true,
1042            use_tabs: false,
1043            tab_size: None,
1044            formatter: None,
1045            format_on_save: false,
1046            on_save: Vec::new(),
1047        }
1048    }
1049}
1050
1051/// Session-specific configuration for runtime/volatile overrides.
1052///
1053/// This struct represents the session layer of the config hierarchy - settings
1054/// that are temporary and may not persist across editor restarts.
1055///
1056/// Unlike PartialConfig, SessionConfig provides a focused API for common
1057/// runtime modifications like temporary theme switching.
1058#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1059#[serde(default)]
1060pub struct SessionConfig {
1061    /// Temporarily override the theme (e.g., for preview)
1062    pub theme: Option<ThemeName>,
1063
1064    /// Temporary editor overrides (e.g., changing tab_size for current session)
1065    pub editor: Option<PartialEditorConfig>,
1066
1067    /// Buffer-specific overrides keyed by absolute file path.
1068    /// These allow per-file settings that persist only during the session.
1069    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1070    pub buffer_overrides: HashMap<std::path::PathBuf, PartialEditorConfig>,
1071}
1072
1073impl SessionConfig {
1074    /// Create a new empty session config.
1075    pub fn new() -> Self {
1076        Self::default()
1077    }
1078
1079    /// Set a temporary theme override.
1080    pub fn set_theme(&mut self, theme: ThemeName) {
1081        self.theme = Some(theme);
1082    }
1083
1084    /// Clear the theme override, reverting to lower layers.
1085    pub fn clear_theme(&mut self) {
1086        self.theme = None;
1087    }
1088
1089    /// Set an editor setting for the current session.
1090    pub fn set_editor_option<F>(&mut self, setter: F)
1091    where
1092        F: FnOnce(&mut PartialEditorConfig),
1093    {
1094        let editor = self.editor.get_or_insert_with(Default::default);
1095        setter(editor);
1096    }
1097
1098    /// Set a buffer-specific editor override.
1099    pub fn set_buffer_override(&mut self, path: std::path::PathBuf, config: PartialEditorConfig) {
1100        self.buffer_overrides.insert(path, config);
1101    }
1102
1103    /// Clear buffer-specific overrides for a path.
1104    pub fn clear_buffer_override(&mut self, path: &std::path::Path) {
1105        self.buffer_overrides.remove(path);
1106    }
1107
1108    /// Get buffer-specific editor config if set.
1109    pub fn get_buffer_override(&self, path: &std::path::Path) -> Option<&PartialEditorConfig> {
1110        self.buffer_overrides.get(path)
1111    }
1112
1113    /// Convert to a PartialConfig for merging with other layers.
1114    pub fn to_partial_config(&self) -> PartialConfig {
1115        PartialConfig {
1116            theme: self.theme.clone(),
1117            editor: self.editor.clone(),
1118            ..Default::default()
1119        }
1120    }
1121
1122    /// Check if this session config has any values set.
1123    pub fn is_empty(&self) -> bool {
1124        self.theme.is_none() && self.editor.is_none() && self.buffer_overrides.is_empty()
1125    }
1126}
1127
1128impl From<PartialConfig> for SessionConfig {
1129    fn from(partial: PartialConfig) -> Self {
1130        Self {
1131            theme: partial.theme,
1132            editor: partial.editor,
1133            buffer_overrides: HashMap::new(),
1134        }
1135    }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141
1142    #[test]
1143    fn merge_option_higher_precedence_wins() {
1144        let mut higher: Option<i32> = Some(10);
1145        let lower: Option<i32> = Some(5);
1146        higher.merge_from(&lower);
1147        assert_eq!(higher, Some(10));
1148    }
1149
1150    #[test]
1151    fn merge_option_fills_from_lower_when_none() {
1152        let mut higher: Option<i32> = None;
1153        let lower: Option<i32> = Some(5);
1154        higher.merge_from(&lower);
1155        assert_eq!(higher, Some(5));
1156    }
1157
1158    #[test]
1159    fn merge_editor_config_recursive() {
1160        let mut higher = PartialEditorConfig {
1161            tab_size: Some(2),
1162            ..Default::default()
1163        };
1164        let lower = PartialEditorConfig {
1165            tab_size: Some(4),
1166            line_numbers: Some(true),
1167            ..Default::default()
1168        };
1169
1170        higher.merge_from(&lower);
1171
1172        assert_eq!(higher.tab_size, Some(2)); // Higher wins
1173        assert_eq!(higher.line_numbers, Some(true)); // Filled from lower
1174    }
1175
1176    #[test]
1177    fn merge_partial_config_combines_languages() {
1178        let mut higher = PartialConfig {
1179            languages: Some(HashMap::from([(
1180                "rust".to_string(),
1181                PartialLanguageConfig {
1182                    tab_size: Some(4),
1183                    ..Default::default()
1184                },
1185            )])),
1186            ..Default::default()
1187        };
1188        let lower = PartialConfig {
1189            languages: Some(HashMap::from([(
1190                "python".to_string(),
1191                PartialLanguageConfig {
1192                    tab_size: Some(4),
1193                    ..Default::default()
1194                },
1195            )])),
1196            ..Default::default()
1197        };
1198
1199        higher.merge_from(&lower);
1200
1201        let langs = higher.languages.unwrap();
1202        assert!(langs.contains_key("rust"));
1203        assert!(langs.contains_key("python"));
1204    }
1205
1206    #[test]
1207    fn merge_languages_same_key_higher_wins() {
1208        let mut higher = PartialConfig {
1209            languages: Some(HashMap::from([(
1210                "rust".to_string(),
1211                PartialLanguageConfig {
1212                    tab_size: Some(2),
1213                    use_tabs: Some(true),
1214                    ..Default::default()
1215                },
1216            )])),
1217            ..Default::default()
1218        };
1219        let lower = PartialConfig {
1220            languages: Some(HashMap::from([(
1221                "rust".to_string(),
1222                PartialLanguageConfig {
1223                    tab_size: Some(4),
1224                    auto_indent: Some(false),
1225                    ..Default::default()
1226                },
1227            )])),
1228            ..Default::default()
1229        };
1230
1231        higher.merge_from(&lower);
1232
1233        let langs = higher.languages.unwrap();
1234        let rust = langs.get("rust").unwrap();
1235        assert_eq!(rust.tab_size, Some(2)); // Higher wins
1236        assert_eq!(rust.use_tabs, Some(true)); // From higher
1237        assert_eq!(rust.auto_indent, Some(false)); // Filled from lower
1238    }
1239
1240    #[test]
1241    fn resolve_fills_defaults() {
1242        let partial = PartialConfig {
1243            theme: Some(ThemeName::from("dark")),
1244            ..Default::default()
1245        };
1246
1247        let resolved = partial.resolve();
1248
1249        assert_eq!(resolved.theme.0, "dark");
1250        assert_eq!(resolved.editor.tab_size, 4); // Default
1251        assert!(resolved.editor.line_numbers); // Default true
1252    }
1253
1254    #[test]
1255    fn resolve_preserves_set_values() {
1256        let partial = PartialConfig {
1257            editor: Some(PartialEditorConfig {
1258                tab_size: Some(2),
1259                line_numbers: Some(false),
1260                ..Default::default()
1261            }),
1262            ..Default::default()
1263        };
1264
1265        let resolved = partial.resolve();
1266
1267        assert_eq!(resolved.editor.tab_size, 2);
1268        assert!(!resolved.editor.line_numbers);
1269    }
1270
1271    #[test]
1272    fn roundtrip_config_to_partial_and_back() {
1273        let original = crate::config::Config::default();
1274        let partial = PartialConfig::from(&original);
1275        let resolved = partial.resolve();
1276
1277        assert_eq!(original.theme, resolved.theme);
1278        assert_eq!(original.editor.tab_size, resolved.editor.tab_size);
1279        assert_eq!(original.check_for_updates, resolved.check_for_updates);
1280    }
1281
1282    #[test]
1283    fn session_config_new_is_empty() {
1284        let session = SessionConfig::new();
1285        assert!(session.is_empty());
1286    }
1287
1288    #[test]
1289    fn session_config_set_theme() {
1290        let mut session = SessionConfig::new();
1291        session.set_theme(ThemeName::from("dark"));
1292        assert_eq!(session.theme, Some(ThemeName::from("dark")));
1293        assert!(!session.is_empty());
1294    }
1295
1296    #[test]
1297    fn session_config_clear_theme() {
1298        let mut session = SessionConfig::new();
1299        session.set_theme(ThemeName::from("dark"));
1300        session.clear_theme();
1301        assert!(session.theme.is_none());
1302    }
1303
1304    #[test]
1305    fn session_config_set_editor_option() {
1306        let mut session = SessionConfig::new();
1307        session.set_editor_option(|e| e.tab_size = Some(2));
1308        assert_eq!(session.editor.as_ref().unwrap().tab_size, Some(2));
1309    }
1310
1311    #[test]
1312    fn session_config_buffer_overrides() {
1313        let mut session = SessionConfig::new();
1314        let path = std::path::PathBuf::from("/test/file.rs");
1315        let config = PartialEditorConfig {
1316            tab_size: Some(8),
1317            ..Default::default()
1318        };
1319
1320        session.set_buffer_override(path.clone(), config);
1321        assert!(session.get_buffer_override(&path).is_some());
1322        assert_eq!(
1323            session.get_buffer_override(&path).unwrap().tab_size,
1324            Some(8)
1325        );
1326
1327        session.clear_buffer_override(&path);
1328        assert!(session.get_buffer_override(&path).is_none());
1329    }
1330
1331    #[test]
1332    fn session_config_to_partial_config() {
1333        let mut session = SessionConfig::new();
1334        session.set_theme(ThemeName::from("dark"));
1335        session.set_editor_option(|e| e.tab_size = Some(2));
1336
1337        let partial = session.to_partial_config();
1338        assert_eq!(partial.theme, Some(ThemeName::from("dark")));
1339        assert_eq!(partial.editor.as_ref().unwrap().tab_size, Some(2));
1340    }
1341
1342    // ============= Plugin Config Delta Saving Tests =============
1343
1344    #[test]
1345    fn plugins_with_default_enabled_not_serialized() {
1346        // When all plugins have enabled=true (the default), plugins should be None
1347        let mut config = crate::config::Config::default();
1348        config.plugins.insert(
1349            "test_plugin".to_string(),
1350            PluginConfig {
1351                enabled: true, // Default value
1352                path: Some(std::path::PathBuf::from("/path/to/plugin.ts")),
1353            },
1354        );
1355
1356        let partial = PartialConfig::from(&config);
1357
1358        // plugins should be None since all have default values
1359        assert!(
1360            partial.plugins.is_none(),
1361            "Plugins with default enabled=true should not be serialized"
1362        );
1363    }
1364
1365    #[test]
1366    fn plugins_with_disabled_are_serialized() {
1367        // When a plugin is disabled, it should be included in the partial config
1368        let mut config = crate::config::Config::default();
1369        config.plugins.insert(
1370            "enabled_plugin".to_string(),
1371            PluginConfig {
1372                enabled: true,
1373                path: Some(std::path::PathBuf::from("/path/to/enabled.ts")),
1374            },
1375        );
1376        config.plugins.insert(
1377            "disabled_plugin".to_string(),
1378            PluginConfig {
1379                enabled: false, // Not default!
1380                path: Some(std::path::PathBuf::from("/path/to/disabled.ts")),
1381            },
1382        );
1383
1384        let partial = PartialConfig::from(&config);
1385
1386        // plugins should contain only the disabled plugin
1387        assert!(partial.plugins.is_some());
1388        let plugins = partial.plugins.unwrap();
1389        assert_eq!(
1390            plugins.len(),
1391            1,
1392            "Only disabled plugins should be serialized"
1393        );
1394        assert!(plugins.contains_key("disabled_plugin"));
1395        assert!(!plugins.contains_key("enabled_plugin"));
1396
1397        // Check the disabled plugin has correct values
1398        let disabled = plugins.get("disabled_plugin").unwrap();
1399        assert_eq!(disabled.enabled, Some(false));
1400        // Path should be None - it's auto-discovered and shouldn't be saved
1401        assert!(disabled.path.is_none(), "Path should not be serialized");
1402    }
1403
1404    #[test]
1405    fn plugin_path_never_serialized() {
1406        // Even for disabled plugins, path should never be serialized
1407        let mut config = crate::config::Config::default();
1408        config.plugins.insert(
1409            "my_plugin".to_string(),
1410            PluginConfig {
1411                enabled: false,
1412                path: Some(std::path::PathBuf::from("/some/path/plugin.ts")),
1413            },
1414        );
1415
1416        let partial = PartialConfig::from(&config);
1417        let plugins = partial.plugins.unwrap();
1418        let plugin = plugins.get("my_plugin").unwrap();
1419
1420        assert!(
1421            plugin.path.is_none(),
1422            "Path is runtime-discovered and should never be serialized"
1423        );
1424    }
1425
1426    #[test]
1427    fn resolving_partial_with_disabled_plugin_preserves_state() {
1428        // Loading a config with a disabled plugin should preserve disabled state
1429        let partial = PartialConfig {
1430            plugins: Some(HashMap::from([(
1431                "my_plugin".to_string(),
1432                PartialPluginConfig {
1433                    enabled: Some(false),
1434                    path: None,
1435                },
1436            )])),
1437            ..Default::default()
1438        };
1439
1440        let resolved = partial.resolve();
1441
1442        // Plugin should exist and be disabled
1443        let plugin = resolved.plugins.get("my_plugin");
1444        assert!(
1445            plugin.is_some(),
1446            "Disabled plugin should be in resolved config"
1447        );
1448        assert!(
1449            !plugin.unwrap().enabled,
1450            "Plugin should remain disabled after resolve"
1451        );
1452    }
1453
1454    #[test]
1455    fn merge_plugins_preserves_higher_precedence_disabled_state() {
1456        // When merging, higher precedence disabled state should win
1457        let mut higher = PartialConfig {
1458            plugins: Some(HashMap::from([(
1459                "my_plugin".to_string(),
1460                PartialPluginConfig {
1461                    enabled: Some(false), // User disabled
1462                    path: None,
1463                },
1464            )])),
1465            ..Default::default()
1466        };
1467
1468        let lower = PartialConfig {
1469            plugins: Some(HashMap::from([(
1470                "my_plugin".to_string(),
1471                PartialPluginConfig {
1472                    enabled: Some(true), // Lower layer has it enabled
1473                    path: None,
1474                },
1475            )])),
1476            ..Default::default()
1477        };
1478
1479        higher.merge_from(&lower);
1480
1481        let plugins = higher.plugins.unwrap();
1482        let plugin = plugins.get("my_plugin").unwrap();
1483        assert_eq!(
1484            plugin.enabled,
1485            Some(false),
1486            "Higher precedence disabled state should win"
1487        );
1488    }
1489
1490    #[test]
1491    fn roundtrip_disabled_plugin_only_saves_delta() {
1492        // Roundtrip test: create config with mix of enabled/disabled plugins,
1493        // convert to partial, serialize to JSON, deserialize, and verify
1494        let mut config = crate::config::Config::default();
1495        config.plugins.insert(
1496            "plugin_a".to_string(),
1497            PluginConfig {
1498                enabled: true,
1499                path: Some(std::path::PathBuf::from("/a.ts")),
1500            },
1501        );
1502        config.plugins.insert(
1503            "plugin_b".to_string(),
1504            PluginConfig {
1505                enabled: false,
1506                path: Some(std::path::PathBuf::from("/b.ts")),
1507            },
1508        );
1509        config.plugins.insert(
1510            "plugin_c".to_string(),
1511            PluginConfig {
1512                enabled: true,
1513                path: Some(std::path::PathBuf::from("/c.ts")),
1514            },
1515        );
1516
1517        // Convert to partial (delta)
1518        let partial = PartialConfig::from(&config);
1519
1520        // Serialize to JSON
1521        let json = serde_json::to_string(&partial).unwrap();
1522
1523        // Verify only plugin_b is in the JSON
1524        assert!(
1525            json.contains("plugin_b"),
1526            "Disabled plugin should be in serialized JSON"
1527        );
1528        assert!(
1529            !json.contains("plugin_a"),
1530            "Enabled plugin_a should not be in serialized JSON"
1531        );
1532        assert!(
1533            !json.contains("plugin_c"),
1534            "Enabled plugin_c should not be in serialized JSON"
1535        );
1536
1537        // Deserialize back
1538        let deserialized: PartialConfig = serde_json::from_str(&json).unwrap();
1539
1540        // Verify plugins section only contains the disabled one
1541        let plugins = deserialized.plugins.unwrap();
1542        assert_eq!(plugins.len(), 1);
1543        assert!(plugins.contains_key("plugin_b"));
1544        assert_eq!(plugins.get("plugin_b").unwrap().enabled, Some(false));
1545    }
1546}