use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserCommand {
pub name: String,
pub description: String,
#[serde(default = "default_action")]
pub action: String,
pub prompt: String,
}
fn default_action() -> String {
"prompt".to_string()
}
#[derive(Debug, Serialize, Deserialize)]
struct CommandsFile {
#[serde(default)]
commands: Vec<UserCommand>,
}
pub struct CommandLoader {
toml_path: PathBuf,
json_path: PathBuf,
}
impl CommandLoader {
pub fn new(path: PathBuf) -> Self {
let json_path = path.with_extension("json");
Self {
toml_path: path,
json_path,
}
}
pub fn from_brain_path(brain_path: &std::path::Path) -> Self {
Self {
toml_path: brain_path.join("commands.toml"),
json_path: brain_path.join("commands.json"),
}
}
pub fn load(&self) -> Vec<UserCommand> {
if let Ok(content) = std::fs::read_to_string(&self.toml_path) {
match toml::from_str::<CommandsFile>(&content) {
Ok(file) => {
tracing::info!(
"Loaded {} user commands from {}",
file.commands.len(),
self.toml_path.display()
);
return file.commands;
}
Err(e) => {
tracing::warn!(
"Failed to parse commands.toml at {}: {}",
self.toml_path.display(),
e
);
}
}
}
if let Ok(content) = std::fs::read_to_string(&self.json_path) {
match serde_json::from_str::<Vec<UserCommand>>(&content) {
Ok(commands) => {
tracing::info!(
"Loaded {} user commands from legacy {} — migrating to TOML",
commands.len(),
self.json_path.display()
);
if let Err(e) = self.save(&commands) {
tracing::warn!("Failed to auto-migrate commands to TOML: {}", e);
} else {
tracing::info!("Migrated commands.json → {}", self.toml_path.display());
}
return commands;
}
Err(e) => {
tracing::warn!(
"Failed to parse commands.json at {}: {}",
self.json_path.display(),
e
);
}
}
}
tracing::debug!(
"No commands file found at {} (this is normal)",
self.toml_path.display()
);
Vec::new()
}
pub fn save(&self, commands: &[UserCommand]) -> Result<()> {
if let Some(parent) = self.toml_path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::config::daily_backup(&self.toml_path, 7);
let file = CommandsFile {
commands: commands.to_vec(),
};
let toml_str = toml::to_string_pretty(&file)?;
std::fs::write(&self.toml_path, toml_str)?;
tracing::info!(
"Saved {} user commands to {}",
commands.len(),
self.toml_path.display()
);
Ok(())
}
pub fn add_command(&self, command: UserCommand) -> Result<()> {
let mut commands = self.load();
if let Some(pos) = commands.iter().position(|c| c.name == command.name) {
commands[pos] = command;
} else {
commands.push(command);
}
self.save(&commands)
}
pub fn remove_command(&self, name: &str) -> Result<bool> {
let mut commands = self.load();
let len_before = commands.len();
commands.retain(|c| c.name != name);
let removed = commands.len() < len_before;
if removed {
self.save(&commands)?;
}
Ok(removed)
}
pub fn commands_section(builtin: &[(&str, &str)], user_commands: &[UserCommand]) -> String {
let mut section = String::new();
section.push_str("Built-in commands:\n");
for (name, desc) in builtin {
section.push_str(&format!(" {} — {}\n", name, desc));
}
if !user_commands.is_empty() {
section.push_str("\nUser-defined commands:\n");
for cmd in user_commands {
section.push_str(&format!(" {} — {}\n", cmd.name, cmd.description));
}
}
section
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_load_nonexistent() {
let loader = CommandLoader::new(PathBuf::from("/nonexistent/commands.toml"));
let commands = loader.load();
assert!(commands.is_empty());
}
#[test]
fn test_save_and_load_toml() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("commands.toml");
let loader = CommandLoader::new(path);
let commands = vec![
UserCommand {
name: "/deploy".to_string(),
description: "Deploy to staging".to_string(),
action: "prompt".to_string(),
prompt: "Run deploy.sh".to_string(),
},
UserCommand {
name: "/test".to_string(),
description: "Run tests".to_string(),
action: "prompt".to_string(),
prompt: "Run cargo test".to_string(),
},
];
loader.save(&commands).unwrap();
let loaded = loader.load();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].name, "/deploy");
assert_eq!(loaded[1].name, "/test");
}
#[test]
fn test_json_migration() {
let dir = TempDir::new().unwrap();
let json_path = dir.path().join("commands.json");
let toml_path = dir.path().join("commands.toml");
let commands = vec![UserCommand {
name: "/legacy".to_string(),
description: "Legacy command".to_string(),
action: "prompt".to_string(),
prompt: "do legacy stuff".to_string(),
}];
let json = serde_json::to_string_pretty(&commands).unwrap();
std::fs::write(&json_path, json).unwrap();
let loader = CommandLoader::new(toml_path.clone());
let loaded = loader.load();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "/legacy");
assert!(toml_path.exists());
let loaded2 = loader.load();
assert_eq!(loaded2.len(), 1);
}
#[test]
fn test_add_command() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("commands.toml");
let loader = CommandLoader::new(path);
loader
.add_command(UserCommand {
name: "/first".to_string(),
description: "First".to_string(),
action: "prompt".to_string(),
prompt: "first".to_string(),
})
.unwrap();
loader
.add_command(UserCommand {
name: "/second".to_string(),
description: "Second".to_string(),
action: "prompt".to_string(),
prompt: "second".to_string(),
})
.unwrap();
let loaded = loader.load();
assert_eq!(loaded.len(), 2);
loader
.add_command(UserCommand {
name: "/first".to_string(),
description: "Updated first".to_string(),
action: "prompt".to_string(),
prompt: "updated".to_string(),
})
.unwrap();
let loaded = loader.load();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].description, "Updated first");
}
#[test]
fn test_remove_command() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("commands.toml");
let loader = CommandLoader::new(path);
let commands = vec![
UserCommand {
name: "/keep".to_string(),
description: "Keep".to_string(),
action: "prompt".to_string(),
prompt: "keep".to_string(),
},
UserCommand {
name: "/remove".to_string(),
description: "Remove".to_string(),
action: "prompt".to_string(),
prompt: "remove".to_string(),
},
];
loader.save(&commands).unwrap();
let removed = loader.remove_command("/remove").unwrap();
assert!(removed);
let loaded = loader.load();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "/keep");
let removed = loader.remove_command("/nonexistent").unwrap();
assert!(!removed);
}
#[test]
fn test_commands_section() {
let builtin = vec![("/help", "Show help"), ("/models", "Switch model")];
let user = vec![UserCommand {
name: "/deploy".to_string(),
description: "Deploy".to_string(),
action: "prompt".to_string(),
prompt: "deploy".to_string(),
}];
let section = CommandLoader::commands_section(&builtin, &user);
assert!(section.contains("/help"));
assert!(section.contains("/deploy"));
assert!(section.contains("User-defined"));
}
}