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    AcceptSuggestionOnEnter, CursorStyle, FileBrowserConfig, FileExplorerConfig, FormatterConfig,
8    HighlighterPreference, Keybinding, KeybindingMapName, KeymapConfig, LanguageConfig,
9    LineEndingOption, OnSaveAction, PluginConfig, TerminalConfig, ThemeName, WarningsConfig,
10};
11use crate::types::LspServerConfig;
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 terminal: Option<PartialTerminalConfig>,
84    pub keybindings: Option<Vec<Keybinding>>,
85    pub keybinding_maps: Option<HashMap<String, KeymapConfig>>,
86    pub active_keybinding_map: Option<KeybindingMapName>,
87    pub languages: Option<HashMap<String, PartialLanguageConfig>>,
88    pub lsp: Option<HashMap<String, LspServerConfig>>,
89    pub warnings: Option<PartialWarningsConfig>,
90    pub plugins: Option<HashMap<String, PartialPluginConfig>>,
91    pub packages: Option<PartialPackagesConfig>,
92}
93
94impl Merge for PartialConfig {
95    fn merge_from(&mut self, other: &Self) {
96        self.version.merge_from(&other.version);
97        self.theme.merge_from(&other.theme);
98        self.locale.merge_from(&other.locale);
99        self.check_for_updates.merge_from(&other.check_for_updates);
100
101        // Nested structs: merge recursively
102        merge_partial(&mut self.editor, &other.editor);
103        merge_partial(&mut self.file_explorer, &other.file_explorer);
104        merge_partial(&mut self.file_browser, &other.file_browser);
105        merge_partial(&mut self.terminal, &other.terminal);
106        merge_partial(&mut self.warnings, &other.warnings);
107        merge_partial(&mut self.packages, &other.packages);
108
109        // Lists: higher precedence replaces (per design doc)
110        self.keybindings.merge_from(&other.keybindings);
111
112        // HashMaps: merge entries, higher precedence wins on key collision
113        merge_hashmap(&mut self.keybinding_maps, &other.keybinding_maps);
114        merge_hashmap_recursive(&mut self.languages, &other.languages);
115        merge_hashmap_recursive(&mut self.lsp, &other.lsp);
116        merge_hashmap_recursive(&mut self.plugins, &other.plugins);
117
118        self.active_keybinding_map
119            .merge_from(&other.active_keybinding_map);
120    }
121}
122
123/// Helper to merge nested partial structs.
124fn merge_partial<T: Merge + Clone>(target: &mut Option<T>, other: &Option<T>) {
125    match (target, other) {
126        (Some(t), Some(o)) => t.merge_from(o),
127        (t @ None, Some(o)) => *t = Some(o.clone()),
128        _ => {}
129    }
130}
131
132/// Partial editor configuration.
133#[derive(Debug, Clone, Default, Deserialize, Serialize)]
134#[serde(default)]
135pub struct PartialEditorConfig {
136    pub tab_size: Option<usize>,
137    pub auto_indent: Option<bool>,
138    pub line_numbers: Option<bool>,
139    pub relative_line_numbers: Option<bool>,
140    pub scroll_offset: Option<usize>,
141    pub syntax_highlighting: Option<bool>,
142    pub line_wrap: Option<bool>,
143    pub highlight_timeout_ms: Option<u64>,
144    pub snapshot_interval: Option<usize>,
145    pub large_file_threshold_bytes: Option<u64>,
146    pub estimated_line_length: Option<usize>,
147    pub enable_inlay_hints: Option<bool>,
148    pub enable_semantic_tokens_full: Option<bool>,
149    pub recovery_enabled: Option<bool>,
150    pub auto_save_interval_secs: Option<u32>,
151    pub highlight_context_bytes: Option<usize>,
152    pub mouse_hover_enabled: Option<bool>,
153    pub mouse_hover_delay_ms: Option<u64>,
154    pub double_click_time_ms: Option<u64>,
155    pub auto_revert_poll_interval_ms: Option<u64>,
156    pub file_tree_poll_interval_ms: Option<u64>,
157    pub default_line_ending: Option<LineEndingOption>,
158    pub trim_trailing_whitespace_on_save: Option<bool>,
159    pub ensure_final_newline_on_save: Option<bool>,
160    pub highlight_matching_brackets: Option<bool>,
161    pub rainbow_brackets: Option<bool>,
162    pub cursor_style: Option<CursorStyle>,
163    pub keyboard_disambiguate_escape_codes: Option<bool>,
164    pub keyboard_report_event_types: Option<bool>,
165    pub keyboard_report_alternate_keys: Option<bool>,
166    pub keyboard_report_all_keys_as_escape_codes: Option<bool>,
167    pub quick_suggestions: Option<bool>,
168    pub quick_suggestions_delay_ms: Option<u64>,
169    pub suggest_on_trigger_characters: Option<bool>,
170    pub accept_suggestion_on_enter: Option<AcceptSuggestionOnEnter>,
171    pub show_menu_bar: Option<bool>,
172    pub show_tab_bar: Option<bool>,
173    pub use_terminal_bg: Option<bool>,
174}
175
176impl Merge for PartialEditorConfig {
177    fn merge_from(&mut self, other: &Self) {
178        self.tab_size.merge_from(&other.tab_size);
179        self.auto_indent.merge_from(&other.auto_indent);
180        self.line_numbers.merge_from(&other.line_numbers);
181        self.relative_line_numbers
182            .merge_from(&other.relative_line_numbers);
183        self.scroll_offset.merge_from(&other.scroll_offset);
184        self.syntax_highlighting
185            .merge_from(&other.syntax_highlighting);
186        self.line_wrap.merge_from(&other.line_wrap);
187        self.highlight_timeout_ms
188            .merge_from(&other.highlight_timeout_ms);
189        self.snapshot_interval.merge_from(&other.snapshot_interval);
190        self.large_file_threshold_bytes
191            .merge_from(&other.large_file_threshold_bytes);
192        self.estimated_line_length
193            .merge_from(&other.estimated_line_length);
194        self.enable_inlay_hints
195            .merge_from(&other.enable_inlay_hints);
196        self.enable_semantic_tokens_full
197            .merge_from(&other.enable_semantic_tokens_full);
198        self.recovery_enabled.merge_from(&other.recovery_enabled);
199        self.auto_save_interval_secs
200            .merge_from(&other.auto_save_interval_secs);
201        self.highlight_context_bytes
202            .merge_from(&other.highlight_context_bytes);
203        self.mouse_hover_enabled
204            .merge_from(&other.mouse_hover_enabled);
205        self.mouse_hover_delay_ms
206            .merge_from(&other.mouse_hover_delay_ms);
207        self.double_click_time_ms
208            .merge_from(&other.double_click_time_ms);
209        self.auto_revert_poll_interval_ms
210            .merge_from(&other.auto_revert_poll_interval_ms);
211        self.file_tree_poll_interval_ms
212            .merge_from(&other.file_tree_poll_interval_ms);
213        self.default_line_ending
214            .merge_from(&other.default_line_ending);
215        self.trim_trailing_whitespace_on_save
216            .merge_from(&other.trim_trailing_whitespace_on_save);
217        self.ensure_final_newline_on_save
218            .merge_from(&other.ensure_final_newline_on_save);
219        self.highlight_matching_brackets
220            .merge_from(&other.highlight_matching_brackets);
221        self.rainbow_brackets.merge_from(&other.rainbow_brackets);
222        self.cursor_style.merge_from(&other.cursor_style);
223        self.keyboard_disambiguate_escape_codes
224            .merge_from(&other.keyboard_disambiguate_escape_codes);
225        self.keyboard_report_event_types
226            .merge_from(&other.keyboard_report_event_types);
227        self.keyboard_report_alternate_keys
228            .merge_from(&other.keyboard_report_alternate_keys);
229        self.keyboard_report_all_keys_as_escape_codes
230            .merge_from(&other.keyboard_report_all_keys_as_escape_codes);
231        self.quick_suggestions.merge_from(&other.quick_suggestions);
232        self.quick_suggestions_delay_ms
233            .merge_from(&other.quick_suggestions_delay_ms);
234        self.suggest_on_trigger_characters
235            .merge_from(&other.suggest_on_trigger_characters);
236        self.accept_suggestion_on_enter
237            .merge_from(&other.accept_suggestion_on_enter);
238        self.show_menu_bar.merge_from(&other.show_menu_bar);
239        self.show_tab_bar.merge_from(&other.show_tab_bar);
240        self.use_terminal_bg.merge_from(&other.use_terminal_bg);
241    }
242}
243
244/// Partial file explorer configuration.
245#[derive(Debug, Clone, Default, Deserialize, Serialize)]
246#[serde(default)]
247pub struct PartialFileExplorerConfig {
248    pub respect_gitignore: Option<bool>,
249    pub show_hidden: Option<bool>,
250    pub show_gitignored: Option<bool>,
251    pub custom_ignore_patterns: Option<Vec<String>>,
252    pub width: Option<f32>,
253}
254
255impl Merge for PartialFileExplorerConfig {
256    fn merge_from(&mut self, other: &Self) {
257        self.respect_gitignore.merge_from(&other.respect_gitignore);
258        self.show_hidden.merge_from(&other.show_hidden);
259        self.show_gitignored.merge_from(&other.show_gitignored);
260        self.custom_ignore_patterns
261            .merge_from(&other.custom_ignore_patterns);
262        self.width.merge_from(&other.width);
263    }
264}
265
266/// Partial file browser configuration.
267#[derive(Debug, Clone, Default, Deserialize, Serialize)]
268#[serde(default)]
269pub struct PartialFileBrowserConfig {
270    pub show_hidden: Option<bool>,
271}
272
273impl Merge for PartialFileBrowserConfig {
274    fn merge_from(&mut self, other: &Self) {
275        self.show_hidden.merge_from(&other.show_hidden);
276    }
277}
278
279/// Partial terminal configuration.
280#[derive(Debug, Clone, Default, Deserialize, Serialize)]
281#[serde(default)]
282pub struct PartialTerminalConfig {
283    pub jump_to_end_on_output: Option<bool>,
284}
285
286impl Merge for PartialTerminalConfig {
287    fn merge_from(&mut self, other: &Self) {
288        self.jump_to_end_on_output
289            .merge_from(&other.jump_to_end_on_output);
290    }
291}
292
293/// Partial warnings configuration.
294#[derive(Debug, Clone, Default, Deserialize, Serialize)]
295#[serde(default)]
296pub struct PartialWarningsConfig {
297    pub show_status_indicator: Option<bool>,
298}
299
300impl Merge for PartialWarningsConfig {
301    fn merge_from(&mut self, other: &Self) {
302        self.show_status_indicator
303            .merge_from(&other.show_status_indicator);
304    }
305}
306
307/// Partial packages configuration for plugin/theme package management.
308#[derive(Debug, Clone, Default, Deserialize, Serialize)]
309#[serde(default)]
310pub struct PartialPackagesConfig {
311    pub sources: Option<Vec<String>>,
312}
313
314impl Merge for PartialPackagesConfig {
315    fn merge_from(&mut self, other: &Self) {
316        self.sources.merge_from(&other.sources);
317    }
318}
319
320/// Partial plugin configuration.
321#[derive(Debug, Clone, Default, Deserialize, Serialize)]
322#[serde(default)]
323pub struct PartialPluginConfig {
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub enabled: Option<bool>,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub path: Option<std::path::PathBuf>,
328}
329
330impl Merge for PartialPluginConfig {
331    fn merge_from(&mut self, other: &Self) {
332        self.enabled.merge_from(&other.enabled);
333        self.path.merge_from(&other.path);
334    }
335}
336
337/// Partial language configuration.
338#[derive(Debug, Clone, Default, Deserialize, Serialize)]
339#[serde(default)]
340pub struct PartialLanguageConfig {
341    pub extensions: Option<Vec<String>>,
342    pub filenames: Option<Vec<String>>,
343    pub grammar: Option<String>,
344    pub comment_prefix: Option<String>,
345    pub auto_indent: Option<bool>,
346    pub highlighter: Option<HighlighterPreference>,
347    pub textmate_grammar: Option<std::path::PathBuf>,
348    pub show_whitespace_tabs: Option<bool>,
349    pub use_tabs: Option<bool>,
350    pub tab_size: Option<usize>,
351    pub formatter: Option<FormatterConfig>,
352    pub format_on_save: Option<bool>,
353    pub on_save: Option<Vec<OnSaveAction>>,
354}
355
356impl Merge for PartialLanguageConfig {
357    fn merge_from(&mut self, other: &Self) {
358        self.extensions.merge_from(&other.extensions);
359        self.filenames.merge_from(&other.filenames);
360        self.grammar.merge_from(&other.grammar);
361        self.comment_prefix.merge_from(&other.comment_prefix);
362        self.auto_indent.merge_from(&other.auto_indent);
363        self.highlighter.merge_from(&other.highlighter);
364        self.textmate_grammar.merge_from(&other.textmate_grammar);
365        self.show_whitespace_tabs
366            .merge_from(&other.show_whitespace_tabs);
367        self.use_tabs.merge_from(&other.use_tabs);
368        self.tab_size.merge_from(&other.tab_size);
369        self.formatter.merge_from(&other.formatter);
370        self.format_on_save.merge_from(&other.format_on_save);
371        self.on_save.merge_from(&other.on_save);
372    }
373}
374
375impl Merge for LspServerConfig {
376    fn merge_from(&mut self, other: &Self) {
377        // If command is empty (serde default), use other's command
378        if self.command.is_empty() {
379            self.command = other.command.clone();
380        }
381        // If args is empty, use other's args
382        if self.args.is_empty() {
383            self.args = other.args.clone();
384        }
385        // For booleans, keep self's value (we can't tell if explicitly set)
386        // For process_limits, keep self's value
387        // For initialization_options, use self if Some, otherwise other
388        if self.initialization_options.is_none() {
389            self.initialization_options = other.initialization_options.clone();
390        }
391    }
392}
393
394// Conversion traits for resolving partial configs to concrete configs
395
396impl From<&crate::config::EditorConfig> for PartialEditorConfig {
397    fn from(cfg: &crate::config::EditorConfig) -> Self {
398        Self {
399            tab_size: Some(cfg.tab_size),
400            auto_indent: Some(cfg.auto_indent),
401            line_numbers: Some(cfg.line_numbers),
402            relative_line_numbers: Some(cfg.relative_line_numbers),
403            scroll_offset: Some(cfg.scroll_offset),
404            syntax_highlighting: Some(cfg.syntax_highlighting),
405            line_wrap: Some(cfg.line_wrap),
406            highlight_timeout_ms: Some(cfg.highlight_timeout_ms),
407            snapshot_interval: Some(cfg.snapshot_interval),
408            large_file_threshold_bytes: Some(cfg.large_file_threshold_bytes),
409            estimated_line_length: Some(cfg.estimated_line_length),
410            enable_inlay_hints: Some(cfg.enable_inlay_hints),
411            enable_semantic_tokens_full: Some(cfg.enable_semantic_tokens_full),
412            recovery_enabled: Some(cfg.recovery_enabled),
413            auto_save_interval_secs: Some(cfg.auto_save_interval_secs),
414            highlight_context_bytes: Some(cfg.highlight_context_bytes),
415            mouse_hover_enabled: Some(cfg.mouse_hover_enabled),
416            mouse_hover_delay_ms: Some(cfg.mouse_hover_delay_ms),
417            double_click_time_ms: Some(cfg.double_click_time_ms),
418            auto_revert_poll_interval_ms: Some(cfg.auto_revert_poll_interval_ms),
419            file_tree_poll_interval_ms: Some(cfg.file_tree_poll_interval_ms),
420            default_line_ending: Some(cfg.default_line_ending.clone()),
421            trim_trailing_whitespace_on_save: Some(cfg.trim_trailing_whitespace_on_save),
422            ensure_final_newline_on_save: Some(cfg.ensure_final_newline_on_save),
423            highlight_matching_brackets: Some(cfg.highlight_matching_brackets),
424            rainbow_brackets: Some(cfg.rainbow_brackets),
425            cursor_style: Some(cfg.cursor_style),
426            keyboard_disambiguate_escape_codes: Some(cfg.keyboard_disambiguate_escape_codes),
427            keyboard_report_event_types: Some(cfg.keyboard_report_event_types),
428            keyboard_report_alternate_keys: Some(cfg.keyboard_report_alternate_keys),
429            keyboard_report_all_keys_as_escape_codes: Some(
430                cfg.keyboard_report_all_keys_as_escape_codes,
431            ),
432            quick_suggestions: Some(cfg.quick_suggestions),
433            quick_suggestions_delay_ms: Some(cfg.quick_suggestions_delay_ms),
434            suggest_on_trigger_characters: Some(cfg.suggest_on_trigger_characters),
435            accept_suggestion_on_enter: Some(cfg.accept_suggestion_on_enter),
436            show_menu_bar: Some(cfg.show_menu_bar),
437            show_tab_bar: Some(cfg.show_tab_bar),
438            use_terminal_bg: Some(cfg.use_terminal_bg),
439        }
440    }
441}
442
443impl PartialEditorConfig {
444    /// Resolve this partial config to a concrete EditorConfig using defaults.
445    pub fn resolve(self, defaults: &crate::config::EditorConfig) -> crate::config::EditorConfig {
446        crate::config::EditorConfig {
447            tab_size: self.tab_size.unwrap_or(defaults.tab_size),
448            auto_indent: self.auto_indent.unwrap_or(defaults.auto_indent),
449            line_numbers: self.line_numbers.unwrap_or(defaults.line_numbers),
450            relative_line_numbers: self
451                .relative_line_numbers
452                .unwrap_or(defaults.relative_line_numbers),
453            scroll_offset: self.scroll_offset.unwrap_or(defaults.scroll_offset),
454            syntax_highlighting: self
455                .syntax_highlighting
456                .unwrap_or(defaults.syntax_highlighting),
457            line_wrap: self.line_wrap.unwrap_or(defaults.line_wrap),
458            highlight_timeout_ms: self
459                .highlight_timeout_ms
460                .unwrap_or(defaults.highlight_timeout_ms),
461            snapshot_interval: self.snapshot_interval.unwrap_or(defaults.snapshot_interval),
462            large_file_threshold_bytes: self
463                .large_file_threshold_bytes
464                .unwrap_or(defaults.large_file_threshold_bytes),
465            estimated_line_length: self
466                .estimated_line_length
467                .unwrap_or(defaults.estimated_line_length),
468            enable_inlay_hints: self
469                .enable_inlay_hints
470                .unwrap_or(defaults.enable_inlay_hints),
471            enable_semantic_tokens_full: self
472                .enable_semantic_tokens_full
473                .unwrap_or(defaults.enable_semantic_tokens_full),
474            recovery_enabled: self.recovery_enabled.unwrap_or(defaults.recovery_enabled),
475            auto_save_interval_secs: self
476                .auto_save_interval_secs
477                .unwrap_or(defaults.auto_save_interval_secs),
478            highlight_context_bytes: self
479                .highlight_context_bytes
480                .unwrap_or(defaults.highlight_context_bytes),
481            mouse_hover_enabled: self
482                .mouse_hover_enabled
483                .unwrap_or(defaults.mouse_hover_enabled),
484            mouse_hover_delay_ms: self
485                .mouse_hover_delay_ms
486                .unwrap_or(defaults.mouse_hover_delay_ms),
487            double_click_time_ms: self
488                .double_click_time_ms
489                .unwrap_or(defaults.double_click_time_ms),
490            auto_revert_poll_interval_ms: self
491                .auto_revert_poll_interval_ms
492                .unwrap_or(defaults.auto_revert_poll_interval_ms),
493            file_tree_poll_interval_ms: self
494                .file_tree_poll_interval_ms
495                .unwrap_or(defaults.file_tree_poll_interval_ms),
496            default_line_ending: self
497                .default_line_ending
498                .unwrap_or(defaults.default_line_ending.clone()),
499            trim_trailing_whitespace_on_save: self
500                .trim_trailing_whitespace_on_save
501                .unwrap_or(defaults.trim_trailing_whitespace_on_save),
502            ensure_final_newline_on_save: self
503                .ensure_final_newline_on_save
504                .unwrap_or(defaults.ensure_final_newline_on_save),
505            highlight_matching_brackets: self
506                .highlight_matching_brackets
507                .unwrap_or(defaults.highlight_matching_brackets),
508            rainbow_brackets: self.rainbow_brackets.unwrap_or(defaults.rainbow_brackets),
509            cursor_style: self.cursor_style.unwrap_or(defaults.cursor_style),
510            keyboard_disambiguate_escape_codes: self
511                .keyboard_disambiguate_escape_codes
512                .unwrap_or(defaults.keyboard_disambiguate_escape_codes),
513            keyboard_report_event_types: self
514                .keyboard_report_event_types
515                .unwrap_or(defaults.keyboard_report_event_types),
516            keyboard_report_alternate_keys: self
517                .keyboard_report_alternate_keys
518                .unwrap_or(defaults.keyboard_report_alternate_keys),
519            keyboard_report_all_keys_as_escape_codes: self
520                .keyboard_report_all_keys_as_escape_codes
521                .unwrap_or(defaults.keyboard_report_all_keys_as_escape_codes),
522            quick_suggestions: self.quick_suggestions.unwrap_or(defaults.quick_suggestions),
523            quick_suggestions_delay_ms: self
524                .quick_suggestions_delay_ms
525                .unwrap_or(defaults.quick_suggestions_delay_ms),
526            suggest_on_trigger_characters: self
527                .suggest_on_trigger_characters
528                .unwrap_or(defaults.suggest_on_trigger_characters),
529            accept_suggestion_on_enter: self
530                .accept_suggestion_on_enter
531                .unwrap_or(defaults.accept_suggestion_on_enter),
532            show_menu_bar: self.show_menu_bar.unwrap_or(defaults.show_menu_bar),
533            show_tab_bar: self.show_tab_bar.unwrap_or(defaults.show_tab_bar),
534            use_terminal_bg: self.use_terminal_bg.unwrap_or(defaults.use_terminal_bg),
535        }
536    }
537}
538
539impl From<&FileExplorerConfig> for PartialFileExplorerConfig {
540    fn from(cfg: &FileExplorerConfig) -> Self {
541        Self {
542            respect_gitignore: Some(cfg.respect_gitignore),
543            show_hidden: Some(cfg.show_hidden),
544            show_gitignored: Some(cfg.show_gitignored),
545            custom_ignore_patterns: Some(cfg.custom_ignore_patterns.clone()),
546            width: Some(cfg.width),
547        }
548    }
549}
550
551impl PartialFileExplorerConfig {
552    pub fn resolve(self, defaults: &FileExplorerConfig) -> FileExplorerConfig {
553        FileExplorerConfig {
554            respect_gitignore: self.respect_gitignore.unwrap_or(defaults.respect_gitignore),
555            show_hidden: self.show_hidden.unwrap_or(defaults.show_hidden),
556            show_gitignored: self.show_gitignored.unwrap_or(defaults.show_gitignored),
557            custom_ignore_patterns: self
558                .custom_ignore_patterns
559                .unwrap_or_else(|| defaults.custom_ignore_patterns.clone()),
560            width: self.width.unwrap_or(defaults.width),
561        }
562    }
563}
564
565impl From<&FileBrowserConfig> for PartialFileBrowserConfig {
566    fn from(cfg: &FileBrowserConfig) -> Self {
567        Self {
568            show_hidden: Some(cfg.show_hidden),
569        }
570    }
571}
572
573impl PartialFileBrowserConfig {
574    pub fn resolve(self, defaults: &FileBrowserConfig) -> FileBrowserConfig {
575        FileBrowserConfig {
576            show_hidden: self.show_hidden.unwrap_or(defaults.show_hidden),
577        }
578    }
579}
580
581impl From<&TerminalConfig> for PartialTerminalConfig {
582    fn from(cfg: &TerminalConfig) -> Self {
583        Self {
584            jump_to_end_on_output: Some(cfg.jump_to_end_on_output),
585        }
586    }
587}
588
589impl PartialTerminalConfig {
590    pub fn resolve(self, defaults: &TerminalConfig) -> TerminalConfig {
591        TerminalConfig {
592            jump_to_end_on_output: self
593                .jump_to_end_on_output
594                .unwrap_or(defaults.jump_to_end_on_output),
595        }
596    }
597}
598
599impl From<&WarningsConfig> for PartialWarningsConfig {
600    fn from(cfg: &WarningsConfig) -> Self {
601        Self {
602            show_status_indicator: Some(cfg.show_status_indicator),
603        }
604    }
605}
606
607impl PartialWarningsConfig {
608    pub fn resolve(self, defaults: &WarningsConfig) -> WarningsConfig {
609        WarningsConfig {
610            show_status_indicator: self
611                .show_status_indicator
612                .unwrap_or(defaults.show_status_indicator),
613        }
614    }
615}
616
617impl From<&crate::config::PackagesConfig> for PartialPackagesConfig {
618    fn from(cfg: &crate::config::PackagesConfig) -> Self {
619        Self {
620            sources: Some(cfg.sources.clone()),
621        }
622    }
623}
624
625impl PartialPackagesConfig {
626    pub fn resolve(
627        self,
628        defaults: &crate::config::PackagesConfig,
629    ) -> crate::config::PackagesConfig {
630        crate::config::PackagesConfig {
631            sources: self.sources.unwrap_or_else(|| defaults.sources.clone()),
632        }
633    }
634}
635
636impl From<&PluginConfig> for PartialPluginConfig {
637    fn from(cfg: &PluginConfig) -> Self {
638        Self {
639            enabled: Some(cfg.enabled),
640            path: cfg.path.clone(),
641        }
642    }
643}
644
645impl PartialPluginConfig {
646    pub fn resolve(self, defaults: &PluginConfig) -> PluginConfig {
647        PluginConfig {
648            enabled: self.enabled.unwrap_or(defaults.enabled),
649            path: self.path.or_else(|| defaults.path.clone()),
650        }
651    }
652}
653
654impl From<&LanguageConfig> for PartialLanguageConfig {
655    fn from(cfg: &LanguageConfig) -> Self {
656        Self {
657            extensions: Some(cfg.extensions.clone()),
658            filenames: Some(cfg.filenames.clone()),
659            grammar: Some(cfg.grammar.clone()),
660            comment_prefix: cfg.comment_prefix.clone(),
661            auto_indent: Some(cfg.auto_indent),
662            highlighter: Some(cfg.highlighter),
663            textmate_grammar: cfg.textmate_grammar.clone(),
664            show_whitespace_tabs: Some(cfg.show_whitespace_tabs),
665            use_tabs: Some(cfg.use_tabs),
666            tab_size: cfg.tab_size,
667            formatter: cfg.formatter.clone(),
668            format_on_save: Some(cfg.format_on_save),
669            on_save: Some(cfg.on_save.clone()),
670        }
671    }
672}
673
674impl PartialLanguageConfig {
675    pub fn resolve(self, defaults: &LanguageConfig) -> LanguageConfig {
676        LanguageConfig {
677            extensions: self
678                .extensions
679                .unwrap_or_else(|| defaults.extensions.clone()),
680            filenames: self.filenames.unwrap_or_else(|| defaults.filenames.clone()),
681            grammar: self.grammar.unwrap_or_else(|| defaults.grammar.clone()),
682            comment_prefix: self
683                .comment_prefix
684                .or_else(|| defaults.comment_prefix.clone()),
685            auto_indent: self.auto_indent.unwrap_or(defaults.auto_indent),
686            highlighter: self.highlighter.unwrap_or(defaults.highlighter),
687            textmate_grammar: self
688                .textmate_grammar
689                .or_else(|| defaults.textmate_grammar.clone()),
690            show_whitespace_tabs: self
691                .show_whitespace_tabs
692                .unwrap_or(defaults.show_whitespace_tabs),
693            use_tabs: self.use_tabs.unwrap_or(defaults.use_tabs),
694            tab_size: self.tab_size.or(defaults.tab_size),
695            formatter: self.formatter.or_else(|| defaults.formatter.clone()),
696            format_on_save: self.format_on_save.unwrap_or(defaults.format_on_save),
697            on_save: self.on_save.unwrap_or_else(|| defaults.on_save.clone()),
698        }
699    }
700}
701
702impl From<&crate::config::Config> for PartialConfig {
703    fn from(cfg: &crate::config::Config) -> Self {
704        Self {
705            version: Some(cfg.version),
706            theme: Some(cfg.theme.clone()),
707            locale: cfg.locale.0.clone(),
708            check_for_updates: Some(cfg.check_for_updates),
709            editor: Some(PartialEditorConfig::from(&cfg.editor)),
710            file_explorer: Some(PartialFileExplorerConfig::from(&cfg.file_explorer)),
711            file_browser: Some(PartialFileBrowserConfig::from(&cfg.file_browser)),
712            terminal: Some(PartialTerminalConfig::from(&cfg.terminal)),
713            keybindings: Some(cfg.keybindings.clone()),
714            keybinding_maps: Some(cfg.keybinding_maps.clone()),
715            active_keybinding_map: Some(cfg.active_keybinding_map.clone()),
716            languages: Some(
717                cfg.languages
718                    .iter()
719                    .map(|(k, v)| (k.clone(), PartialLanguageConfig::from(v)))
720                    .collect(),
721            ),
722            lsp: Some(cfg.lsp.clone()),
723            warnings: Some(PartialWarningsConfig::from(&cfg.warnings)),
724            // Only include plugins that differ from defaults
725            // Path is auto-discovered at runtime and should never be saved
726            plugins: {
727                let default_plugin = crate::config::PluginConfig::default();
728                let non_default_plugins: HashMap<String, PartialPluginConfig> = cfg
729                    .plugins
730                    .iter()
731                    .filter(|(_, v)| v.enabled != default_plugin.enabled)
732                    .map(|(k, v)| {
733                        (
734                            k.clone(),
735                            PartialPluginConfig {
736                                enabled: Some(v.enabled),
737                                path: None, // Don't save path - it's auto-discovered
738                            },
739                        )
740                    })
741                    .collect();
742                if non_default_plugins.is_empty() {
743                    None
744                } else {
745                    Some(non_default_plugins)
746                }
747            },
748            packages: Some(PartialPackagesConfig::from(&cfg.packages)),
749        }
750    }
751}
752
753impl PartialConfig {
754    /// Resolve this partial config to a concrete Config using system defaults.
755    pub fn resolve(self) -> crate::config::Config {
756        let defaults = crate::config::Config::default();
757        self.resolve_with_defaults(&defaults)
758    }
759
760    /// Resolve this partial config to a concrete Config using provided defaults.
761    pub fn resolve_with_defaults(self, defaults: &crate::config::Config) -> crate::config::Config {
762        // Resolve languages HashMap - merge with defaults
763        let languages = {
764            let mut result = defaults.languages.clone();
765            if let Some(partial_langs) = self.languages {
766                for (key, partial_lang) in partial_langs {
767                    let default_lang = result.get(&key).cloned().unwrap_or_default();
768                    result.insert(key, partial_lang.resolve(&default_lang));
769                }
770            }
771            result
772        };
773
774        // Resolve lsp HashMap - merge with defaults
775        let lsp = {
776            let mut result = defaults.lsp.clone();
777            if let Some(partial_lsp) = self.lsp {
778                for (key, partial_config) in partial_lsp {
779                    if let Some(default_config) = result.get(&key) {
780                        result.insert(key, partial_config.merge_with_defaults(default_config));
781                    } else {
782                        // New language not in defaults - use as-is
783                        result.insert(key, partial_config);
784                    }
785                }
786            }
787            result
788        };
789
790        // Resolve keybinding_maps HashMap - merge with defaults
791        let keybinding_maps = {
792            let mut result = defaults.keybinding_maps.clone();
793            if let Some(partial_maps) = self.keybinding_maps {
794                for (key, config) in partial_maps {
795                    result.insert(key, config);
796                }
797            }
798            result
799        };
800
801        // Resolve plugins HashMap - merge with defaults
802        let plugins = {
803            let mut result = defaults.plugins.clone();
804            if let Some(partial_plugins) = self.plugins {
805                for (key, partial_plugin) in partial_plugins {
806                    let default_plugin = result.get(&key).cloned().unwrap_or_default();
807                    result.insert(key, partial_plugin.resolve(&default_plugin));
808                }
809            }
810            result
811        };
812
813        crate::config::Config {
814            version: self.version.unwrap_or(defaults.version),
815            theme: self.theme.unwrap_or_else(|| defaults.theme.clone()),
816            locale: crate::config::LocaleName::from(
817                self.locale.or_else(|| defaults.locale.0.clone()),
818            ),
819            check_for_updates: self.check_for_updates.unwrap_or(defaults.check_for_updates),
820            editor: self
821                .editor
822                .map(|e| e.resolve(&defaults.editor))
823                .unwrap_or_else(|| defaults.editor.clone()),
824            file_explorer: self
825                .file_explorer
826                .map(|e| e.resolve(&defaults.file_explorer))
827                .unwrap_or_else(|| defaults.file_explorer.clone()),
828            file_browser: self
829                .file_browser
830                .map(|e| e.resolve(&defaults.file_browser))
831                .unwrap_or_else(|| defaults.file_browser.clone()),
832            terminal: self
833                .terminal
834                .map(|e| e.resolve(&defaults.terminal))
835                .unwrap_or_else(|| defaults.terminal.clone()),
836            keybindings: self
837                .keybindings
838                .unwrap_or_else(|| defaults.keybindings.clone()),
839            keybinding_maps,
840            active_keybinding_map: self
841                .active_keybinding_map
842                .unwrap_or_else(|| defaults.active_keybinding_map.clone()),
843            languages,
844            lsp,
845            warnings: self
846                .warnings
847                .map(|e| e.resolve(&defaults.warnings))
848                .unwrap_or_else(|| defaults.warnings.clone()),
849            plugins,
850            packages: self
851                .packages
852                .map(|e| e.resolve(&defaults.packages))
853                .unwrap_or_else(|| defaults.packages.clone()),
854        }
855    }
856}
857
858// Default implementation for LanguageConfig to support merge_hashmap_recursive
859impl Default for LanguageConfig {
860    fn default() -> Self {
861        Self {
862            extensions: Vec::new(),
863            filenames: Vec::new(),
864            grammar: String::new(),
865            comment_prefix: None,
866            auto_indent: true,
867            highlighter: HighlighterPreference::default(),
868            textmate_grammar: None,
869            show_whitespace_tabs: true,
870            use_tabs: false,
871            tab_size: None,
872            formatter: None,
873            format_on_save: false,
874            on_save: Vec::new(),
875        }
876    }
877}
878
879/// Session-specific configuration for runtime/volatile overrides.
880///
881/// This struct represents the session layer of the config hierarchy - settings
882/// that are temporary and may not persist across editor restarts.
883///
884/// Unlike PartialConfig, SessionConfig provides a focused API for common
885/// runtime modifications like temporary theme switching.
886#[derive(Debug, Clone, Default, Deserialize, Serialize)]
887#[serde(default)]
888pub struct SessionConfig {
889    /// Temporarily override the theme (e.g., for preview)
890    pub theme: Option<ThemeName>,
891
892    /// Temporary editor overrides (e.g., changing tab_size for current session)
893    pub editor: Option<PartialEditorConfig>,
894
895    /// Buffer-specific overrides keyed by absolute file path.
896    /// These allow per-file settings that persist only during the session.
897    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
898    pub buffer_overrides: HashMap<std::path::PathBuf, PartialEditorConfig>,
899}
900
901impl SessionConfig {
902    /// Create a new empty session config.
903    pub fn new() -> Self {
904        Self::default()
905    }
906
907    /// Set a temporary theme override.
908    pub fn set_theme(&mut self, theme: ThemeName) {
909        self.theme = Some(theme);
910    }
911
912    /// Clear the theme override, reverting to lower layers.
913    pub fn clear_theme(&mut self) {
914        self.theme = None;
915    }
916
917    /// Set an editor setting for the current session.
918    pub fn set_editor_option<F>(&mut self, setter: F)
919    where
920        F: FnOnce(&mut PartialEditorConfig),
921    {
922        let editor = self.editor.get_or_insert_with(Default::default);
923        setter(editor);
924    }
925
926    /// Set a buffer-specific editor override.
927    pub fn set_buffer_override(&mut self, path: std::path::PathBuf, config: PartialEditorConfig) {
928        self.buffer_overrides.insert(path, config);
929    }
930
931    /// Clear buffer-specific overrides for a path.
932    pub fn clear_buffer_override(&mut self, path: &std::path::Path) {
933        self.buffer_overrides.remove(path);
934    }
935
936    /// Get buffer-specific editor config if set.
937    pub fn get_buffer_override(&self, path: &std::path::Path) -> Option<&PartialEditorConfig> {
938        self.buffer_overrides.get(path)
939    }
940
941    /// Convert to a PartialConfig for merging with other layers.
942    pub fn to_partial_config(&self) -> PartialConfig {
943        PartialConfig {
944            theme: self.theme.clone(),
945            editor: self.editor.clone(),
946            ..Default::default()
947        }
948    }
949
950    /// Check if this session config has any values set.
951    pub fn is_empty(&self) -> bool {
952        self.theme.is_none() && self.editor.is_none() && self.buffer_overrides.is_empty()
953    }
954}
955
956impl From<PartialConfig> for SessionConfig {
957    fn from(partial: PartialConfig) -> Self {
958        Self {
959            theme: partial.theme,
960            editor: partial.editor,
961            buffer_overrides: HashMap::new(),
962        }
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn merge_option_higher_precedence_wins() {
972        let mut higher: Option<i32> = Some(10);
973        let lower: Option<i32> = Some(5);
974        higher.merge_from(&lower);
975        assert_eq!(higher, Some(10));
976    }
977
978    #[test]
979    fn merge_option_fills_from_lower_when_none() {
980        let mut higher: Option<i32> = None;
981        let lower: Option<i32> = Some(5);
982        higher.merge_from(&lower);
983        assert_eq!(higher, Some(5));
984    }
985
986    #[test]
987    fn merge_editor_config_recursive() {
988        let mut higher = PartialEditorConfig {
989            tab_size: Some(2),
990            ..Default::default()
991        };
992        let lower = PartialEditorConfig {
993            tab_size: Some(4),
994            line_numbers: Some(true),
995            ..Default::default()
996        };
997
998        higher.merge_from(&lower);
999
1000        assert_eq!(higher.tab_size, Some(2)); // Higher wins
1001        assert_eq!(higher.line_numbers, Some(true)); // Filled from lower
1002    }
1003
1004    #[test]
1005    fn merge_partial_config_combines_languages() {
1006        let mut higher = PartialConfig {
1007            languages: Some(HashMap::from([(
1008                "rust".to_string(),
1009                PartialLanguageConfig {
1010                    tab_size: Some(4),
1011                    ..Default::default()
1012                },
1013            )])),
1014            ..Default::default()
1015        };
1016        let lower = PartialConfig {
1017            languages: Some(HashMap::from([(
1018                "python".to_string(),
1019                PartialLanguageConfig {
1020                    tab_size: Some(4),
1021                    ..Default::default()
1022                },
1023            )])),
1024            ..Default::default()
1025        };
1026
1027        higher.merge_from(&lower);
1028
1029        let langs = higher.languages.unwrap();
1030        assert!(langs.contains_key("rust"));
1031        assert!(langs.contains_key("python"));
1032    }
1033
1034    #[test]
1035    fn merge_languages_same_key_higher_wins() {
1036        let mut higher = PartialConfig {
1037            languages: Some(HashMap::from([(
1038                "rust".to_string(),
1039                PartialLanguageConfig {
1040                    tab_size: Some(2),
1041                    use_tabs: Some(true),
1042                    ..Default::default()
1043                },
1044            )])),
1045            ..Default::default()
1046        };
1047        let lower = PartialConfig {
1048            languages: Some(HashMap::from([(
1049                "rust".to_string(),
1050                PartialLanguageConfig {
1051                    tab_size: Some(4),
1052                    auto_indent: Some(false),
1053                    ..Default::default()
1054                },
1055            )])),
1056            ..Default::default()
1057        };
1058
1059        higher.merge_from(&lower);
1060
1061        let langs = higher.languages.unwrap();
1062        let rust = langs.get("rust").unwrap();
1063        assert_eq!(rust.tab_size, Some(2)); // Higher wins
1064        assert_eq!(rust.use_tabs, Some(true)); // From higher
1065        assert_eq!(rust.auto_indent, Some(false)); // Filled from lower
1066    }
1067
1068    #[test]
1069    fn resolve_fills_defaults() {
1070        let partial = PartialConfig {
1071            theme: Some(ThemeName::from("dark")),
1072            ..Default::default()
1073        };
1074
1075        let resolved = partial.resolve();
1076
1077        assert_eq!(resolved.theme.0, "dark");
1078        assert_eq!(resolved.editor.tab_size, 4); // Default
1079        assert!(resolved.editor.line_numbers); // Default true
1080    }
1081
1082    #[test]
1083    fn resolve_preserves_set_values() {
1084        let partial = PartialConfig {
1085            editor: Some(PartialEditorConfig {
1086                tab_size: Some(2),
1087                line_numbers: Some(false),
1088                ..Default::default()
1089            }),
1090            ..Default::default()
1091        };
1092
1093        let resolved = partial.resolve();
1094
1095        assert_eq!(resolved.editor.tab_size, 2);
1096        assert!(!resolved.editor.line_numbers);
1097    }
1098
1099    #[test]
1100    fn roundtrip_config_to_partial_and_back() {
1101        let original = crate::config::Config::default();
1102        let partial = PartialConfig::from(&original);
1103        let resolved = partial.resolve();
1104
1105        assert_eq!(original.theme, resolved.theme);
1106        assert_eq!(original.editor.tab_size, resolved.editor.tab_size);
1107        assert_eq!(original.check_for_updates, resolved.check_for_updates);
1108    }
1109
1110    #[test]
1111    fn session_config_new_is_empty() {
1112        let session = SessionConfig::new();
1113        assert!(session.is_empty());
1114    }
1115
1116    #[test]
1117    fn session_config_set_theme() {
1118        let mut session = SessionConfig::new();
1119        session.set_theme(ThemeName::from("dark"));
1120        assert_eq!(session.theme, Some(ThemeName::from("dark")));
1121        assert!(!session.is_empty());
1122    }
1123
1124    #[test]
1125    fn session_config_clear_theme() {
1126        let mut session = SessionConfig::new();
1127        session.set_theme(ThemeName::from("dark"));
1128        session.clear_theme();
1129        assert!(session.theme.is_none());
1130    }
1131
1132    #[test]
1133    fn session_config_set_editor_option() {
1134        let mut session = SessionConfig::new();
1135        session.set_editor_option(|e| e.tab_size = Some(2));
1136        assert_eq!(session.editor.as_ref().unwrap().tab_size, Some(2));
1137    }
1138
1139    #[test]
1140    fn session_config_buffer_overrides() {
1141        let mut session = SessionConfig::new();
1142        let path = std::path::PathBuf::from("/test/file.rs");
1143        let config = PartialEditorConfig {
1144            tab_size: Some(8),
1145            ..Default::default()
1146        };
1147
1148        session.set_buffer_override(path.clone(), config);
1149        assert!(session.get_buffer_override(&path).is_some());
1150        assert_eq!(
1151            session.get_buffer_override(&path).unwrap().tab_size,
1152            Some(8)
1153        );
1154
1155        session.clear_buffer_override(&path);
1156        assert!(session.get_buffer_override(&path).is_none());
1157    }
1158
1159    #[test]
1160    fn session_config_to_partial_config() {
1161        let mut session = SessionConfig::new();
1162        session.set_theme(ThemeName::from("dark"));
1163        session.set_editor_option(|e| e.tab_size = Some(2));
1164
1165        let partial = session.to_partial_config();
1166        assert_eq!(partial.theme, Some(ThemeName::from("dark")));
1167        assert_eq!(partial.editor.as_ref().unwrap().tab_size, Some(2));
1168    }
1169
1170    // ============= Plugin Config Delta Saving Tests =============
1171
1172    #[test]
1173    fn plugins_with_default_enabled_not_serialized() {
1174        // When all plugins have enabled=true (the default), plugins should be None
1175        let mut config = crate::config::Config::default();
1176        config.plugins.insert(
1177            "test_plugin".to_string(),
1178            PluginConfig {
1179                enabled: true, // Default value
1180                path: Some(std::path::PathBuf::from("/path/to/plugin.ts")),
1181            },
1182        );
1183
1184        let partial = PartialConfig::from(&config);
1185
1186        // plugins should be None since all have default values
1187        assert!(
1188            partial.plugins.is_none(),
1189            "Plugins with default enabled=true should not be serialized"
1190        );
1191    }
1192
1193    #[test]
1194    fn plugins_with_disabled_are_serialized() {
1195        // When a plugin is disabled, it should be included in the partial config
1196        let mut config = crate::config::Config::default();
1197        config.plugins.insert(
1198            "enabled_plugin".to_string(),
1199            PluginConfig {
1200                enabled: true,
1201                path: Some(std::path::PathBuf::from("/path/to/enabled.ts")),
1202            },
1203        );
1204        config.plugins.insert(
1205            "disabled_plugin".to_string(),
1206            PluginConfig {
1207                enabled: false, // Not default!
1208                path: Some(std::path::PathBuf::from("/path/to/disabled.ts")),
1209            },
1210        );
1211
1212        let partial = PartialConfig::from(&config);
1213
1214        // plugins should contain only the disabled plugin
1215        assert!(partial.plugins.is_some());
1216        let plugins = partial.plugins.unwrap();
1217        assert_eq!(
1218            plugins.len(),
1219            1,
1220            "Only disabled plugins should be serialized"
1221        );
1222        assert!(plugins.contains_key("disabled_plugin"));
1223        assert!(!plugins.contains_key("enabled_plugin"));
1224
1225        // Check the disabled plugin has correct values
1226        let disabled = plugins.get("disabled_plugin").unwrap();
1227        assert_eq!(disabled.enabled, Some(false));
1228        // Path should be None - it's auto-discovered and shouldn't be saved
1229        assert!(disabled.path.is_none(), "Path should not be serialized");
1230    }
1231
1232    #[test]
1233    fn plugin_path_never_serialized() {
1234        // Even for disabled plugins, path should never be serialized
1235        let mut config = crate::config::Config::default();
1236        config.plugins.insert(
1237            "my_plugin".to_string(),
1238            PluginConfig {
1239                enabled: false,
1240                path: Some(std::path::PathBuf::from("/some/path/plugin.ts")),
1241            },
1242        );
1243
1244        let partial = PartialConfig::from(&config);
1245        let plugins = partial.plugins.unwrap();
1246        let plugin = plugins.get("my_plugin").unwrap();
1247
1248        assert!(
1249            plugin.path.is_none(),
1250            "Path is runtime-discovered and should never be serialized"
1251        );
1252    }
1253
1254    #[test]
1255    fn resolving_partial_with_disabled_plugin_preserves_state() {
1256        // Loading a config with a disabled plugin should preserve disabled state
1257        let partial = PartialConfig {
1258            plugins: Some(HashMap::from([(
1259                "my_plugin".to_string(),
1260                PartialPluginConfig {
1261                    enabled: Some(false),
1262                    path: None,
1263                },
1264            )])),
1265            ..Default::default()
1266        };
1267
1268        let resolved = partial.resolve();
1269
1270        // Plugin should exist and be disabled
1271        let plugin = resolved.plugins.get("my_plugin");
1272        assert!(
1273            plugin.is_some(),
1274            "Disabled plugin should be in resolved config"
1275        );
1276        assert!(
1277            !plugin.unwrap().enabled,
1278            "Plugin should remain disabled after resolve"
1279        );
1280    }
1281
1282    #[test]
1283    fn merge_plugins_preserves_higher_precedence_disabled_state() {
1284        // When merging, higher precedence disabled state should win
1285        let mut higher = PartialConfig {
1286            plugins: Some(HashMap::from([(
1287                "my_plugin".to_string(),
1288                PartialPluginConfig {
1289                    enabled: Some(false), // User disabled
1290                    path: None,
1291                },
1292            )])),
1293            ..Default::default()
1294        };
1295
1296        let lower = PartialConfig {
1297            plugins: Some(HashMap::from([(
1298                "my_plugin".to_string(),
1299                PartialPluginConfig {
1300                    enabled: Some(true), // Lower layer has it enabled
1301                    path: None,
1302                },
1303            )])),
1304            ..Default::default()
1305        };
1306
1307        higher.merge_from(&lower);
1308
1309        let plugins = higher.plugins.unwrap();
1310        let plugin = plugins.get("my_plugin").unwrap();
1311        assert_eq!(
1312            plugin.enabled,
1313            Some(false),
1314            "Higher precedence disabled state should win"
1315        );
1316    }
1317
1318    #[test]
1319    fn roundtrip_disabled_plugin_only_saves_delta() {
1320        // Roundtrip test: create config with mix of enabled/disabled plugins,
1321        // convert to partial, serialize to JSON, deserialize, and verify
1322        let mut config = crate::config::Config::default();
1323        config.plugins.insert(
1324            "plugin_a".to_string(),
1325            PluginConfig {
1326                enabled: true,
1327                path: Some(std::path::PathBuf::from("/a.ts")),
1328            },
1329        );
1330        config.plugins.insert(
1331            "plugin_b".to_string(),
1332            PluginConfig {
1333                enabled: false,
1334                path: Some(std::path::PathBuf::from("/b.ts")),
1335            },
1336        );
1337        config.plugins.insert(
1338            "plugin_c".to_string(),
1339            PluginConfig {
1340                enabled: true,
1341                path: Some(std::path::PathBuf::from("/c.ts")),
1342            },
1343        );
1344
1345        // Convert to partial (delta)
1346        let partial = PartialConfig::from(&config);
1347
1348        // Serialize to JSON
1349        let json = serde_json::to_string(&partial).unwrap();
1350
1351        // Verify only plugin_b is in the JSON
1352        assert!(
1353            json.contains("plugin_b"),
1354            "Disabled plugin should be in serialized JSON"
1355        );
1356        assert!(
1357            !json.contains("plugin_a"),
1358            "Enabled plugin_a should not be in serialized JSON"
1359        );
1360        assert!(
1361            !json.contains("plugin_c"),
1362            "Enabled plugin_c should not be in serialized JSON"
1363        );
1364
1365        // Deserialize back
1366        let deserialized: PartialConfig = serde_json::from_str(&json).unwrap();
1367
1368        // Verify plugins section only contains the disabled one
1369        let plugins = deserialized.plugins.unwrap();
1370        assert_eq!(plugins.len(), 1);
1371        assert!(plugins.contains_key("plugin_b"));
1372        assert_eq!(plugins.get("plugin_b").unwrap().enabled, Some(false));
1373    }
1374}