use anyhow::{bail, Result};
use clap::Args;
use colored::Colorize;
use serde::Serialize;
use super::OutputConfig;
use crate::commands::{self, CommandDef};
#[derive(Args)]
pub struct RunArgs {
name: Option<String>,
#[arg(long, short = 'l')]
list: bool,
#[arg(long)]
save: Option<String>,
#[arg(long)]
remove: Option<String>,
#[arg(long)]
show: Option<String>,
#[arg(long)]
init: bool,
#[arg(long, short = 'd')]
description: Option<String>,
#[arg(long, default_value = ".")]
path: std::path::PathBuf,
#[arg(last = true)]
extra_args: Vec<String>,
}
#[derive(Serialize)]
struct CommandListOutput {
count: usize,
commands: Vec<CommandListEntry>,
}
#[derive(Serialize)]
struct CommandListEntry {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
command: String,
args: Vec<String>,
expands_to: String,
}
pub enum RunResult {
Execute(Vec<String>),
Done,
}
pub fn resolve(args: RunArgs, output: &OutputConfig) -> Result<RunResult> {
let repo_root = args
.path
.canonicalize()
.unwrap_or_else(|_| args.path.clone());
if args.list {
list_commands(&repo_root, output)?;
return Ok(RunResult::Done);
}
if args.init {
init_defaults(&repo_root, output)?;
return Ok(RunResult::Done);
}
if let Some(name) = &args.show {
show_command(&repo_root, name, output)?;
return Ok(RunResult::Done);
}
if let Some(name) = &args.remove {
remove_command(&repo_root, name, output)?;
return Ok(RunResult::Done);
}
if let Some(name) = &args.save {
save_command(
&repo_root,
name,
args.description.as_deref(),
&args.extra_args,
output,
)?;
return Ok(RunResult::Done);
}
if let Some(name) = &args.name {
let full_args = build_command_args(name, &repo_root, &args.extra_args, output)?;
return Ok(RunResult::Execute(full_args));
}
bail!(
"No command specified. Usage:\n \
bobbin run <name> Execute a saved command\n \
bobbin run --list List all saved commands\n \
bobbin run --init Initialize default commands\n \
bobbin run --save NAME -- CMD Save a new command\n \
bobbin run --show NAME Show command definition\n \
bobbin run --remove NAME Remove a command"
);
}
fn list_commands(repo_root: &std::path::Path, output: &OutputConfig) -> Result<()> {
let commands = commands::load_commands(repo_root)?;
if output.json {
let entries: Vec<CommandListEntry> = commands
.iter()
.map(|(name, def)| CommandListEntry {
name: name.clone(),
description: def.description.clone(),
command: def.command.clone(),
args: def.args.clone(),
expands_to: expand_command(def),
})
.collect();
let list_output = CommandListOutput {
count: entries.len(),
commands: entries,
};
println!("{}", serde_json::to_string_pretty(&list_output)?);
return Ok(());
}
if commands.is_empty() {
if !output.quiet {
println!(
"{} No commands defined. Save one with: bobbin run --save NAME -- COMMAND [ARGS...]",
"!".yellow()
);
}
return Ok(());
}
if !output.quiet {
println!(
"{} {} saved command{}:",
"✓".green(),
commands.len(),
if commands.len() == 1 { "" } else { "s" }
);
println!();
for (name, def) in &commands {
let desc = def
.description
.as_deref()
.map(|d| format!(" — {}", d.dimmed()))
.unwrap_or_default();
println!(" {}{}", name.bold(), desc);
println!(" {}", expand_command(def).dimmed());
println!();
}
}
Ok(())
}
fn show_command(repo_root: &std::path::Path, name: &str, output: &OutputConfig) -> Result<()> {
let commands = commands::load_commands(repo_root)?;
let Some(def) = commands.get(name) else {
bail!(
"Command '{}' not found. Run `bobbin run --list` to see available commands.",
name
);
};
if output.json {
let entry = CommandListEntry {
name: name.to_string(),
description: def.description.clone(),
command: def.command.clone(),
args: def.args.clone(),
expands_to: expand_command(def),
};
println!("{}", serde_json::to_string_pretty(&entry)?);
return Ok(());
}
if !output.quiet {
let desc = def
.description
.as_deref()
.map(|d| format!(" — {}", d))
.unwrap_or_default();
println!("{}{}", name.bold(), desc);
println!(" Expands to: {}", expand_command(def).cyan());
}
Ok(())
}
fn save_command(
repo_root: &std::path::Path,
name: &str,
description: Option<&str>,
extra_args: &[String],
output: &OutputConfig,
) -> Result<()> {
if extra_args.is_empty() {
bail!(
"No command specified. Usage: bobbin run --save {} -- COMMAND [ARGS...]\n\
Example: bobbin run --save find-tests -- search --type function test",
name
);
}
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
bail!("Command name must contain only alphanumeric characters, hyphens, and underscores");
}
let command = extra_args[0].clone();
let args = extra_args[1..].to_vec();
if command == "run" {
bail!("Cannot save 'run' as the underlying command (would cause recursion)");
}
validate_subcommand(&command)?;
let mut commands = commands::load_commands(repo_root)?;
let is_update = commands.contains_key(name);
commands.insert(
name.to_string(),
CommandDef {
description: description.map(|s| s.to_string()),
command,
args,
},
);
commands::save_commands(repo_root, &commands)?;
if output.json {
let entry = &commands[name];
let json_entry = CommandListEntry {
name: name.to_string(),
description: entry.description.clone(),
command: entry.command.clone(),
args: entry.args.clone(),
expands_to: expand_command(entry),
};
println!("{}", serde_json::to_string_pretty(&json_entry)?);
} else if !output.quiet {
let verb = if is_update { "Updated" } else { "Saved" };
println!(
"{} {} command '{}': {}",
"✓".green(),
verb,
name.bold(),
expand_command(&commands[name]).cyan()
);
}
Ok(())
}
fn remove_command(repo_root: &std::path::Path, name: &str, output: &OutputConfig) -> Result<()> {
let mut commands = commands::load_commands(repo_root)?;
if commands.remove(name).is_none() {
bail!(
"Command '{}' not found. Run `bobbin run --list` to see available commands.",
name
);
}
commands::save_commands(repo_root, &commands)?;
if output.json {
println!(r#"{{"removed": "{}"}}"#, name);
} else if !output.quiet {
println!("{} Removed command '{}'", "✓".green(), name.bold());
}
Ok(())
}
pub fn init_defaults(repo_root: &std::path::Path, output: &OutputConfig) -> Result<()> {
let mut commands = commands::load_commands(repo_root)?;
let mut added = 0;
let defaults: Vec<(&str, CommandDef)> = vec![
(
"find-code",
CommandDef {
description: Some("Search for code (functions, structs, classes)".into()),
command: "search".into(),
args: vec!["--type".into(), "function".into()],
},
),
(
"find-config",
CommandDef {
description: Some("Search config files (toml, yaml, json)".into()),
command: "grep".into(),
args: vec!["--type".into(), "other".into()],
},
),
(
"find-docs",
CommandDef {
description: Some("Search documentation sections".into()),
command: "search".into(),
args: vec!["--type".into(), "section".into()],
},
),
(
"find-structs",
CommandDef {
description: Some("Search for struct/type definitions".into()),
command: "search".into(),
args: vec!["--type".into(), "struct".into()],
},
),
(
"recent-changes",
CommandDef {
description: Some("Show most-changed files (hotspots)".into()),
command: "hotspots".into(),
args: vec!["--limit".into(), "15".into()],
},
),
(
"what-changed",
CommandDef {
description: Some("Search commit history semantically".into()),
command: "log".into(),
args: vec![],
},
),
];
for (name, def) in defaults {
if !commands.contains_key(name) {
commands.insert(name.to_string(), def);
added += 1;
}
}
if added > 0 {
commands::save_commands(repo_root, &commands)?;
}
if output.json {
let entries: Vec<CommandListEntry> = commands
.iter()
.map(|(name, def)| CommandListEntry {
name: name.clone(),
description: def.description.clone(),
command: def.command.clone(),
args: def.args.clone(),
expands_to: expand_command(def),
})
.collect();
let list_output = CommandListOutput {
count: entries.len(),
commands: entries,
};
println!("{}", serde_json::to_string_pretty(&list_output)?);
} else if !output.quiet {
if added > 0 {
println!(
"{} Initialized {} default command{} ({} total)",
"✓".green(),
added,
if added == 1 { "" } else { "s" },
commands.len()
);
println!();
for (name, def) in &commands {
let desc = def
.description
.as_deref()
.map(|d| format!(" — {}", d.dimmed()))
.unwrap_or_default();
println!(" {}{}", name.bold(), desc);
println!(" {}", expand_command(def).dimmed());
println!();
}
println!("{}", "Run any command: bobbin <name> <query>".dimmed());
} else {
println!(
"{} All default commands already exist ({} total)",
"✓".green(),
commands.len()
);
}
}
Ok(())
}
fn build_command_args(
name: &str,
repo_root: &std::path::Path,
extra_args: &[String],
output: &OutputConfig,
) -> Result<Vec<String>> {
let commands = commands::load_commands(repo_root)?;
let Some(def) = commands.get(name) else {
bail!(
"Command '{}' not found. Run `bobbin run --list` to see available commands.",
name
);
};
let mut full_args = vec!["bobbin".to_string()];
if output.json {
full_args.push("--json".to_string());
}
if output.quiet {
full_args.push("--quiet".to_string());
}
if output.verbose {
full_args.push("--verbose".to_string());
}
if let Some(ref server) = output.server {
full_args.push("--server".to_string());
full_args.push(server.clone());
}
full_args.push(def.command.clone());
full_args.extend(def.args.iter().cloned());
full_args.extend(extra_args.iter().cloned());
Ok(full_args)
}
fn expand_command(def: &CommandDef) -> String {
let mut parts = vec![format!("bobbin {}", def.command)];
for arg in &def.args {
if arg.contains(' ') {
parts.push(format!("\"{}\"", arg));
} else {
parts.push(arg.clone());
}
}
parts.join(" ")
}
fn validate_subcommand(name: &str) -> Result<()> {
const VALID_COMMANDS: &[&str] = &[
"init",
"index",
"search",
"context",
"deps",
"grep",
"refs",
"related",
"history",
"log",
"hotspots",
"impact",
"review",
"similar",
"status",
"serve",
"benchmark",
"watch",
"completions",
"hook",
"tour",
"prime",
"feedback",
];
if VALID_COMMANDS.contains(&name) {
Ok(())
} else {
bail!(
"Unknown bobbin command '{}'. Valid commands: {}",
name,
VALID_COMMANDS.join(", ")
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_command_basic() {
let def = CommandDef {
description: None,
command: "search".into(),
args: vec!["--type".into(), "function".into(), "test".into()],
};
assert_eq!(expand_command(&def), "bobbin search --type function test");
}
#[test]
fn test_expand_command_no_args() {
let def = CommandDef {
description: None,
command: "status".into(),
args: vec![],
};
assert_eq!(expand_command(&def), "bobbin status");
}
#[test]
fn test_expand_command_quoted_args() {
let def = CommandDef {
description: None,
command: "search".into(),
args: vec!["hello world".into()],
};
assert_eq!(expand_command(&def), "bobbin search \"hello world\"");
}
#[test]
fn test_validate_subcommand_valid() {
assert!(validate_subcommand("search").is_ok());
assert!(validate_subcommand("hotspots").is_ok());
assert!(validate_subcommand("context").is_ok());
assert!(validate_subcommand("status").is_ok());
}
#[test]
fn test_validate_subcommand_invalid() {
assert!(validate_subcommand("unknown").is_err());
assert!(validate_subcommand("run").is_err());
assert!(validate_subcommand("").is_err());
}
#[test]
fn test_validate_subcommand_rejects_run() {
assert!(validate_subcommand("run").is_err());
}
#[test]
fn test_list_commands_empty_json() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".bobbin")).unwrap();
let output = OutputConfig {
json: true,
quiet: false,
verbose: false,
server: None,
role: "default".to_string(),
};
let result = list_commands(tmp.path(), &output);
assert!(result.is_ok());
}
#[test]
fn test_save_and_show_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".bobbin")).unwrap();
let output = OutputConfig {
json: false,
quiet: true,
verbose: false,
server: None,
role: "default".to_string(),
};
let extra = vec![
"search".into(),
"--type".into(),
"function".into(),
"test".into(),
];
save_command(
tmp.path(),
"find-tests",
Some("Find tests"),
&extra,
&output,
)
.unwrap();
let commands = commands::load_commands(tmp.path()).unwrap();
assert_eq!(commands.len(), 1);
assert_eq!(commands["find-tests"].command, "search");
assert_eq!(
commands["find-tests"].args,
vec!["--type", "function", "test"]
);
assert_eq!(
commands["find-tests"].description.as_deref(),
Some("Find tests")
);
}
#[test]
fn test_save_rejects_invalid_name() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".bobbin")).unwrap();
let output = OutputConfig {
json: false,
quiet: true,
verbose: false,
server: None,
role: "default".to_string(),
};
let extra = vec!["search".into(), "test".into()];
let result = save_command(tmp.path(), "bad name!", None, &extra, &output);
assert!(result.is_err());
}
#[test]
fn test_save_rejects_run_command() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".bobbin")).unwrap();
let output = OutputConfig {
json: false,
quiet: true,
verbose: false,
server: None,
role: "default".to_string(),
};
let extra = vec!["run".into(), "other".into()];
let result = save_command(tmp.path(), "recursive", None, &extra, &output);
assert!(result.is_err());
}
#[test]
fn test_save_rejects_unknown_command() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".bobbin")).unwrap();
let output = OutputConfig {
json: false,
quiet: true,
verbose: false,
server: None,
role: "default".to_string(),
};
let extra = vec!["nonexistent".into(), "arg".into()];
let result = save_command(tmp.path(), "bad-cmd", None, &extra, &output);
assert!(result.is_err());
}
#[test]
fn test_remove_nonexistent() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".bobbin")).unwrap();
let output = OutputConfig {
json: false,
quiet: true,
verbose: false,
server: None,
role: "default".to_string(),
};
let result = remove_command(tmp.path(), "nonexistent", &output);
assert!(result.is_err());
}
#[test]
fn test_remove_existing() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".bobbin")).unwrap();
let output = OutputConfig {
json: false,
quiet: true,
verbose: false,
server: None,
role: "default".to_string(),
};
let extra = vec!["search".into(), "test".into()];
save_command(tmp.path(), "my-cmd", None, &extra, &output).unwrap();
remove_command(tmp.path(), "my-cmd", &output).unwrap();
let commands = commands::load_commands(tmp.path()).unwrap();
assert!(commands.is_empty());
}
}