use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandDef {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
pub type CommandsMap = BTreeMap<String, CommandDef>;
pub fn load_commands(repo_root: &Path) -> Result<CommandsMap> {
let path = commands_path(repo_root);
if !path.exists() {
return Ok(BTreeMap::new());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read commands file: {}", path.display()))?;
toml::from_str(&content)
.with_context(|| format!("Failed to parse commands file: {}", path.display()))
}
pub fn save_commands(repo_root: &Path, commands: &CommandsMap) -> Result<()> {
let path = commands_path(repo_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let content = toml::to_string_pretty(commands).context("Failed to serialize commands")?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write commands file: {}", path.display()))
}
pub fn commands_path(repo_root: &Path) -> std::path::PathBuf {
repo_root.join(".bobbin").join("commands.toml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_def_serialize() {
let cmd = CommandDef {
description: Some("Find test functions".into()),
command: "search".into(),
args: vec!["--type".into(), "function".into(), "test".into()],
};
let toml_str = toml::to_string_pretty(&cmd).unwrap();
assert!(toml_str.contains("description = \"Find test functions\""));
assert!(toml_str.contains("command = \"search\""));
}
#[test]
fn test_command_def_deserialize() {
let toml_str = r#"
description = "Find test functions"
command = "search"
args = ["--type", "function", "test"]
"#;
let cmd: CommandDef = toml::from_str(toml_str).unwrap();
assert_eq!(cmd.description.as_deref(), Some("Find test functions"));
assert_eq!(cmd.command, "search");
assert_eq!(cmd.args, vec!["--type", "function", "test"]);
}
#[test]
fn test_command_def_no_description() {
let toml_str = r#"
command = "hotspots"
args = ["--limit", "20"]
"#;
let cmd: CommandDef = toml::from_str(toml_str).unwrap();
assert!(cmd.description.is_none());
assert_eq!(cmd.command, "hotspots");
}
#[test]
fn test_command_def_no_args() {
let toml_str = r#"
command = "status"
"#;
let cmd: CommandDef = toml::from_str(toml_str).unwrap();
assert_eq!(cmd.command, "status");
assert!(cmd.args.is_empty());
}
#[test]
fn test_commands_map_roundtrip() {
let mut commands = CommandsMap::new();
commands.insert(
"find-tests".into(),
CommandDef {
description: Some("Find test functions".into()),
command: "search".into(),
args: vec!["--type".into(), "function".into(), "test".into()],
},
);
commands.insert(
"api-hotspots".into(),
CommandDef {
description: None,
command: "hotspots".into(),
args: vec!["--limit".into(), "20".into()],
},
);
let toml_str = toml::to_string_pretty(&commands).unwrap();
let parsed: CommandsMap = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed["find-tests"].command, "search");
assert_eq!(parsed["api-hotspots"].command, "hotspots");
}
#[test]
fn test_load_nonexistent_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let commands = load_commands(tmp.path()).unwrap();
assert!(commands.is_empty());
}
#[test]
fn test_save_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".bobbin")).unwrap();
let mut commands = CommandsMap::new();
commands.insert(
"my-cmd".into(),
CommandDef {
description: Some("Test command".into()),
command: "search".into(),
args: vec!["hello".into()],
},
);
save_commands(tmp.path(), &commands).unwrap();
let loaded = load_commands(tmp.path()).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded["my-cmd"].command, "search");
assert_eq!(loaded["my-cmd"].args, vec!["hello"]);
}
}