Skip to main content

agile_config_client/
options.rs

1//! Client construction options and builder.
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::client::Client;
7use crate::error::Error;
8
9/// Connection and cache settings for [`Client`].
10///
11/// Fields are public so callers can construct this struct directly. Invalid
12/// combinations (empty `app_id` or `nodes`) are rejected by [`Client::new`].
13///
14/// # Examples
15///
16/// ```
17/// use agile_config_client::ClientOptions;
18///
19/// let options = ClientOptions {
20///     app_id: "app".into(),
21///     secret: "secret".into(),
22///     nodes: vec!["http://localhost:5000".into()],
23///     env: "DEV".into(),
24///     ..ClientOptions::default()
25/// };
26/// assert_eq!(options.app_id, "app");
27/// ```
28#[derive(Clone, Debug)]
29pub struct ClientOptions {
30    /// Application id configured in the `AgileConfig` console.
31    pub app_id: String,
32    /// Application secret. May be empty when the app has no secret.
33    pub secret: String,
34    /// Server node base URLs (`http://` or `https://`).
35    ///
36    /// Entries may be comma-separated; they are split and trimmed in [`Client::new`].
37    pub nodes: Vec<String>,
38    /// Target environment. Empty lets the server pick its default.
39    pub env: String,
40    /// Optional display name shown in the admin console.
41    pub name: Option<String>,
42    /// Optional tag shown in the admin console.
43    pub tag: Option<String>,
44    /// Timeout for HTTP configuration pulls.
45    pub http_timeout: Duration,
46    /// Delay between WebSocket reconnect attempts.
47    pub reconnect_interval: Duration,
48    /// Interval for sending WebSocket `ping` text frames.
49    pub heartbeat_interval: Duration,
50    /// Local file cache settings.
51    pub cache: CacheOptions,
52}
53
54impl Default for ClientOptions {
55    fn default() -> Self {
56        Self {
57            app_id: String::new(),
58            secret: String::new(),
59            nodes: Vec::new(),
60            env: String::new(),
61            name: None,
62            tag: None,
63            http_timeout: Duration::from_secs(100),
64            reconnect_interval: Duration::from_secs(5),
65            heartbeat_interval: Duration::from_secs(30),
66            cache: CacheOptions::default(),
67        }
68    }
69}
70
71impl ClientOptions {
72    /// Starts a builder that fills a [`ClientOptions`] value.
73    pub fn builder() -> ClientBuilder {
74        ClientBuilder::default()
75    }
76
77    pub(crate) fn normalized(mut self) -> Result<Self, Error> {
78        self.app_id = self.app_id.trim().to_string();
79        if self.app_id.is_empty() {
80            return Err(Error::EmptyAppId);
81        }
82
83        self.nodes = normalize_nodes(&self.nodes);
84        if self.nodes.is_empty() {
85            return Err(Error::EmptyNodes);
86        }
87
88        self.env = self.env.trim().to_ascii_uppercase();
89        self.secret = self.secret.trim().to_string();
90        self.name = trim_optional(self.name);
91        self.tag = trim_optional(self.tag);
92
93        if self.http_timeout.is_zero() {
94            self.http_timeout = Duration::from_secs(30);
95        }
96        if self.reconnect_interval.is_zero() {
97            self.reconnect_interval = Duration::from_secs(5);
98        }
99        if self.heartbeat_interval.is_zero() {
100            self.heartbeat_interval = Duration::from_secs(30);
101        }
102
103        #[cfg(not(feature = "cache-encrypt"))]
104        if self.cache.encrypt {
105            return Err(Error::CacheEncryptDisabled);
106        }
107
108        Ok(self)
109    }
110}
111
112/// Local cache of the last successfully pulled configuration JSON.
113#[derive(Clone, Debug)]
114pub struct CacheOptions {
115    /// When `true`, successful pulls are written to disk and used if HTTP fails.
116    pub enabled: bool,
117    /// Directory for the cache file. Empty means the process working directory.
118    pub directory: PathBuf,
119    /// When `true`, cache contents are AES-encrypted with the application secret.
120    ///
121    /// Requires the `cache-encrypt` crate feature. [`Client::new`] returns
122    /// [`Error::CacheEncryptDisabled`] if this is set without that feature.
123    pub encrypt: bool,
124}
125
126impl Default for CacheOptions {
127    fn default() -> Self {
128        Self {
129            enabled: true,
130            directory: PathBuf::new(),
131            encrypt: false,
132        }
133    }
134}
135
136/// Fluent builder for [`Client`] / [`ClientOptions`].
137#[derive(Clone, Debug, Default)]
138#[must_use]
139pub struct ClientBuilder {
140    options: ClientOptions,
141}
142
143impl ClientBuilder {
144    /// Sets the application id.
145    pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
146        self.options.app_id = app_id.into();
147        self
148    }
149
150    /// Sets the application secret.
151    pub fn secret(mut self, secret: impl Into<String>) -> Self {
152        self.options.secret = secret.into();
153        self
154    }
155
156    /// Sets the server node URLs.
157    pub fn nodes<I, S>(mut self, nodes: I) -> Self
158    where
159        I: IntoIterator<Item = S>,
160        S: Into<String>,
161    {
162        self.options.nodes = nodes.into_iter().map(Into::into).collect();
163        self
164    }
165
166    /// Sets the environment name.
167    pub fn env(mut self, env: impl Into<String>) -> Self {
168        self.options.env = env.into();
169        self
170    }
171
172    /// Sets the client display name.
173    pub fn name(mut self, name: impl Into<String>) -> Self {
174        self.options.name = Some(name.into());
175        self
176    }
177
178    /// Sets the client tag.
179    pub fn tag(mut self, tag: impl Into<String>) -> Self {
180        self.options.tag = Some(tag.into());
181        self
182    }
183
184    /// Sets the HTTP timeout.
185    pub fn http_timeout(mut self, timeout: Duration) -> Self {
186        self.options.http_timeout = timeout;
187        self
188    }
189
190    /// Sets the WebSocket reconnect interval.
191    pub fn reconnect_interval(mut self, interval: Duration) -> Self {
192        self.options.reconnect_interval = interval;
193        self
194    }
195
196    /// Sets the WebSocket ping interval.
197    pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
198        self.options.heartbeat_interval = interval;
199        self
200    }
201
202    /// Replaces cache settings.
203    pub fn cache(mut self, cache: CacheOptions) -> Self {
204        self.options.cache = cache;
205        self
206    }
207
208    /// Builds a [`Client`], validating required fields.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`Error::EmptyAppId`] or [`Error::EmptyNodes`] when required
213    /// fields are missing.
214    pub fn build(self) -> Result<Client, Error> {
215        Client::new(self.options)
216    }
217
218    /// Builds a validated [`ClientOptions`] value without creating a client.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`Error::EmptyAppId`] or [`Error::EmptyNodes`] when required
223    /// fields are missing.
224    pub fn build_options(self) -> Result<ClientOptions, Error> {
225        self.options.normalized()
226    }
227}
228
229pub(crate) fn normalize_nodes(nodes: &[String]) -> Vec<String> {
230    nodes
231        .iter()
232        .flat_map(|node| node.split(','))
233        .map(str::trim)
234        .filter(|node| !node.is_empty())
235        .map(|node| node.trim_end_matches('/').to_string())
236        .collect()
237}
238
239fn trim_optional(value: Option<String>) -> Option<String> {
240    value.and_then(|raw| {
241        let trimmed = raw.trim();
242        if trimmed.is_empty() {
243            None
244        } else {
245            Some(trimmed.to_string())
246        }
247    })
248}
249
250#[cfg(test)]
251mod tests {
252    use super::{ClientOptions, normalize_nodes};
253
254    #[test]
255    fn normalize_nodes_splits_commas_and_strips_slashes() {
256        let nodes = vec![
257            " http://localhost:5000/ ".into(),
258            "http://n2:1,http://n3:2/".into(),
259        ];
260        assert_eq!(
261            normalize_nodes(&nodes),
262            vec![
263                "http://localhost:5000".to_string(),
264                "http://n2:1".to_string(),
265                "http://n3:2".to_string(),
266            ]
267        );
268    }
269
270    #[test]
271    fn normalized_rejects_empty_app_id() {
272        let error = ClientOptions {
273            nodes: vec!["http://localhost:5000".into()],
274            ..ClientOptions::default()
275        }
276        .normalized()
277        .unwrap_err();
278        assert_eq!(error.to_string(), "app_id must not be empty");
279    }
280
281    #[test]
282    fn normalized_uppercases_env() {
283        let options = ClientOptions {
284            app_id: "app".into(),
285            nodes: vec!["http://localhost:5000".into()],
286            env: " dev ".into(),
287            ..ClientOptions::default()
288        }
289        .normalized()
290        .unwrap();
291        assert_eq!(options.env, "DEV");
292    }
293
294    #[cfg(not(feature = "cache-encrypt"))]
295    #[test]
296    fn normalized_rejects_encrypt_without_feature() {
297        use super::CacheOptions;
298        use crate::Error;
299
300        let error = ClientOptions {
301            app_id: "app".into(),
302            nodes: vec!["http://localhost:5000".into()],
303            cache: CacheOptions {
304                encrypt: true,
305                ..CacheOptions::default()
306            },
307            ..ClientOptions::default()
308        }
309        .normalized()
310        .unwrap_err();
311        assert!(matches!(error, Error::CacheEncryptDisabled));
312    }
313
314    #[cfg(feature = "cache-encrypt")]
315    #[test]
316    fn normalized_allows_encrypt_with_feature() {
317        use super::CacheOptions;
318
319        ClientOptions {
320            app_id: "app".into(),
321            nodes: vec!["http://localhost:5000".into()],
322            cache: CacheOptions {
323                encrypt: true,
324                ..CacheOptions::default()
325            },
326            ..ClientOptions::default()
327        }
328        .normalized()
329        .unwrap();
330    }
331}