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