Skip to main content

par_term_config/config/
prettifier.rs

1//! Configuration structures for the Content Prettifier system.
2//!
3//! Maps to the `content_prettifier:` section in `config.yaml`.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// Default value functions
10// ---------------------------------------------------------------------------
11
12fn default_true() -> bool {
13    true
14}
15
16fn default_global_toggle_key() -> String {
17    "Ctrl+Shift+P".to_string()
18}
19
20fn default_detection_scope() -> String {
21    "all".to_string()
22}
23
24fn default_confidence_threshold() -> f32 {
25    0.6
26}
27
28fn default_max_scan_lines() -> usize {
29    500
30}
31
32fn default_debounce_ms() -> u64 {
33    100
34}
35
36fn default_clipboard_copy() -> String {
37    "rendered".to_string()
38}
39
40fn default_priority() -> i32 {
41    50
42}
43
44fn default_cache_max_entries() -> usize {
45    64
46}
47
48// ---------------------------------------------------------------------------
49// Top-level prettifier config
50// ---------------------------------------------------------------------------
51
52/// Top-level prettifier configuration (lives under `content_prettifier:` in config.yaml).
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct PrettifierYamlConfig {
55    /// Whether to respect alternate-screen transitions as boundaries.
56    #[serde(default = "default_true")]
57    pub respect_alternate_screen: bool,
58
59    /// Key binding for the global prettifier toggle.
60    #[serde(default = "default_global_toggle_key")]
61    pub global_toggle_key: String,
62
63    /// Whether per-block source/rendered toggling is enabled.
64    #[serde(default = "default_true")]
65    pub per_block_toggle: bool,
66
67    /// Detection settings.
68    #[serde(default)]
69    pub detection: DetectionConfig,
70
71    /// Clipboard behavior settings.
72    #[serde(default)]
73    pub clipboard: ClipboardConfig,
74
75    /// Per-renderer enable/disable and priority.
76    #[serde(default)]
77    pub renderers: RenderersConfig,
78
79    /// User-defined custom renderer configurations.
80    #[serde(default)]
81    pub custom_renderers: Vec<CustomRendererConfig>,
82
83    /// Claude Code integration settings.
84    #[serde(default)]
85    pub claude_code_integration: ClaudeCodeConfig,
86
87    /// User-defined detection rule overrides, keyed by format ID.
88    #[serde(default)]
89    pub detection_rules: HashMap<String, FormatDetectionRulesConfig>,
90
91    /// Render cache settings.
92    #[serde(default)]
93    pub cache: CacheConfig,
94}
95
96impl Default for PrettifierYamlConfig {
97    fn default() -> Self {
98        Self {
99            respect_alternate_screen: true,
100            global_toggle_key: default_global_toggle_key(),
101            per_block_toggle: true,
102            detection: DetectionConfig::default(),
103            clipboard: ClipboardConfig::default(),
104            renderers: RenderersConfig::default(),
105            custom_renderers: Vec::new(),
106            claude_code_integration: ClaudeCodeConfig::default(),
107            detection_rules: HashMap::new(),
108            cache: CacheConfig::default(),
109        }
110    }
111}
112
113// ---------------------------------------------------------------------------
114// Sub-configs
115// ---------------------------------------------------------------------------
116
117/// Detection pipeline settings.
118#[derive(Clone, Debug, Serialize, Deserialize)]
119pub struct DetectionConfig {
120    /// When to detect: "command_output", "all", or "manual_only".
121    #[serde(default = "default_detection_scope")]
122    pub scope: String,
123
124    /// Minimum confidence score (0.0–1.0) for auto-detection.
125    #[serde(default = "default_confidence_threshold")]
126    pub confidence_threshold: f32,
127
128    /// Maximum lines to scan before forcing emission.
129    #[serde(default = "default_max_scan_lines")]
130    pub max_scan_lines: usize,
131
132    /// Milliseconds of inactivity before emitting a block.
133    #[serde(default = "default_debounce_ms")]
134    pub debounce_ms: u64,
135}
136
137impl Default for DetectionConfig {
138    fn default() -> Self {
139        Self {
140            scope: default_detection_scope(),
141            confidence_threshold: default_confidence_threshold(),
142            max_scan_lines: default_max_scan_lines(),
143            debounce_ms: default_debounce_ms(),
144        }
145    }
146}
147
148/// Clipboard behavior for prettified content.
149#[derive(Clone, Debug, Serialize, Deserialize)]
150pub struct ClipboardConfig {
151    /// What to copy by default: "rendered" or "source".
152    #[serde(default = "default_clipboard_copy")]
153    pub default_copy: String,
154}
155
156impl Default for ClipboardConfig {
157    fn default() -> Self {
158        Self {
159            default_copy: default_clipboard_copy(),
160        }
161    }
162}
163
164/// Per-renderer enable/disable and priority settings.
165#[derive(Clone, Debug, Default, Serialize, Deserialize)]
166pub struct RenderersConfig {
167    #[serde(default)]
168    pub markdown: RendererToggle,
169    #[serde(default)]
170    pub json: RendererToggle,
171    #[serde(default)]
172    pub yaml: RendererToggle,
173    #[serde(default)]
174    pub toml: RendererToggle,
175    #[serde(default)]
176    pub xml: RendererToggle,
177    #[serde(default)]
178    pub csv: RendererToggle,
179    #[serde(default)]
180    pub diff: DiffRendererConfig,
181    #[serde(default)]
182    pub log: RendererToggle,
183    #[serde(default)]
184    pub diagrams: DiagramRendererConfig,
185    #[serde(default)]
186    pub sql_results: RendererToggle,
187    #[serde(default)]
188    pub stack_trace: RendererToggle,
189}
190
191/// Enable/disable and priority for a renderer.
192#[derive(Clone, Debug, Serialize, Deserialize)]
193pub struct RendererToggle {
194    /// Whether this renderer is enabled.
195    #[serde(default = "default_true")]
196    pub enabled: bool,
197
198    /// Priority (higher = checked first in detection).
199    #[serde(default = "default_priority")]
200    pub priority: i32,
201}
202
203impl Default for RendererToggle {
204    fn default() -> Self {
205        Self {
206            enabled: true,
207            priority: default_priority(),
208        }
209    }
210}
211
212/// Diff renderer with side-by-side option.
213#[derive(Clone, Debug, Serialize, Deserialize)]
214pub struct DiffRendererConfig {
215    #[serde(default = "default_true")]
216    pub enabled: bool,
217
218    #[serde(default = "default_priority")]
219    pub priority: i32,
220
221    /// Display mode: "unified" or "side_by_side".
222    #[serde(default)]
223    pub display_mode: Option<String>,
224}
225
226impl Default for DiffRendererConfig {
227    fn default() -> Self {
228        Self {
229            enabled: true,
230            priority: default_priority(),
231            display_mode: None,
232        }
233    }
234}
235
236/// Diagram renderer with engine selection.
237#[derive(Clone, Debug, Serialize, Deserialize)]
238pub struct DiagramRendererConfig {
239    #[serde(default = "default_true")]
240    pub enabled: bool,
241
242    #[serde(default = "default_priority")]
243    pub priority: i32,
244
245    /// Rendering engine: "auto" (default — tries native → local → kroki),
246    /// "native" (pure-Rust mermaid only), "local" (CLI tools), "kroki" (API),
247    /// or "text_fallback" (source display only).
248    #[serde(default)]
249    pub engine: Option<String>,
250
251    /// Kroki server URL (only used when engine = "kroki").
252    #[serde(default)]
253    pub kroki_server: Option<String>,
254}
255
256impl Default for DiagramRendererConfig {
257    fn default() -> Self {
258        Self {
259            enabled: true,
260            priority: default_priority(),
261            engine: None,
262            kroki_server: None,
263        }
264    }
265}
266
267/// A user-defined custom renderer definition.
268#[derive(Clone, Debug, Serialize, Deserialize)]
269pub struct CustomRendererConfig {
270    /// Unique ID for this custom renderer.
271    pub id: String,
272
273    /// Human-readable name.
274    pub name: String,
275
276    /// Detection regex patterns (at least one must match).
277    #[serde(default)]
278    pub detect_patterns: Vec<String>,
279
280    /// Shell command to pipe content through for rendering.
281    #[serde(default)]
282    pub render_command: Option<String>,
283
284    /// Arguments to pass to the render command.
285    #[serde(default)]
286    pub render_args: Vec<String>,
287
288    /// Priority relative to built-in renderers.
289    #[serde(default = "default_priority")]
290    pub priority: i32,
291}
292
293/// Claude Code integration settings.
294#[derive(Clone, Debug, Serialize, Deserialize)]
295pub struct ClaudeCodeConfig {
296    /// Auto-detect Claude Code sessions.
297    #[serde(default = "default_true")]
298    pub auto_detect: bool,
299
300    /// Enable markdown rendering in Claude Code output.
301    #[serde(default = "default_true")]
302    pub render_markdown: bool,
303
304    /// Enable diff rendering in Claude Code output.
305    #[serde(default = "default_true")]
306    pub render_diffs: bool,
307
308    /// Automatically render content when a collapsed block is expanded (Ctrl+O).
309    #[serde(default = "default_true")]
310    pub auto_render_on_expand: bool,
311
312    /// Show format badges on collapsed blocks (e.g., "MD", "{} JSON").
313    #[serde(default = "default_true")]
314    pub show_format_badges: bool,
315}
316
317impl Default for ClaudeCodeConfig {
318    fn default() -> Self {
319        Self {
320            auto_detect: true,
321            render_markdown: true,
322            render_diffs: true,
323            auto_render_on_expand: true,
324            show_format_badges: true,
325        }
326    }
327}
328
329/// User-defined detection rule overrides for a specific format.
330#[derive(Clone, Debug, Serialize, Deserialize, Default)]
331pub struct FormatDetectionRulesConfig {
332    /// Additional user-defined rules.
333    #[serde(default)]
334    pub additional: Vec<UserDetectionRule>,
335
336    /// Overrides for built-in rules (matched by rule ID).
337    #[serde(default)]
338    pub overrides: Vec<RuleOverride>,
339}
340
341/// A user-defined detection rule from config.
342#[derive(Clone, Debug, Serialize, Deserialize)]
343pub struct UserDetectionRule {
344    /// Rule identifier.
345    pub id: String,
346
347    /// Regex pattern.
348    pub pattern: String,
349
350    /// Confidence weight (0.0–1.0).
351    #[serde(default = "default_rule_weight")]
352    pub weight: f32,
353
354    /// Scope: "any_line", "first_lines:N", "last_lines:N", "full_block", "preceding_command".
355    #[serde(default = "default_rule_scope")]
356    pub scope: String,
357
358    /// Whether this rule is enabled.
359    #[serde(default = "default_true")]
360    pub enabled: bool,
361
362    /// Human-readable description.
363    #[serde(default)]
364    pub description: String,
365}
366
367fn default_rule_weight() -> f32 {
368    0.3
369}
370
371fn default_rule_scope() -> String {
372    "any_line".to_string()
373}
374
375/// Override settings for a built-in detection rule.
376#[derive(Clone, Debug, Serialize, Deserialize)]
377pub struct RuleOverride {
378    /// ID of the built-in rule to override.
379    pub id: String,
380
381    /// Override enabled state.
382    #[serde(default)]
383    pub enabled: Option<bool>,
384
385    /// Override weight.
386    #[serde(default)]
387    pub weight: Option<f32>,
388}
389
390/// Render cache settings.
391#[derive(Clone, Debug, Serialize, Deserialize)]
392pub struct CacheConfig {
393    /// Maximum number of entries in the render cache.
394    #[serde(default = "default_cache_max_entries")]
395    pub max_entries: usize,
396}
397
398impl Default for CacheConfig {
399    fn default() -> Self {
400        Self {
401            max_entries: default_cache_max_entries(),
402        }
403    }
404}
405
406// ---------------------------------------------------------------------------
407// Profile override types — every field is Option<T> so omitted values
408// inherit from global config.
409// ---------------------------------------------------------------------------
410
411/// Profile-level override for prettifier configuration.
412/// All fields are optional; `None` means "inherit from global".
413#[derive(Clone, Debug, Serialize, Deserialize, Default)]
414pub struct PrettifierConfigOverride {
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub respect_alternate_screen: Option<bool>,
417
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub per_block_toggle: Option<bool>,
420
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub detection: Option<DetectionConfigOverride>,
423
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub renderers: Option<RenderersConfigOverride>,
426
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub claude_code_integration: Option<ClaudeCodeConfigOverride>,
429}
430
431/// Profile-level override for detection settings.
432#[derive(Clone, Debug, Serialize, Deserialize, Default)]
433pub struct DetectionConfigOverride {
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub scope: Option<String>,
436
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub confidence_threshold: Option<f32>,
439
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub max_scan_lines: Option<usize>,
442
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub debounce_ms: Option<u64>,
445}
446
447/// Profile-level override for per-renderer settings.
448#[derive(Clone, Debug, Serialize, Deserialize, Default)]
449pub struct RenderersConfigOverride {
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub markdown: Option<RendererToggleOverride>,
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub json: Option<RendererToggleOverride>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub yaml: Option<RendererToggleOverride>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub toml: Option<RendererToggleOverride>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub xml: Option<RendererToggleOverride>,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub csv: Option<RendererToggleOverride>,
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub diff: Option<RendererToggleOverride>,
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub log: Option<RendererToggleOverride>,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub diagrams: Option<RendererToggleOverride>,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub sql_results: Option<RendererToggleOverride>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub stack_trace: Option<RendererToggleOverride>,
472}
473
474/// Profile-level override for a single renderer's toggle.
475#[derive(Clone, Debug, Serialize, Deserialize, Default)]
476pub struct RendererToggleOverride {
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub enabled: Option<bool>,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub priority: Option<i32>,
481}
482
483/// Profile-level override for Claude Code integration.
484#[derive(Clone, Debug, Serialize, Deserialize, Default)]
485pub struct ClaudeCodeConfigOverride {
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub auto_detect: Option<bool>,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub render_markdown: Option<bool>,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub render_diffs: Option<bool>,
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub auto_render_on_expand: Option<bool>,
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub show_format_badges: Option<bool>,
496}
497
498// ---------------------------------------------------------------------------
499// Resolved config — the final merged result from global + profile.
500// ---------------------------------------------------------------------------
501
502/// Fully resolved prettifier config after merging global + profile overrides.
503#[derive(Clone, Debug)]
504pub struct ResolvedPrettifierConfig {
505    pub enabled: bool,
506    pub respect_alternate_screen: bool,
507    pub global_toggle_key: String,
508    pub per_block_toggle: bool,
509    pub detection: DetectionConfig,
510    pub clipboard: ClipboardConfig,
511    pub renderers: RenderersConfig,
512    pub custom_renderers: Vec<CustomRendererConfig>,
513    pub claude_code_integration: ClaudeCodeConfig,
514    pub detection_rules: HashMap<String, FormatDetectionRulesConfig>,
515    pub cache: CacheConfig,
516}
517
518/// Resolve effective prettifier config by merging global defaults with profile overrides.
519///
520/// Precedence (highest to lowest):
521/// 1. Profile-level setting (if present)
522/// 2. Global config-level setting
523/// 3. Built-in default
524pub fn resolve_prettifier_config(
525    global_enabled: bool,
526    global_config: &PrettifierYamlConfig,
527    profile_enabled: Option<bool>,
528    profile_config: Option<&PrettifierConfigOverride>,
529) -> ResolvedPrettifierConfig {
530    let enabled = profile_enabled.unwrap_or(global_enabled);
531
532    let (detection, renderers, claude_code_integration, respect_alternate_screen, per_block_toggle) =
533        if let Some(overrides) = profile_config {
534            let detection = merge_detection(&global_config.detection, overrides.detection.as_ref());
535            let renderers = merge_renderers(&global_config.renderers, overrides.renderers.as_ref());
536            let claude = merge_claude_code(
537                &global_config.claude_code_integration,
538                overrides.claude_code_integration.as_ref(),
539            );
540            let respect_alt = overrides
541                .respect_alternate_screen
542                .unwrap_or(global_config.respect_alternate_screen);
543            let per_block = overrides
544                .per_block_toggle
545                .unwrap_or(global_config.per_block_toggle);
546            (detection, renderers, claude, respect_alt, per_block)
547        } else {
548            (
549                global_config.detection.clone(),
550                global_config.renderers.clone(),
551                global_config.claude_code_integration.clone(),
552                global_config.respect_alternate_screen,
553                global_config.per_block_toggle,
554            )
555        };
556
557    ResolvedPrettifierConfig {
558        enabled,
559        respect_alternate_screen,
560        global_toggle_key: global_config.global_toggle_key.clone(),
561        per_block_toggle,
562        detection,
563        clipboard: global_config.clipboard.clone(),
564        renderers,
565        custom_renderers: global_config.custom_renderers.clone(),
566        claude_code_integration,
567        detection_rules: global_config.detection_rules.clone(),
568        cache: global_config.cache.clone(),
569    }
570}
571
572fn merge_detection(
573    global: &DetectionConfig,
574    profile: Option<&DetectionConfigOverride>,
575) -> DetectionConfig {
576    let Some(p) = profile else {
577        return global.clone();
578    };
579    DetectionConfig {
580        scope: p.scope.clone().unwrap_or_else(|| global.scope.clone()),
581        confidence_threshold: p
582            .confidence_threshold
583            .unwrap_or(global.confidence_threshold),
584        max_scan_lines: p.max_scan_lines.unwrap_or(global.max_scan_lines),
585        debounce_ms: p.debounce_ms.unwrap_or(global.debounce_ms),
586    }
587}
588
589fn merge_renderers(
590    global: &RenderersConfig,
591    profile: Option<&RenderersConfigOverride>,
592) -> RenderersConfig {
593    let Some(p) = profile else {
594        return global.clone();
595    };
596
597    RenderersConfig {
598        markdown: merge_toggle(&global.markdown, p.markdown.as_ref()),
599        json: merge_toggle(&global.json, p.json.as_ref()),
600        yaml: merge_toggle(&global.yaml, p.yaml.as_ref()),
601        toml: merge_toggle(&global.toml, p.toml.as_ref()),
602        xml: merge_toggle(&global.xml, p.xml.as_ref()),
603        csv: merge_toggle(&global.csv, p.csv.as_ref()),
604        diff: DiffRendererConfig {
605            enabled: p
606                .diff
607                .as_ref()
608                .and_then(|d| d.enabled)
609                .unwrap_or(global.diff.enabled),
610            priority: p
611                .diff
612                .as_ref()
613                .and_then(|d| d.priority)
614                .unwrap_or(global.diff.priority),
615            display_mode: global.diff.display_mode.clone(),
616        },
617        log: merge_toggle(&global.log, p.log.as_ref()),
618        diagrams: DiagramRendererConfig {
619            enabled: p
620                .diagrams
621                .as_ref()
622                .and_then(|d| d.enabled)
623                .unwrap_or(global.diagrams.enabled),
624            priority: p
625                .diagrams
626                .as_ref()
627                .and_then(|d| d.priority)
628                .unwrap_or(global.diagrams.priority),
629            engine: global.diagrams.engine.clone(),
630            kroki_server: global.diagrams.kroki_server.clone(),
631        },
632        sql_results: merge_toggle(&global.sql_results, p.sql_results.as_ref()),
633        stack_trace: merge_toggle(&global.stack_trace, p.stack_trace.as_ref()),
634    }
635}
636
637fn merge_toggle(
638    global: &RendererToggle,
639    profile: Option<&RendererToggleOverride>,
640) -> RendererToggle {
641    let Some(p) = profile else {
642        return global.clone();
643    };
644    RendererToggle {
645        enabled: p.enabled.unwrap_or(global.enabled),
646        priority: p.priority.unwrap_or(global.priority),
647    }
648}
649
650fn merge_claude_code(
651    global: &ClaudeCodeConfig,
652    profile: Option<&ClaudeCodeConfigOverride>,
653) -> ClaudeCodeConfig {
654    let Some(p) = profile else {
655        return global.clone();
656    };
657    ClaudeCodeConfig {
658        auto_detect: p.auto_detect.unwrap_or(global.auto_detect),
659        render_markdown: p.render_markdown.unwrap_or(global.render_markdown),
660        render_diffs: p.render_diffs.unwrap_or(global.render_diffs),
661        auto_render_on_expand: p
662            .auto_render_on_expand
663            .unwrap_or(global.auto_render_on_expand),
664        show_format_badges: p.show_format_badges.unwrap_or(global.show_format_badges),
665    }
666}
667
668// ---------------------------------------------------------------------------
669// Tests
670// ---------------------------------------------------------------------------
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    #[test]
677    fn test_prettifier_yaml_config_defaults() {
678        let config = PrettifierYamlConfig::default();
679        assert!(config.respect_alternate_screen);
680        assert!(config.per_block_toggle);
681        assert_eq!(config.global_toggle_key, "Ctrl+Shift+P");
682        assert!(config.custom_renderers.is_empty());
683        assert!(config.detection_rules.is_empty());
684    }
685
686    #[test]
687    fn test_detection_config_defaults() {
688        let config = DetectionConfig::default();
689        assert_eq!(config.scope, "all");
690        assert!((config.confidence_threshold - 0.6).abs() < f32::EPSILON);
691        assert_eq!(config.max_scan_lines, 500);
692        assert_eq!(config.debounce_ms, 100);
693    }
694
695    #[test]
696    fn test_renderer_toggle_defaults() {
697        let toggle = RendererToggle::default();
698        assert!(toggle.enabled);
699        assert_eq!(toggle.priority, 50);
700    }
701
702    #[test]
703    fn test_renderers_config_defaults() {
704        let config = RenderersConfig::default();
705        assert!(config.markdown.enabled);
706        assert!(config.json.enabled);
707        assert!(config.diff.enabled);
708        assert!(config.diagrams.enabled);
709    }
710
711    #[test]
712    fn test_clipboard_config_defaults() {
713        let config = ClipboardConfig::default();
714        assert_eq!(config.default_copy, "rendered");
715    }
716
717    #[test]
718    fn test_claude_code_config_defaults() {
719        let config = ClaudeCodeConfig::default();
720        assert!(config.auto_detect);
721        assert!(config.render_markdown);
722        assert!(config.render_diffs);
723        assert!(config.auto_render_on_expand);
724        assert!(config.show_format_badges);
725    }
726
727    #[test]
728    fn test_cache_config_defaults() {
729        let config = CacheConfig::default();
730        assert_eq!(config.max_entries, 64);
731    }
732
733    #[test]
734    fn test_yaml_deserialization_empty() {
735        let yaml = "{}";
736        let config: PrettifierYamlConfig = serde_yaml::from_str(yaml).unwrap();
737        assert!(config.respect_alternate_screen);
738        assert_eq!(config.detection.scope, "all");
739    }
740
741    #[test]
742    fn test_yaml_deserialization_partial() {
743        let yaml = r#"
744detection:
745  scope: "all"
746  confidence_threshold: 0.8
747renderers:
748  markdown:
749    enabled: false
750  json:
751    priority: 100
752"#;
753        let config: PrettifierYamlConfig = serde_yaml::from_str(yaml).unwrap();
754        assert_eq!(config.detection.scope, "all");
755        assert!((config.detection.confidence_threshold - 0.8).abs() < f32::EPSILON);
756        assert!(!config.renderers.markdown.enabled);
757        assert_eq!(config.renderers.json.priority, 100);
758        // Unspecified fields keep defaults
759        assert!(config.renderers.yaml.enabled);
760    }
761
762    #[test]
763    fn test_yaml_deserialization_custom_renderers() {
764        let yaml = r#"
765custom_renderers:
766  - id: "protobuf"
767    name: "Protocol Buffers"
768    detect_patterns: ["^message\\s+\\w+"]
769    render_command: "protoc --decode_raw"
770    priority: 30
771"#;
772        let config: PrettifierYamlConfig = serde_yaml::from_str(yaml).unwrap();
773        assert_eq!(config.custom_renderers.len(), 1);
774        assert_eq!(config.custom_renderers[0].id, "protobuf");
775        assert_eq!(config.custom_renderers[0].priority, 30);
776    }
777
778    #[test]
779    fn test_yaml_deserialization_detection_rules() {
780        let yaml = r#"
781detection_rules:
782  markdown:
783    additional:
784      - id: "md_custom_fence"
785        pattern: "^```custom"
786        weight: 0.4
787        scope: "first_lines:5"
788    overrides:
789      - id: "md_atx_header"
790        enabled: false
791"#;
792        let config: PrettifierYamlConfig = serde_yaml::from_str(yaml).unwrap();
793        assert!(config.detection_rules.contains_key("markdown"));
794        let md_rules = &config.detection_rules["markdown"];
795        assert_eq!(md_rules.additional.len(), 1);
796        assert_eq!(md_rules.additional[0].id, "md_custom_fence");
797        assert_eq!(md_rules.overrides.len(), 1);
798        assert!(!md_rules.overrides[0].enabled.unwrap());
799    }
800
801    #[test]
802    fn test_override_struct_defaults() {
803        let override_config = PrettifierConfigOverride::default();
804        assert!(override_config.respect_alternate_screen.is_none());
805        assert!(override_config.per_block_toggle.is_none());
806        assert!(override_config.detection.is_none());
807        assert!(override_config.renderers.is_none());
808        assert!(override_config.claude_code_integration.is_none());
809    }
810
811    #[test]
812    fn test_override_serialization_skips_none() {
813        let override_config = PrettifierConfigOverride::default();
814        let yaml = serde_yaml::to_string(&override_config).unwrap();
815        // All fields are None, so YAML should be essentially empty
816        assert_eq!(yaml.trim(), "{}");
817    }
818
819    #[test]
820    fn test_resolve_no_profile() {
821        let global = PrettifierYamlConfig::default();
822        let resolved = resolve_prettifier_config(true, &global, None, None);
823
824        assert!(resolved.enabled);
825        assert!(resolved.respect_alternate_screen);
826        assert_eq!(resolved.detection.scope, "all");
827        assert!(resolved.renderers.markdown.enabled);
828    }
829
830    #[test]
831    fn test_resolve_profile_overrides_enabled() {
832        let global = PrettifierYamlConfig::default();
833
834        // Profile disables prettifier
835        let resolved = resolve_prettifier_config(true, &global, Some(false), None);
836        assert!(!resolved.enabled);
837
838        // Profile enables prettifier when global is false
839        let resolved = resolve_prettifier_config(false, &global, Some(true), None);
840        assert!(resolved.enabled);
841    }
842
843    #[test]
844    fn test_resolve_profile_overrides_detection() {
845        let global = PrettifierYamlConfig::default();
846        let profile = PrettifierConfigOverride {
847            detection: Some(DetectionConfigOverride {
848                scope: Some("all".to_string()),
849                confidence_threshold: Some(0.9),
850                ..Default::default()
851            }),
852            ..Default::default()
853        };
854
855        let resolved = resolve_prettifier_config(true, &global, None, Some(&profile));
856        assert_eq!(resolved.detection.scope, "all");
857        assert!((resolved.detection.confidence_threshold - 0.9).abs() < f32::EPSILON);
858        // Non-overridden fields inherit global
859        assert_eq!(resolved.detection.max_scan_lines, 500);
860        assert_eq!(resolved.detection.debounce_ms, 100);
861    }
862
863    #[test]
864    fn test_resolve_profile_overrides_renderers() {
865        let global = PrettifierYamlConfig::default();
866        let profile = PrettifierConfigOverride {
867            renderers: Some(RenderersConfigOverride {
868                markdown: Some(RendererToggleOverride {
869                    enabled: Some(false),
870                    ..Default::default()
871                }),
872                json: Some(RendererToggleOverride {
873                    priority: Some(100),
874                    ..Default::default()
875                }),
876                ..Default::default()
877            }),
878            ..Default::default()
879        };
880
881        let resolved = resolve_prettifier_config(true, &global, None, Some(&profile));
882        assert!(!resolved.renderers.markdown.enabled);
883        assert_eq!(resolved.renderers.json.priority, 100);
884        // Unoverridden renderers inherit global
885        assert!(resolved.renderers.yaml.enabled);
886        assert!(resolved.renderers.diff.enabled);
887    }
888
889    #[test]
890    fn test_resolve_profile_overrides_claude_code() {
891        let global = PrettifierYamlConfig::default();
892        let profile = PrettifierConfigOverride {
893            claude_code_integration: Some(ClaudeCodeConfigOverride {
894                render_markdown: Some(false),
895                ..Default::default()
896            }),
897            ..Default::default()
898        };
899
900        let resolved = resolve_prettifier_config(true, &global, None, Some(&profile));
901        assert!(!resolved.claude_code_integration.render_markdown);
902        assert!(resolved.claude_code_integration.auto_detect); // Inherited
903        assert!(resolved.claude_code_integration.render_diffs); // Inherited
904    }
905
906    #[test]
907    fn test_resolve_inherits_omitted_fields() {
908        let mut global = PrettifierYamlConfig::default();
909        global.respect_alternate_screen = false;
910        global.per_block_toggle = false;
911
912        // Profile overrides only one field
913        let profile = PrettifierConfigOverride {
914            respect_alternate_screen: Some(true),
915            ..Default::default()
916        };
917
918        let resolved = resolve_prettifier_config(true, &global, None, Some(&profile));
919        assert!(resolved.respect_alternate_screen); // Overridden
920        assert!(!resolved.per_block_toggle); // Inherited from global
921    }
922}