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