use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::cli::{AliasAction, AskArgs};
use crate::profile::{self, Pool};
pub const BUILTIN_SUBCOMMANDS: &[&str] = &[
"history", "last", "profile", "cost", "skill", "agent", "alias", "help",
];
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Alias {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
}
pub fn run(action: AliasAction) -> Result<()> {
match action {
AliasAction::List => run_list(),
AliasAction::Show { name } => run_show(&name),
AliasAction::Path => run_path(),
}
}
fn run_list() -> Result<()> {
let pool = profile::load_pool()?;
if pool.aliases.is_empty() {
eprintln!("no aliases defined");
if pool.sources.is_empty() {
eprintln!("hint: add an `[alias.NAME]` section to a roba.toml");
} else {
eprintln!("sources checked:");
for s in &pool.sources {
eprintln!(" {}", s.display());
}
}
return Ok(());
}
let mut names: Vec<&String> = pool.aliases.keys().collect();
names.sort();
let name_w = names.iter().map(|n| n.len()).max().unwrap_or(4).max(4);
let desc_w = names
.iter()
.map(|n| pool.aliases[*n].description.as_deref().unwrap_or("").len())
.max()
.unwrap_or(11)
.max(11);
println!("{:<name_w$} {:<desc_w$} AGENT", "NAME", "DESCRIPTION");
for name in names {
let alias = &pool.aliases[name];
let desc = alias.description.as_deref().unwrap_or("");
let agent = alias.agent.as_deref().unwrap_or("-");
println!("{name:<name_w$} {desc:<desc_w$} {agent}");
}
Ok(())
}
fn run_show(name: &str) -> Result<()> {
let pool = profile::load_pool()?;
let alias = pool
.aliases
.get(name)
.ok_or_else(|| anyhow::anyhow!(unknown_alias_message(name, &pool)))?;
print!("{}", render_alias_toml(name, alias)?);
if !alias.args.is_empty() {
println!();
println!("# positional schema: {}", alias.args.join(", "));
}
if let Some(template) = &alias.template {
println!();
println!("# expansion preview (variables as <placeholders>, shell left unexpanded):");
print!("{}", preview_template(template, &alias.args));
println!();
}
Ok(())
}
fn run_path() -> Result<()> {
let pool = profile::load_pool()?;
let user = profile::user_config_path();
println!(
"user: {}",
user.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(none)".to_string())
);
let cwd = std::env::current_dir().unwrap_or_default();
let project = profile::discover_project_configs(&cwd);
if project.is_empty() {
println!("project: (none found above {})", cwd.display());
} else {
for (i, p) in project.iter().enumerate() {
let label = if i == 0 { "project:" } else { " " };
println!("{label} {}", p.display());
}
}
if !pool.sources.is_empty() {
println!();
println!(
"loaded {} source(s); {} alias(es) defined:",
pool.sources.len(),
pool.aliases.len()
);
for s in &pool.sources {
println!(" {}", s.display());
}
}
Ok(())
}
fn render_alias_toml(name: &str, alias: &Alias) -> Result<String> {
use std::collections::HashMap;
let mut wrapper: HashMap<String, HashMap<String, Alias>> = HashMap::new();
let mut inner: HashMap<String, Alias> = HashMap::new();
inner.insert(name.to_string(), alias.clone());
wrapper.insert("alias".to_string(), inner);
toml::to_string_pretty(&wrapper).context("re-serializing alias")
}
pub fn bare_alias_candidate(ask: &AskArgs) -> Result<Option<String>> {
let Some(prompt) = ask.prompt.as_deref() else {
return Ok(None);
};
if prompt.is_empty() || prompt.chars().any(char::is_whitespace) {
return Ok(None);
}
if ask.file.is_some() || ask.editor {
return Ok(None);
}
let pool = profile::load_pool()?;
if pool.aliases.contains_key(prompt) {
Ok(Some(prompt.to_string()))
} else {
Ok(None)
}
}
pub fn trailing_args_from_env(name: &str) -> Vec<String> {
let all: Vec<String> = std::env::args().skip(1).collect();
match all.iter().position(|a| a == name) {
Some(pos) => all[pos + 1..].to_vec(),
None => Vec::new(),
}
}
pub async fn dispatch_alias(name: &str, raw_args: &[String]) -> Result<()> {
use clap::Parser;
let pool = profile::load_pool()?;
let alias = match pool.aliases.get(name) {
Some(a) => a.clone(),
None => bail!(unknown_alias_message(name, &pool)),
};
let (positional, user_flags) = split_positional_flags(raw_args);
let prompt = match &alias.template {
Some(template) => expand_template(template, &alias.args, &positional)?,
None => positional.join(" "),
};
let mut argv: Vec<String> = vec!["roba".to_string()];
argv.extend(alias.flags.iter().cloned());
if let Some(agent) = &alias.agent {
argv.push("--agent".to_string());
argv.push(agent.clone());
}
argv.extend(user_flags.iter().cloned());
if !prompt.is_empty() {
argv.push("--".to_string());
argv.push(prompt);
}
let cli = crate::cli::Cli::try_parse_from(&argv)
.with_context(|| format!("expanding alias `{name}`"))?;
crate::run_ask(cli.ask).await
}
fn split_positional_flags(raw: &[String]) -> (Vec<String>, Vec<String>) {
let split = raw
.iter()
.position(|a| a.starts_with('-'))
.unwrap_or(raw.len());
(raw[..split].to_vec(), raw[split..].to_vec())
}
pub fn expand_template(template: &str, schema: &[String], args: &[String]) -> Result<String> {
let chars: Vec<char> = template.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '$' && i + 1 < chars.len() {
match chars[i + 1] {
'$' => {
out.push('$');
i += 2;
continue;
}
'{' => {
if let Some(close) = find_char(&chars, i + 2, '}') {
let name: String = chars[i + 2..close].iter().collect();
out.push_str(&resolve_var(&name, schema, args));
i = close + 1;
continue;
}
}
'(' => {
if let Some(close) = find_matching_paren(&chars, i + 1) {
let cmd: String = chars[i + 2..close].iter().collect();
out.push_str(&run_shell(&cmd)?);
i = close + 1;
continue;
}
}
_ => {}
}
}
out.push(chars[i]);
i += 1;
}
Ok(out)
}
fn resolve_var(name: &str, schema: &[String], args: &[String]) -> String {
if name == "@" {
return args.join(" ");
}
if let Ok(n) = name.parse::<usize>()
&& n >= 1
{
return args.get(n - 1).cloned().unwrap_or_default();
}
if let Some(idx) = schema.iter().position(|s| s == name) {
return args.get(idx).cloned().unwrap_or_default();
}
String::new()
}
fn run_shell(cmd: &str) -> Result<String> {
let output = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.output()
.with_context(|| format!("running shell substitution `$({cmd})`"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("shell substitution `$({cmd})` failed: {}", stderr.trim());
}
let mut s = String::from_utf8_lossy(&output.stdout).into_owned();
if s.ends_with('\n') {
s.pop();
if s.ends_with('\r') {
s.pop();
}
}
Ok(s)
}
fn find_char(chars: &[char], from: usize, target: char) -> Option<usize> {
(from..chars.len()).find(|&i| chars[i] == target)
}
fn find_matching_paren(chars: &[char], open: usize) -> Option<usize> {
let mut depth = 0usize;
for (offset, &c) in chars.iter().enumerate().skip(open) {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(offset);
}
}
_ => {}
}
}
None
}
fn preview_template(template: &str, schema: &[String]) -> String {
let chars: Vec<char> = template.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '$' && i + 1 < chars.len() {
match chars[i + 1] {
'$' => {
out.push('$');
i += 2;
continue;
}
'{' => {
if let Some(close) = find_char(&chars, i + 2, '}') {
let name: String = chars[i + 2..close].iter().collect();
let _ = schema; out.push('<');
out.push_str(if name == "@" { "args..." } else { &name });
out.push('>');
i = close + 1;
continue;
}
}
_ => {}
}
}
out.push(chars[i]);
i += 1;
}
out
}
fn unknown_alias_message(name: &str, pool: &Pool) -> String {
let mut candidates: Vec<String> = BUILTIN_SUBCOMMANDS.iter().map(|s| s.to_string()).collect();
candidates.extend(pool.aliases.keys().cloned());
let mut scored: Vec<(usize, String)> = candidates
.into_iter()
.map(|c| (levenshtein(name, &c), c))
.collect();
scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let suggestions: Vec<String> = scored
.into_iter()
.filter(|(d, _)| *d <= 3)
.take(3)
.map(|(_, c)| c)
.collect();
if suggestions.is_empty() {
format!("no built-in or alias named `{name}`")
} else {
format!(
"no built-in or alias named `{name}`; did you mean: {}?",
suggestions.join(", ")
)
}
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr = vec![0usize; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|x| x.to_string()).collect()
}
#[test]
fn expand_template_substitutes_positional() {
let out = expand_template("PR #${1} and ${2}", &[], &s(&["42", "main"])).unwrap();
assert_eq!(out, "PR #42 and main");
}
#[test]
fn expand_template_substitutes_named() {
let out = expand_template("PR #${pr}", &s(&["pr"]), &s(&["42"])).unwrap();
assert_eq!(out, "PR #42");
}
#[test]
fn expand_template_substitutes_at() {
let out = expand_template("review ${@}", &[], &s(&["a", "b", "c"])).unwrap();
assert_eq!(out, "review a b c");
}
#[test]
fn expand_template_escapes_dollar() {
let out = expand_template("cost is $$5.00", &[], &[]).unwrap();
assert_eq!(out, "cost is $5.00");
}
#[test]
fn expand_template_passes_lone_dollar_through() {
let out = expand_template("price $5 each", &[], &[]).unwrap();
assert_eq!(out, "price $5 each");
}
#[test]
fn expand_template_unknown_var_is_empty() {
let out = expand_template("[${nope}]", &[], &s(&["x"])).unwrap();
assert_eq!(out, "[]");
}
#[test]
fn expand_template_out_of_range_positional_is_empty() {
let out = expand_template("[${3}]", &[], &s(&["only-one"])).unwrap();
assert_eq!(out, "[]");
}
#[test]
fn expand_template_runs_shell_substitution() {
let out = expand_template("got: $(echo foo)", &[], &[]).unwrap();
assert_eq!(out, "got: foo");
}
#[test]
fn expand_template_shell_substitution_uses_args() {
let out = expand_template("$(echo $(echo nested))", &[], &[]).unwrap();
assert_eq!(out, "nested");
}
#[test]
fn expand_template_failing_shell_errors() {
let err = expand_template("$(exit 3)", &[], &[]).unwrap_err();
assert!(format!("{err:#}").contains("shell substitution"));
}
#[test]
fn split_positional_flags_splits_at_first_dash() {
let (pos, flags) = split_positional_flags(&s(&["42", "main", "--readonly", "x"]));
assert_eq!(pos, s(&["42", "main"]));
assert_eq!(flags, s(&["--readonly", "x"]));
}
#[test]
fn split_positional_flags_all_positional() {
let (pos, flags) = split_positional_flags(&s(&["a", "b"]));
assert_eq!(pos, s(&["a", "b"]));
assert!(flags.is_empty());
}
#[test]
fn split_positional_flags_all_flags() {
let (pos, flags) = split_positional_flags(&s(&["--full-auto"]));
assert!(pos.is_empty());
assert_eq!(flags, s(&["--full-auto"]));
}
#[test]
fn preview_template_shows_placeholders() {
let out = preview_template("PR #${pr}: ${@} cost $$5 $(gh pr diff ${pr})", &s(&["pr"]));
assert_eq!(out, "PR #<pr>: <args...> cost $5 $(gh pr diff <pr>)");
}
#[test]
fn levenshtein_basic() {
assert_eq!(levenshtein("review", "reveiw"), 2);
assert_eq!(levenshtein("cost", "cost"), 0);
assert_eq!(levenshtein("", "abc"), 3);
}
#[test]
fn unknown_alias_suggests_close_match() {
let mut pool = Pool::default();
pool.aliases.insert("review".to_string(), Alias::default());
let msg = unknown_alias_message("reveiw", &pool);
assert!(msg.contains("no built-in or alias named `reveiw`"), "{msg}");
assert!(msg.contains("review"), "{msg}");
}
#[test]
fn unknown_alias_no_close_match_omits_suggestions() {
let pool = Pool::default();
let msg = unknown_alias_message("zzzzzzzz", &pool);
assert_eq!(msg, "no built-in or alias named `zzzzzzzz`");
}
#[test]
fn render_alias_toml_round_trips() {
let alias = Alias {
description: Some("Review a PR".to_string()),
agent: Some("reviewer".to_string()),
template: Some("PR #${pr}".to_string()),
flags: s(&["--readonly"]),
args: s(&["pr"]),
};
let rendered = render_alias_toml("review", &alias).unwrap();
assert!(rendered.contains("[alias.review]"));
assert!(rendered.contains("reviewer"));
assert!(rendered.contains("--readonly"));
}
}