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