Skip to main content

sqry_core/uses/
config.rs

1//! Configuration for local uses and insights
2//!
3//! This module provides configuration loading and management for the uses system.
4//! Configuration can come from:
5//! 1. Environment variables (highest priority)
6//! 2. Configuration file (`~/.sqry/uses/config.json`)
7//! 3. Defaults (lowest priority)
8//!
9//! # Environment Variables
10//!
11//! - `SQRY_USES_ENABLED` - Set to "false" or "0" to disable all uses capture
12//! - `SQRY_USES_DIR` - Custom directory for uses storage (default: `~/.sqry/uses/`)
13//!
14//! # Usage
15//!
16//! ```rust,ignore
17//! use sqry_core::uses::config::UsesConfig;
18//!
19//! let config = UsesConfig::load()?;
20//! if config.enabled {
21//!     // Uses capture is enabled
22//! }
23//! ```
24
25use serde::{Deserialize, Serialize};
26use std::path::{Path, PathBuf};
27
28/// Default retention period in days
29const DEFAULT_RETENTION_DAYS: u32 = 365;
30
31/// Default uses directory name
32const USES_DIR_NAME: &str = "uses";
33
34/// Environment variable for enabling/disabling uses
35const ENV_USES_ENABLED: &str = "SQRY_USES_ENABLED";
36
37/// Environment variable for custom uses directory
38const ENV_USES_DIR: &str = "SQRY_USES_DIR";
39
40/// Configuration for local uses and insights
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42#[serde(default)]
43pub struct UsesConfig {
44    /// Whether uses capture is enabled
45    ///
46    /// Default: true
47    /// Can be overridden by `SQRY_USES_ENABLED=false`
48    pub enabled: bool,
49
50    /// Number of days to retain event logs
51    ///
52    /// Default: 365
53    pub retention_days: u32,
54
55    /// Configuration for contextual feedback prompts
56    #[serde(default)]
57    pub contextual_feedback: ContextualFeedbackConfig,
58
59    /// Configuration for automatic summarization
60    #[serde(default)]
61    pub auto_summarize: AutoSummarizeConfig,
62}
63
64impl Default for UsesConfig {
65    fn default() -> Self {
66        Self {
67            enabled: true,
68            retention_days: DEFAULT_RETENTION_DAYS,
69            contextual_feedback: ContextualFeedbackConfig::default(),
70            auto_summarize: AutoSummarizeConfig::default(),
71        }
72    }
73}
74
75/// Configuration for contextual feedback prompts
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77#[serde(default)]
78pub struct ContextualFeedbackConfig {
79    /// Whether contextual feedback prompts are enabled
80    ///
81    /// Default: true
82    pub enabled: bool,
83
84    /// How often to prompt for feedback
85    ///
86    /// Default: "`session_once`" (at most once per session)
87    pub prompt_frequency: PromptFrequency,
88}
89
90impl Default for ContextualFeedbackConfig {
91    fn default() -> Self {
92        Self {
93            enabled: true,
94            prompt_frequency: PromptFrequency::SessionOnce,
95        }
96    }
97}
98
99/// Frequency of feedback prompts
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "snake_case")]
102pub enum PromptFrequency {
103    /// Prompt at most once per CLI session
104    SessionOnce,
105    /// Never prompt (user must initiate feedback)
106    Never,
107}
108
109/// Configuration for automatic summarization
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111#[serde(default)]
112pub struct AutoSummarizeConfig {
113    /// Whether automatic summarization is enabled
114    ///
115    /// Default: true
116    pub enabled: bool,
117}
118
119impl Default for AutoSummarizeConfig {
120    fn default() -> Self {
121        Self { enabled: true }
122    }
123}
124
125impl UsesConfig {
126    /// Load configuration from environment and/or file
127    ///
128    /// Priority (highest to lowest):
129    /// 1. Environment variables
130    /// 2. Config file
131    /// 3. Defaults
132    ///
133    /// # Returns
134    ///
135    /// The effective configuration.
136    #[must_use]
137    pub fn load() -> Self {
138        let mut config = Self::load_from_file().unwrap_or_default();
139
140        // Environment overrides
141        config.apply_env_overrides();
142
143        config
144    }
145
146    /// Load configuration from the default config file
147    ///
148    /// # Returns
149    ///
150    /// The configuration from file, or None if file doesn't exist or is invalid.
151    fn load_from_file() -> Option<Self> {
152        let config_path = Self::default_config_path()?;
153
154        if !config_path.exists() {
155            return None;
156        }
157
158        let contents = std::fs::read_to_string(&config_path).ok()?;
159        serde_json::from_str(&contents).ok()
160    }
161
162    /// Apply environment variable overrides
163    fn apply_env_overrides(&mut self) {
164        // SQRY_USES_ENABLED
165        if let Ok(value) = std::env::var(ENV_USES_ENABLED) {
166            let value_lower = value.to_lowercase();
167            if value_lower == "false" || value_lower == "0" || value_lower == "no" {
168                self.enabled = false;
169            } else if value_lower == "true" || value_lower == "1" || value_lower == "yes" {
170                self.enabled = true;
171            }
172        }
173    }
174
175    /// Get the default config file path
176    ///
177    /// # Returns
178    ///
179    /// Path to `~/.sqry/uses/config.json`, or None if home dir unavailable.
180    #[must_use]
181    pub fn default_config_path() -> Option<PathBuf> {
182        dirs::home_dir().map(|h| h.join(".sqry").join(USES_DIR_NAME).join("config.json"))
183    }
184
185    /// Get the uses directory path
186    ///
187    /// Respects `SQRY_USES_DIR` environment variable.
188    ///
189    /// # Returns
190    ///
191    /// Path to the uses directory.
192    #[must_use]
193    pub fn uses_dir() -> Option<PathBuf> {
194        // Check environment variable first
195        if let Ok(custom_dir) = std::env::var(ENV_USES_DIR) {
196            let path = PathBuf::from(custom_dir);
197            if !path.as_os_str().is_empty() {
198                return Some(path);
199            }
200        }
201
202        // Default to ~/.sqry/uses/
203        dirs::home_dir().map(|h| h.join(".sqry").join(USES_DIR_NAME))
204    }
205
206    /// Save configuration to the default config file
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the file cannot be written.
211    pub fn save(&self) -> Result<(), ConfigSaveError> {
212        let config_path = Self::default_config_path().ok_or(ConfigSaveError::NoHomeDir)?;
213
214        // Ensure directory exists
215        if let Some(parent) = config_path.parent() {
216            std::fs::create_dir_all(parent).map_err(|e| ConfigSaveError::IoError(e.to_string()))?;
217        }
218
219        let json = serde_json::to_string_pretty(self)
220            .map_err(|e| ConfigSaveError::SerializeError(e.to_string()))?;
221
222        std::fs::write(&config_path, json).map_err(|e| ConfigSaveError::IoError(e.to_string()))?;
223
224        Ok(())
225    }
226
227    /// Load configuration from a specific path
228    ///
229    /// # Arguments
230    ///
231    /// * `path` - Path to the config file
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the file cannot be read or parsed.
236    pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigLoadError> {
237        let path = path.as_ref();
238        let contents =
239            std::fs::read_to_string(path).map_err(|e| ConfigLoadError::IoError(e.to_string()))?;
240
241        serde_json::from_str(&contents).map_err(|e| ConfigLoadError::ParseError(e.to_string()))
242    }
243
244    /// Create a config for testing (uses disabled by default)
245    #[cfg(test)]
246    #[must_use]
247    pub fn test_disabled() -> Self {
248        Self {
249            enabled: false,
250            ..Default::default()
251        }
252    }
253}
254
255/// Errors that can occur when loading configuration
256#[derive(Debug, thiserror::Error)]
257pub enum ConfigLoadError {
258    /// IO error reading config file
259    #[error("failed to read config: {0}")]
260    IoError(String),
261
262    /// Parse error in config file
263    #[error("failed to parse config: {0}")]
264    ParseError(String),
265}
266
267/// Errors that can occur when saving configuration
268#[derive(Debug, thiserror::Error)]
269pub enum ConfigSaveError {
270    /// Home directory not available
271    #[error("home directory not available")]
272    NoHomeDir,
273
274    /// IO error writing config file
275    #[error("failed to write config: {0}")]
276    IoError(String),
277
278    /// Serialization error
279    #[error("failed to serialize config: {0}")]
280    SerializeError(String),
281}
282
283// ============================================================================
284// Tests
285// ============================================================================
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use serial_test::serial;
291    use tempfile::tempdir;
292
293    #[test]
294    fn test_default_config() {
295        let config = UsesConfig::default();
296
297        assert!(config.enabled);
298        assert_eq!(config.retention_days, 365);
299        assert!(config.contextual_feedback.enabled);
300        assert_eq!(
301            config.contextual_feedback.prompt_frequency,
302            PromptFrequency::SessionOnce
303        );
304        assert!(config.auto_summarize.enabled);
305    }
306
307    #[test]
308    fn test_config_serialization() {
309        let config = UsesConfig::default();
310        let json = serde_json::to_string(&config).unwrap();
311        let parsed: UsesConfig = serde_json::from_str(&json).unwrap();
312
313        assert_eq!(config, parsed);
314    }
315
316    #[test]
317    fn test_config_partial_parse() {
318        // Partial config should use defaults for missing fields
319        let json = r#"{"enabled": false}"#;
320        let config: UsesConfig = serde_json::from_str(json).unwrap();
321
322        assert!(!config.enabled);
323        assert_eq!(config.retention_days, 365); // Default
324        assert!(config.contextual_feedback.enabled); // Default
325    }
326
327    #[test]
328    fn test_prompt_frequency_serialization() {
329        assert_eq!(
330            serde_json::to_string(&PromptFrequency::SessionOnce).unwrap(),
331            "\"session_once\""
332        );
333        assert_eq!(
334            serde_json::to_string(&PromptFrequency::Never).unwrap(),
335            "\"never\""
336        );
337    }
338
339    #[test]
340    fn test_load_from_path() {
341        let dir = tempdir().unwrap();
342        let config_path = dir.path().join("config.json");
343
344        let config = UsesConfig {
345            enabled: false,
346            retention_days: 90,
347            ..Default::default()
348        };
349
350        let json = serde_json::to_string_pretty(&config).unwrap();
351        std::fs::write(&config_path, json).unwrap();
352
353        let loaded = UsesConfig::load_from_path(&config_path).unwrap();
354
355        assert!(!loaded.enabled);
356        assert_eq!(loaded.retention_days, 90);
357    }
358
359    #[test]
360    #[serial]
361    fn test_env_override_disabled() {
362        // Note: This test modifies environment variables
363        // In production, you'd want to use serial_test for this
364
365        let mut config = UsesConfig::default();
366        assert!(config.enabled);
367
368        // Simulate environment variable
369        unsafe {
370            std::env::set_var(ENV_USES_ENABLED, "false");
371        }
372        config.apply_env_overrides();
373
374        assert!(!config.enabled);
375
376        // Clean up
377        unsafe {
378            std::env::remove_var(ENV_USES_ENABLED);
379        }
380    }
381
382    #[test]
383    #[serial]
384    fn test_env_override_enabled() {
385        let mut config = UsesConfig {
386            enabled: false,
387            ..Default::default()
388        };
389
390        unsafe {
391            std::env::set_var(ENV_USES_ENABLED, "true");
392        }
393        config.apply_env_overrides();
394
395        assert!(config.enabled);
396
397        unsafe {
398            std::env::remove_var(ENV_USES_ENABLED);
399        }
400    }
401
402    #[test]
403    #[serial]
404    fn test_env_override_variations() {
405        // Test various truthy/falsy values
406
407        let mut config = UsesConfig::default();
408
409        // Test "0"
410        unsafe {
411            std::env::set_var(ENV_USES_ENABLED, "0");
412        }
413        config.apply_env_overrides();
414        assert!(!config.enabled);
415
416        // Test "no"
417        config.enabled = true;
418        unsafe {
419            std::env::set_var(ENV_USES_ENABLED, "no");
420        }
421        config.apply_env_overrides();
422        assert!(!config.enabled);
423
424        // Test "1"
425        unsafe {
426            std::env::set_var(ENV_USES_ENABLED, "1");
427        }
428        config.apply_env_overrides();
429        assert!(config.enabled);
430
431        // Test "yes"
432        config.enabled = false;
433        unsafe {
434            std::env::set_var(ENV_USES_ENABLED, "yes");
435        }
436        config.apply_env_overrides();
437        assert!(config.enabled);
438
439        // Clean up
440        unsafe {
441            std::env::remove_var(ENV_USES_ENABLED);
442        }
443    }
444
445    #[test]
446    fn test_save_and_load() {
447        let dir = tempdir().unwrap();
448        let config_path = dir.path().join("config.json");
449
450        let config = UsesConfig {
451            enabled: false,
452            retention_days: 30,
453            contextual_feedback: ContextualFeedbackConfig {
454                enabled: false,
455                prompt_frequency: PromptFrequency::Never,
456            },
457            auto_summarize: AutoSummarizeConfig { enabled: false },
458        };
459
460        // Save
461        let json = serde_json::to_string_pretty(&config).unwrap();
462        std::fs::write(&config_path, json).unwrap();
463
464        // Load
465        let loaded = UsesConfig::load_from_path(&config_path).unwrap();
466
467        assert_eq!(config, loaded);
468    }
469
470    #[test]
471    #[serial]
472    fn test_uses_dir_default() {
473        // Clear any env override
474        unsafe {
475            std::env::remove_var(ENV_USES_DIR);
476        }
477
478        let dir = UsesConfig::uses_dir();
479
480        // Should return Some path ending in ".sqry/uses"
481        if let Some(path) = dir {
482            assert!(path.ends_with(".sqry/uses") || path.ends_with(".sqry\\uses"));
483        }
484    }
485
486    #[test]
487    #[serial]
488    #[ignore = "Flaky: modifies global env vars which interfere with parallel tests"]
489    fn test_uses_dir_env_override() {
490        let custom_path = "/custom/uses/path";
491
492        unsafe {
493            std::env::set_var(ENV_USES_DIR, custom_path);
494        }
495
496        let dir = UsesConfig::uses_dir();
497        assert_eq!(dir, Some(PathBuf::from(custom_path)));
498
499        unsafe {
500            std::env::remove_var(ENV_USES_DIR);
501        }
502    }
503}