use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const DEFAULT_RETENTION_DAYS: u32 = 365;
const USES_DIR_NAME: &str = "uses";
const ENV_USES_ENABLED: &str = "SQRY_USES_ENABLED";
const ENV_USES_DIR: &str = "SQRY_USES_DIR";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct UsesConfig {
pub enabled: bool,
pub retention_days: u32,
#[serde(default)]
pub contextual_feedback: ContextualFeedbackConfig,
#[serde(default)]
pub auto_summarize: AutoSummarizeConfig,
}
impl Default for UsesConfig {
fn default() -> Self {
Self {
enabled: true,
retention_days: DEFAULT_RETENTION_DAYS,
contextual_feedback: ContextualFeedbackConfig::default(),
auto_summarize: AutoSummarizeConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ContextualFeedbackConfig {
pub enabled: bool,
pub prompt_frequency: PromptFrequency,
}
impl Default for ContextualFeedbackConfig {
fn default() -> Self {
Self {
enabled: true,
prompt_frequency: PromptFrequency::SessionOnce,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PromptFrequency {
SessionOnce,
Never,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct AutoSummarizeConfig {
pub enabled: bool,
}
impl Default for AutoSummarizeConfig {
fn default() -> Self {
Self { enabled: true }
}
}
impl UsesConfig {
#[must_use]
pub fn load() -> Self {
let mut config = Self::load_from_file().unwrap_or_default();
config.apply_env_overrides();
config
}
fn load_from_file() -> Option<Self> {
let config_path = Self::default_config_path()?;
if !config_path.exists() {
return None;
}
let contents = std::fs::read_to_string(&config_path).ok()?;
serde_json::from_str(&contents).ok()
}
fn apply_env_overrides(&mut self) {
if let Ok(value) = std::env::var(ENV_USES_ENABLED) {
let value_lower = value.to_lowercase();
if value_lower == "false" || value_lower == "0" || value_lower == "no" {
self.enabled = false;
} else if value_lower == "true" || value_lower == "1" || value_lower == "yes" {
self.enabled = true;
}
}
}
#[must_use]
pub fn default_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".sqry").join(USES_DIR_NAME).join("config.json"))
}
#[must_use]
pub fn uses_dir() -> Option<PathBuf> {
if let Ok(custom_dir) = std::env::var(ENV_USES_DIR) {
let path = PathBuf::from(custom_dir);
if !path.as_os_str().is_empty() {
return Some(path);
}
}
dirs::home_dir().map(|h| h.join(".sqry").join(USES_DIR_NAME))
}
pub fn save(&self) -> Result<(), ConfigSaveError> {
let config_path = Self::default_config_path().ok_or(ConfigSaveError::NoHomeDir)?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ConfigSaveError::IoError(e.to_string()))?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| ConfigSaveError::SerializeError(e.to_string()))?;
std::fs::write(&config_path, json).map_err(|e| ConfigSaveError::IoError(e.to_string()))?;
Ok(())
}
pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigLoadError> {
let path = path.as_ref();
let contents =
std::fs::read_to_string(path).map_err(|e| ConfigLoadError::IoError(e.to_string()))?;
serde_json::from_str(&contents).map_err(|e| ConfigLoadError::ParseError(e.to_string()))
}
#[cfg(test)]
#[must_use]
pub fn test_disabled() -> Self {
Self {
enabled: false,
..Default::default()
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigLoadError {
#[error("failed to read config: {0}")]
IoError(String),
#[error("failed to parse config: {0}")]
ParseError(String),
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigSaveError {
#[error("home directory not available")]
NoHomeDir,
#[error("failed to write config: {0}")]
IoError(String),
#[error("failed to serialize config: {0}")]
SerializeError(String),
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::tempdir;
#[test]
fn test_default_config() {
let config = UsesConfig::default();
assert!(config.enabled);
assert_eq!(config.retention_days, 365);
assert!(config.contextual_feedback.enabled);
assert_eq!(
config.contextual_feedback.prompt_frequency,
PromptFrequency::SessionOnce
);
assert!(config.auto_summarize.enabled);
}
#[test]
fn test_config_serialization() {
let config = UsesConfig::default();
let json = serde_json::to_string(&config).unwrap();
let parsed: UsesConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn test_config_partial_parse() {
let json = r#"{"enabled": false}"#;
let config: UsesConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
assert_eq!(config.retention_days, 365); assert!(config.contextual_feedback.enabled); }
#[test]
fn test_prompt_frequency_serialization() {
assert_eq!(
serde_json::to_string(&PromptFrequency::SessionOnce).unwrap(),
"\"session_once\""
);
assert_eq!(
serde_json::to_string(&PromptFrequency::Never).unwrap(),
"\"never\""
);
}
#[test]
fn test_load_from_path() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("config.json");
let config = UsesConfig {
enabled: false,
retention_days: 90,
..Default::default()
};
let json = serde_json::to_string_pretty(&config).unwrap();
std::fs::write(&config_path, json).unwrap();
let loaded = UsesConfig::load_from_path(&config_path).unwrap();
assert!(!loaded.enabled);
assert_eq!(loaded.retention_days, 90);
}
#[test]
#[serial]
fn test_env_override_disabled() {
let mut config = UsesConfig::default();
assert!(config.enabled);
unsafe {
std::env::set_var(ENV_USES_ENABLED, "false");
}
config.apply_env_overrides();
assert!(!config.enabled);
unsafe {
std::env::remove_var(ENV_USES_ENABLED);
}
}
#[test]
#[serial]
fn test_env_override_enabled() {
let mut config = UsesConfig {
enabled: false,
..Default::default()
};
unsafe {
std::env::set_var(ENV_USES_ENABLED, "true");
}
config.apply_env_overrides();
assert!(config.enabled);
unsafe {
std::env::remove_var(ENV_USES_ENABLED);
}
}
#[test]
#[serial]
fn test_env_override_variations() {
let mut config = UsesConfig::default();
unsafe {
std::env::set_var(ENV_USES_ENABLED, "0");
}
config.apply_env_overrides();
assert!(!config.enabled);
config.enabled = true;
unsafe {
std::env::set_var(ENV_USES_ENABLED, "no");
}
config.apply_env_overrides();
assert!(!config.enabled);
unsafe {
std::env::set_var(ENV_USES_ENABLED, "1");
}
config.apply_env_overrides();
assert!(config.enabled);
config.enabled = false;
unsafe {
std::env::set_var(ENV_USES_ENABLED, "yes");
}
config.apply_env_overrides();
assert!(config.enabled);
unsafe {
std::env::remove_var(ENV_USES_ENABLED);
}
}
#[test]
fn test_save_and_load() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("config.json");
let config = UsesConfig {
enabled: false,
retention_days: 30,
contextual_feedback: ContextualFeedbackConfig {
enabled: false,
prompt_frequency: PromptFrequency::Never,
},
auto_summarize: AutoSummarizeConfig { enabled: false },
};
let json = serde_json::to_string_pretty(&config).unwrap();
std::fs::write(&config_path, json).unwrap();
let loaded = UsesConfig::load_from_path(&config_path).unwrap();
assert_eq!(config, loaded);
}
#[test]
#[serial]
fn test_uses_dir_default() {
unsafe {
std::env::remove_var(ENV_USES_DIR);
}
let dir = UsesConfig::uses_dir();
if let Some(path) = dir {
assert!(path.ends_with(".sqry/uses") || path.ends_with(".sqry\\uses"));
}
}
#[test]
#[serial]
#[ignore = "Flaky: modifies global env vars which interfere with parallel tests"]
fn test_uses_dir_env_override() {
let custom_path = "/custom/uses/path";
unsafe {
std::env::set_var(ENV_USES_DIR, custom_path);
}
let dir = UsesConfig::uses_dir();
assert_eq!(dir, Some(PathBuf::from(custom_path)));
unsafe {
std::env::remove_var(ENV_USES_DIR);
}
}
}