Skip to main content

par_term_config/
profile.rs

1//! Profile configuration types.
2//!
3//! Defines configuration types for profile management including
4//! dynamic profile sources.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9// ── Serde default helpers ──────────────────────────────────────────────
10
11fn default_refresh_interval_secs() -> u64 {
12    1800
13}
14
15fn default_max_size_bytes() -> usize {
16    1_048_576
17}
18
19fn default_fetch_timeout_secs() -> u64 {
20    10
21}
22
23fn default_true() -> bool {
24    true
25}
26
27/// How to resolve conflicts when a remote profile has the same ID as a local one
28#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
29#[serde(rename_all = "snake_case")]
30pub enum ConflictResolution {
31    /// Local profile takes precedence over remote
32    #[default]
33    LocalWins,
34    /// Remote profile takes precedence over local
35    RemoteWins,
36}
37
38impl ConflictResolution {
39    /// Returns all variants of `ConflictResolution`
40    pub fn variants() -> &'static [ConflictResolution] {
41        &[
42            ConflictResolution::LocalWins,
43            ConflictResolution::RemoteWins,
44        ]
45    }
46
47    /// Returns a human-readable display name for this variant
48    pub fn display_name(&self) -> &'static str {
49        match self {
50            ConflictResolution::LocalWins => "Local Wins",
51            ConflictResolution::RemoteWins => "Remote Wins",
52        }
53    }
54}
55
56/// A remote profile source configuration stored in the main config file
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
58pub struct DynamicProfileSource {
59    /// URL to fetch profiles YAML from
60    pub url: String,
61
62    /// Custom HTTP headers to include in the fetch request (e.g., Authorization)
63    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
64    pub headers: HashMap<String, String>,
65
66    /// How often to re-fetch profiles, in seconds (default: 1800 = 30 min)
67    #[serde(default = "default_refresh_interval_secs")]
68    pub refresh_interval_secs: u64,
69
70    /// Maximum allowed response size in bytes (default: 1 MB)
71    #[serde(default = "default_max_size_bytes")]
72    pub max_size_bytes: usize,
73
74    /// Timeout for the HTTP fetch request, in seconds (default: 10)
75    #[serde(default = "default_fetch_timeout_secs")]
76    pub fetch_timeout_secs: u64,
77
78    /// Whether this source is enabled (default: true)
79    #[serde(default = "default_true")]
80    pub enabled: bool,
81
82    /// How to resolve conflicts when a remote profile ID matches a local one
83    #[serde(default)]
84    pub conflict_resolution: ConflictResolution,
85
86    /// Whether plain HTTP (non-HTTPS) URLs are permitted for this source.
87    ///
88    /// This field is populated at runtime from the global `allow_http_profiles`
89    /// config option and is never read from or written to the config file.
90    /// Defaults to `false` (HTTP is refused) to match the global config default.
91    #[serde(skip)]
92    pub allow_http: bool,
93}
94
95impl Default for DynamicProfileSource {
96    fn default() -> Self {
97        Self {
98            url: String::new(),
99            headers: HashMap::new(),
100            refresh_interval_secs: default_refresh_interval_secs(),
101            max_size_bytes: default_max_size_bytes(),
102            fetch_timeout_secs: default_fetch_timeout_secs(),
103            enabled: true,
104            conflict_resolution: ConflictResolution::default(),
105            allow_http: false,
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn test_default_source() {
116        let source = DynamicProfileSource::default();
117
118        assert_eq!(source.url, "");
119        assert!(source.headers.is_empty());
120        assert_eq!(source.refresh_interval_secs, 1800);
121        assert_eq!(source.max_size_bytes, 1_048_576);
122        assert_eq!(source.fetch_timeout_secs, 10);
123        assert!(source.enabled);
124        assert_eq!(source.conflict_resolution, ConflictResolution::LocalWins);
125    }
126
127    #[test]
128    fn test_serialize_deserialize_roundtrip() {
129        let mut headers = HashMap::new();
130        headers.insert("Authorization".to_string(), "Bearer tok123".to_string());
131        headers.insert("X-Custom".to_string(), "value".to_string());
132
133        let source = DynamicProfileSource {
134            url: "https://example.com/profiles.yaml".to_string(),
135            headers,
136            refresh_interval_secs: 900,
137            max_size_bytes: 512_000,
138            fetch_timeout_secs: 15,
139            enabled: false,
140            conflict_resolution: ConflictResolution::RemoteWins,
141            allow_http: false,
142        };
143
144        let yaml = serde_yaml_ng::to_string(&source).expect("serialize");
145        let deserialized: DynamicProfileSource =
146            serde_yaml_ng::from_str(&yaml).expect("deserialize");
147
148        assert_eq!(deserialized.url, source.url);
149        assert_eq!(deserialized.headers, source.headers);
150        assert_eq!(
151            deserialized.refresh_interval_secs,
152            source.refresh_interval_secs
153        );
154        assert_eq!(deserialized.max_size_bytes, source.max_size_bytes);
155        assert_eq!(deserialized.fetch_timeout_secs, source.fetch_timeout_secs);
156        assert_eq!(deserialized.enabled, source.enabled);
157        assert_eq!(deserialized.conflict_resolution, source.conflict_resolution);
158    }
159
160    #[test]
161    fn test_deserialize_minimal_yaml() {
162        let yaml = "url: https://example.com/profiles.yaml\n";
163        let source: DynamicProfileSource =
164            serde_yaml_ng::from_str(yaml).expect("deserialize minimal");
165
166        assert_eq!(source.url, "https://example.com/profiles.yaml");
167        assert!(source.headers.is_empty());
168        assert_eq!(source.refresh_interval_secs, 1800);
169        assert_eq!(source.max_size_bytes, 1_048_576);
170        assert_eq!(source.fetch_timeout_secs, 10);
171        assert!(source.enabled);
172        assert_eq!(source.conflict_resolution, ConflictResolution::LocalWins);
173    }
174
175    #[test]
176    fn test_conflict_resolution_display() {
177        assert_eq!(ConflictResolution::LocalWins.display_name(), "Local Wins");
178        assert_eq!(ConflictResolution::RemoteWins.display_name(), "Remote Wins");
179    }
180
181    #[test]
182    fn test_conflict_resolution_variants() {
183        let variants = ConflictResolution::variants();
184        assert_eq!(variants.len(), 2);
185        assert_eq!(variants[0], ConflictResolution::LocalWins);
186        assert_eq!(variants[1], ConflictResolution::RemoteWins);
187    }
188}