use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub mod parser;
pub mod discovery;
pub mod manager;
pub use parser::CommandParser;
pub use discovery::CommandDiscovery;
pub use manager::CommandManagerImpl;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomCommand {
pub id: String,
pub name: String,
pub description: Option<String>,
pub content: String,
pub parameters: Vec<CommandParameter>,
pub source_file: PathBuf,
pub command_type: CommandType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandParameter {
pub name: String,
pub description: Option<String>,
pub required: bool,
pub default_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum CommandType {
User,
Project,
}
#[derive(Debug, Clone)]
pub struct CommandExecution {
pub command: CustomCommand,
pub arguments: HashMap<String, String>,
pub resolved_content: String,
}
#[derive(Debug, thiserror::Error)]
pub enum CommandError {
#[error("File error: {0}")]
FileError(String),
#[error("Parse error: {0}")]
ParseError(String),
#[error("Missing required parameter: {0}")]
MissingParameter(String),
#[error("Command not found: {0}")]
CommandNotFound(String),
#[error("Invalid parameter value: {0}")]
InvalidParameter(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Regex error: {0}")]
RegexError(#[from] regex::Error),
}
#[async_trait]
pub trait CommandManager: Send + Sync {
async fn discover_commands(&self) -> Result<Vec<CustomCommand>, CommandError>;
async fn get_command(&self, command_id: &str) -> Result<Option<CustomCommand>, CommandError>;
async fn execute_command(
&self,
command_id: &str,
arguments: HashMap<String, String>,
) -> Result<CommandExecution, CommandError>;
async fn list_commands(&self) -> Result<Vec<CustomCommand>, CommandError>;
async fn reload_commands(&self) -> Result<(), CommandError>;
}
#[derive(Debug, Clone)]
pub struct CommandConfig {
pub enabled: bool,
pub user_command_dirs: Vec<PathBuf>,
pub project_command_dir: String,
pub max_depth: usize,
pub auto_reload: bool,
}
impl Default for CommandConfig {
fn default() -> Self {
let mut user_dirs = Vec::new();
if let Some(config_dir) = dirs::config_dir() {
user_dirs.push(config_dir.join("coderlib").join("commands"));
}
if let Some(home_dir) = dirs::home_dir() {
user_dirs.push(home_dir.join(".coderlib").join("commands"));
}
Self {
enabled: true,
user_command_dirs: user_dirs,
project_command_dir: ".coderlib/commands".to_string(),
max_depth: 3,
auto_reload: true,
}
}
}
impl CommandConfig {
pub fn new() -> Self {
Self::default()
}
pub fn add_user_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
self.user_command_dirs.push(dir.into());
self
}
pub fn project_dir<S: Into<String>>(mut self, dir: S) -> Self {
self.project_command_dir = dir.into();
self
}
pub fn max_depth(mut self, depth: usize) -> Self {
self.max_depth = depth;
self
}
pub fn auto_reload(mut self, enabled: bool) -> Self {
self.auto_reload = enabled;
self
}
}
pub mod utils {
use super::*;
pub fn default_user_command_dir() -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join("coderlib").join("commands"))
}
pub fn project_command_dir() -> Result<PathBuf, CommandError> {
let current_dir = std::env::current_dir()
.map_err(|e| CommandError::FileError(e.to_string()))?;
Ok(current_dir.join(".coderlib").join("commands"))
}
pub fn is_command_file(path: &Path) -> bool {
path.is_file() &&
path.extension().map_or(false, |ext| ext == "md") &&
!path.file_name().map_or(true, |name| name.to_string_lossy().starts_with('.'))
}
pub fn generate_command_id(file_path: &Path, command_type: CommandType) -> Result<String, CommandError> {
let file_name = file_path.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| CommandError::ParseError("Invalid file name".to_string()))?;
let prefix = match command_type {
CommandType::User => "user",
CommandType::Project => "project",
};
Ok(format!("{}:{}", prefix, file_name))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_command_config_default() {
let config = CommandConfig::default();
assert!(config.enabled);
assert!(!config.user_command_dirs.is_empty());
assert_eq!(config.project_command_dir, ".coderlib/commands");
assert_eq!(config.max_depth, 3);
assert!(config.auto_reload);
}
#[test]
fn test_command_config_builder() {
let config = CommandConfig::new()
.add_user_dir("/custom/commands")
.project_dir("custom-commands")
.max_depth(5)
.auto_reload(false);
assert!(config.user_command_dirs.iter().any(|p| p == &PathBuf::from("/custom/commands")));
assert_eq!(config.project_command_dir, "custom-commands");
assert_eq!(config.max_depth, 5);
assert!(!config.auto_reload);
}
#[test]
fn test_utils_is_command_file() {
use tempfile::TempDir;
use std::fs::File;
let temp_dir = TempDir::new().unwrap();
let md_file = temp_dir.path().join("test.md");
let txt_file = temp_dir.path().join("test.txt");
let hidden_file = temp_dir.path().join(".hidden.md");
File::create(&md_file).unwrap();
File::create(&txt_file).unwrap();
File::create(&hidden_file).unwrap();
assert!(utils::is_command_file(&md_file));
assert!(!utils::is_command_file(&txt_file));
assert!(!utils::is_command_file(&hidden_file));
}
#[test]
fn test_utils_generate_command_id() {
let path = Path::new("hello-world.md");
let id = utils::generate_command_id(path, CommandType::User).unwrap();
assert_eq!(id, "user:hello-world");
let id = utils::generate_command_id(path, CommandType::Project).unwrap();
assert_eq!(id, "project:hello-world");
}
}