use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
const CONFIG_FILE_NAME: &str = "wrap.toml";
const DEFAULT_AUTO_WRAP_COMMANDS: &[&str] = &[
"claude",
"openai",
"gemini",
"anthropic",
"gpt",
"ai",
"llm",
"copilot",
"codewhisperer",
"tabnine",
"codium",
"bard",
"perplexity",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WrapConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_mode")]
pub mode: WrapMode,
#[serde(default = "default_commands")]
pub commands: HashSet<String>,
#[serde(default)]
pub custom_commands: HashSet<String>,
#[serde(default = "default_server")]
pub server: String,
#[serde(default = "default_verbose")]
pub verbose: bool,
#[serde(default)]
pub log_sessions: bool,
#[serde(default)]
pub log_directory: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WrapMode {
Warning,
Blocking,
}
impl Default for WrapConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
mode: default_mode(),
commands: default_commands(),
custom_commands: HashSet::new(),
server: default_server(),
verbose: default_verbose(),
log_sessions: false,
log_directory: None,
}
}
}
impl WrapConfig {
pub fn load() -> Result<Self> {
let config_path = Self::default_config_path()?;
if config_path.exists() {
Self::load_from_path(&config_path)
} else {
Ok(Self::default())
}
}
pub fn load_from_path(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read configuration from {}", path.display()))?;
let mut config: Self = toml::from_str(&content)
.with_context(|| format!("Failed to parse configuration from {}", path.display()))?;
config.merge_custom_commands();
Ok(config)
}
pub fn save(&self) -> Result<()> {
let config_path = Self::default_config_path()?;
self.save_to_path(&config_path)
}
pub fn save_to_path(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize configuration")?;
std::fs::write(path, content)
.with_context(|| format!("Failed to write configuration to {}", path.display()))?;
Ok(())
}
pub fn default_config_path() -> Result<PathBuf> {
let config_dir = crate::config_dir()?;
Ok(config_dir.join(CONFIG_FILE_NAME))
}
pub fn should_wrap(&self, command: &str) -> bool {
if !self.enabled {
return false;
}
let basename = Path::new(command)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(command);
self.commands.contains(basename) || self.commands.contains(command)
}
pub fn add_command(&mut self, command: String) {
self.custom_commands.insert(command.clone());
self.commands.insert(command);
}
pub fn remove_command(&mut self, command: &str) -> bool {
self.custom_commands.remove(command);
self.commands.remove(command)
}
fn merge_custom_commands(&mut self) {
for cmd in &self.custom_commands {
self.commands.insert(cmd.clone());
}
}
pub fn create_default_config() -> Result<()> {
let config_path = Self::default_config_path()?;
if config_path.exists() {
anyhow::bail!(
"Configuration file already exists at {}",
config_path.display()
);
}
let default_config = Self::default();
default_config.save_to_path(&config_path)?;
println!(
"Created default configuration at: {}",
config_path.display()
);
Ok(())
}
pub fn mode_string(&self) -> &'static str {
match self.mode {
WrapMode::Warning => "warning",
WrapMode::Blocking => "blocking",
}
}
}
fn default_enabled() -> bool {
true
}
fn default_mode() -> WrapMode {
WrapMode::Warning
}
fn default_commands() -> HashSet<String> {
DEFAULT_AUTO_WRAP_COMMANDS
.iter()
.map(|&s| s.to_string())
.collect()
}
fn default_server() -> String {
"http://localhost:8080".to_string()
}
fn default_verbose() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_default_config() {
let config = WrapConfig::default();
assert!(config.enabled);
assert_eq!(config.mode, WrapMode::Warning);
assert!(config.commands.contains("claude"));
assert!(config.commands.contains("openai"));
}
#[test]
fn test_should_wrap() {
let mut config = WrapConfig::default();
assert!(config.should_wrap("claude"));
assert!(config.should_wrap("openai"));
assert!(config.should_wrap("/usr/bin/claude"));
assert!(config.should_wrap("./openai"));
assert!(!config.should_wrap("ls"));
assert!(!config.should_wrap("cat"));
config.enabled = false;
assert!(!config.should_wrap("claude"));
}
#[test]
fn test_add_remove_commands() {
let mut config = WrapConfig::default();
config.add_command("mycli".to_string());
assert!(config.should_wrap("mycli"));
assert!(config.custom_commands.contains("mycli"));
assert!(config.remove_command("mycli"));
assert!(!config.should_wrap("mycli"));
assert!(!config.custom_commands.contains("mycli"));
}
#[test]
fn test_save_load_config() -> Result<()> {
let temp_dir = TempDir::new()?;
let config_path = temp_dir.path().join("wrap.toml");
let mut config = WrapConfig::default();
config.mode = WrapMode::Blocking;
config.add_command("custom-ai".to_string());
config.verbose = true;
config.save_to_path(&config_path)?;
let loaded = WrapConfig::load_from_path(&config_path)?;
assert_eq!(loaded.mode, WrapMode::Blocking);
assert!(loaded.should_wrap("custom-ai"));
assert!(loaded.verbose);
Ok(())
}
#[test]
fn test_mode_serialization() -> Result<()> {
let warning_toml = r#"mode = "warning""#;
let config: WrapConfig = toml::from_str(warning_toml)?;
assert_eq!(config.mode, WrapMode::Warning);
let blocking_toml = r#"mode = "blocking""#;
let config: WrapConfig = toml::from_str(blocking_toml)?;
assert_eq!(config.mode, WrapMode::Blocking);
Ok(())
}
}