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