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