1use serde::{Deserialize, Serialize};
26use std::path::{Path, PathBuf};
27
28const DEFAULT_RETENTION_DAYS: u32 = 365;
30
31const USES_DIR_NAME: &str = "uses";
33
34const ENV_USES_ENABLED: &str = "SQRY_USES_ENABLED";
36
37const ENV_USES_DIR: &str = "SQRY_USES_DIR";
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42#[serde(default)]
43pub struct UsesConfig {
44 pub enabled: bool,
49
50 pub retention_days: u32,
54
55 #[serde(default)]
57 pub contextual_feedback: ContextualFeedbackConfig,
58
59 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77#[serde(default)]
78pub struct ContextualFeedbackConfig {
79 pub enabled: bool,
83
84 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "snake_case")]
102pub enum PromptFrequency {
103 SessionOnce,
105 Never,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111#[serde(default)]
112pub struct AutoSummarizeConfig {
113 pub enabled: bool,
117}
118
119impl Default for AutoSummarizeConfig {
120 fn default() -> Self {
121 Self { enabled: true }
122 }
123}
124
125impl UsesConfig {
126 #[must_use]
137 pub fn load() -> Self {
138 let mut config = Self::load_from_file().unwrap_or_default();
139
140 config.apply_env_overrides();
142
143 config
144 }
145
146 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 fn apply_env_overrides(&mut self) {
164 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 #[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 #[must_use]
193 pub fn uses_dir() -> Option<PathBuf> {
194 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 dirs::home_dir().map(|h| h.join(".sqry").join(USES_DIR_NAME))
204 }
205
206 pub fn save(&self) -> Result<(), ConfigSaveError> {
212 let config_path = Self::default_config_path().ok_or(ConfigSaveError::NoHomeDir)?;
213
214 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 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 #[cfg(test)]
246 #[must_use]
247 pub fn test_disabled() -> Self {
248 Self {
249 enabled: false,
250 ..Default::default()
251 }
252 }
253}
254
255#[derive(Debug, thiserror::Error)]
257pub enum ConfigLoadError {
258 #[error("failed to read config: {0}")]
260 IoError(String),
261
262 #[error("failed to parse config: {0}")]
264 ParseError(String),
265}
266
267#[derive(Debug, thiserror::Error)]
269pub enum ConfigSaveError {
270 #[error("home directory not available")]
272 NoHomeDir,
273
274 #[error("failed to write config: {0}")]
276 IoError(String),
277
278 #[error("failed to serialize config: {0}")]
280 SerializeError(String),
281}
282
283#[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 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); assert!(config.contextual_feedback.enabled); }
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 let mut config = UsesConfig::default();
366 assert!(config.enabled);
367
368 unsafe {
370 std::env::set_var(ENV_USES_ENABLED, "false");
371 }
372 config.apply_env_overrides();
373
374 assert!(!config.enabled);
375
376 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 let mut config = UsesConfig::default();
408
409 unsafe {
411 std::env::set_var(ENV_USES_ENABLED, "0");
412 }
413 config.apply_env_overrides();
414 assert!(!config.enabled);
415
416 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 unsafe {
426 std::env::set_var(ENV_USES_ENABLED, "1");
427 }
428 config.apply_env_overrides();
429 assert!(config.enabled);
430
431 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 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 let json = serde_json::to_string_pretty(&config).unwrap();
462 std::fs::write(&config_path, json).unwrap();
463
464 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 unsafe {
475 std::env::remove_var(ENV_USES_DIR);
476 }
477
478 let dir = UsesConfig::uses_dir();
479
480 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}