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