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