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