use std::path::Path;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::cli::{AliasAction, AliasDraftArgs, AskArgs};
use crate::profile::{self, Pool};
pub fn builtin_subcommands() -> &'static [String] {
use clap::CommandFactory;
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
NAMES.get_or_init(|| {
let cmd = crate::cli::Cli::command();
let mut names: Vec<String> = Vec::new();
for sub in cmd.get_subcommands() {
names.push(sub.get_name().to_string());
names.extend(sub.get_visible_aliases().map(str::to_string));
}
names.push("help".to_string());
names.sort();
names.dedup();
names
})
}
pub fn is_builtin_subcommand(name: &str) -> bool {
builtin_subcommands().iter().any(|n| n == name)
}
#[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(),
AliasAction::Draft(_) => unreachable!("alias draft is dispatched via run_draft"),
}
}
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(());
}
print!("{}", render_alias_list(&pool.aliases));
Ok(())
}
fn render_alias_list(aliases: &std::collections::HashMap<String, Alias>) -> String {
let mut names: Vec<&String> = 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| aliases[*n].description.as_deref().unwrap_or("").len())
.max()
.unwrap_or(11)
.max(11);
let show_agent = aliases.values().any(|a| a.agent.is_some());
let mut out = String::new();
if show_agent {
out.push_str(&format!(
"{:<name_w$} {:<desc_w$} AGENT\n",
"NAME", "DESCRIPTION"
));
} else {
out.push_str(&format!("{:<name_w$} {}\n", "NAME", "DESCRIPTION"));
}
for name in names {
let alias = &aliases[name];
let desc = alias.description.as_deref().unwrap_or("");
if show_agent {
let agent = alias.agent.as_deref().unwrap_or("-");
out.push_str(&format!("{name:<name_w$} {desc:<desc_w$} {agent}\n"));
} else {
out.push_str(&format!("{name:<name_w$} {desc}\n"));
}
}
out
}
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 async fn run_draft(args: AliasDraftArgs) -> Result<()> {
let prompt = draft_prompt(&args.description);
let raw = crate::draft::generate(prompt, args.model.as_deref(), "roba: alias draft").await?;
let (name, alias) = parse_drafted_alias(&raw)?;
let block = render_alias_toml(&name, &alias)?;
let is_builtin = is_builtin_subcommand(&name);
match &args.write {
Some(target) => {
let path = match target {
Some(p) => p.clone(),
None => profile::user_config_path().ok_or_else(|| {
anyhow::anyhow!(
"--write: cannot locate your user config; pass an explicit path (`--write PATH`)"
)
})?,
};
if is_builtin {
bail!(
"alias `{name}` collides with a built-in subcommand and would be unreachable; pick another name"
);
}
if file_defines_alias(&path, &name)? {
bail!(
"{} already defines [alias.{name}]; refusing to append a duplicate (it would break the next config load)",
path.display()
);
}
crate::draft::append_block(&path, &block)?;
eprintln!("wrote [alias.{name}] to {}", path.display());
}
None => {
if is_builtin {
eprintln!(
"warning: alias `{name}` collides with a built-in subcommand; the built-in wins, so the alias would be unreachable"
);
} else if profile::load_pool()?.aliases.contains_key(&name) {
eprintln!(
"warning: alias `{name}` already exists in your config pool; this draft would shadow or duplicate it"
);
}
}
}
print!("{block}");
Ok(())
}
fn draft_prompt(description: &str) -> String {
let schema = alias_sample_section();
format!(
"You are generating a single roba alias definition in TOML.\n\n\
roba aliases are user-defined verbs. Here is the alias schema, \
documented by example -- this is the ONLY allowed shape, do not \
invent fields:\n\n\
{schema}\n\n\
The user wants an alias for: {description}\n\n\
Output requirements (follow exactly):\n\
- Produce EXACTLY ONE `[alias.NAME]` TOML block and nothing else.\n\
- Pick a short, memorable kebab-case or single-word NAME from the description.\n\
- Use ONLY the fields shown above (description, agent, template, flags, args).\n\
- The block must be valid TOML that parses against that schema.\n\
- Do NOT wrap the output in markdown code fences.\n\
- Do NOT include any prose, comments, or explanation -- only the TOML block."
)
}
fn alias_sample_section() -> String {
const SAMPLE: &str = crate::profile::STARTER_CONFIG_TOML;
let start = SAMPLE.find("# Aliases");
let end = SAMPLE.find("# Named sessions");
match (start, end) {
(Some(s), Some(e)) if s < e => SAMPLE[s..e].trim_end().to_string(),
_ => SAMPLE.trim_end().to_string(),
}
}
fn parse_drafted_alias(raw: &str) -> Result<(String, Alias)> {
use std::collections::HashMap;
#[derive(Deserialize)]
struct Wrapper {
#[serde(default)]
alias: HashMap<String, Alias>,
}
let cleaned = crate::draft::strip_code_fences(raw);
let wrapper: Wrapper = toml::from_str(&cleaned).map_err(|e| {
anyhow::anyhow!("drafted alias did not parse: {e}\n\n--- raw model output ---\n{raw}")
})?;
let mut entries: Vec<(String, Alias)> = wrapper.alias.into_iter().collect();
match entries.len() {
1 => Ok(entries.pop().expect("len checked == 1")),
0 => {
bail!("drafted output defined no [alias.NAME] block\n\n--- raw model output ---\n{raw}")
}
n => bail!(
"drafted output defined {n} alias blocks (expected exactly one)\n\n--- raw model output ---\n{raw}"
),
}
}
fn file_defines_alias(path: &Path, name: &str) -> Result<bool> {
use std::collections::HashMap;
#[derive(Deserialize)]
struct Probe {
#[serde(default)]
alias: HashMap<String, Alias>,
}
if !path.exists() {
return Ok(false);
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading --write target {}", path.display()))?;
let probe: Probe = toml::from_str(&text)
.with_context(|| format!("--write target {} is not valid TOML", path.display()))?;
Ok(probe.alias.contains_key(name))
}
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())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Ctx {
Prompt,
Shell,
}
pub fn expand_template(template: &str, schema: &[String], args: &[String]) -> Result<String> {
expand_in(template, schema, args, Ctx::Prompt)
}
fn expand_in(template: &str, schema: &[String], args: &[String], ctx: Ctx) -> 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();
match ctx {
Ctx::Prompt => out.push_str(&resolve_var(&name, schema, args)),
Ctx::Shell => out.push_str(&resolve_var_shell(&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();
let cmd = expand_in(&cmd, schema, args, Ctx::Shell)?;
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 resolve_var_shell(name: &str, schema: &[String], args: &[String]) -> String {
if name == "@" {
return args
.iter()
.map(|a| shell_quote(a))
.collect::<Vec<_>>()
.join(" ");
}
shell_quote(&resolve_var(name, schema, args))
}
fn shell_quote(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
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().to_vec();
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::*;
use std::collections::HashMap;
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 ${1})", &[], &["foo".into()]).unwrap();
assert_eq!(out, "foo");
}
#[test]
fn expand_template_shell_substitution_uses_named_args() {
let out = expand_template("$(echo pr=${pr})", &["pr".into()], &["42".into()]).unwrap();
assert_eq!(out, "pr=42");
}
#[test]
fn expand_template_shell_substitution_dollar_escape() {
let out = expand_template("$(X=hi; echo $$X)", &[], &[]).unwrap();
assert_eq!(out, "hi");
}
#[test]
fn expand_template_nested_parens_in_shell() {
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 shell_quote_wraps_plain_value() {
assert_eq!(shell_quote("244"), "'244'");
assert_eq!(shell_quote("hello world"), "'hello world'");
}
#[test]
fn shell_quote_empty_is_two_quotes() {
assert_eq!(shell_quote(""), "''");
}
#[test]
fn shell_quote_escapes_embedded_single_quote() {
assert_eq!(shell_quote("it's"), "'it'\\''s'");
}
#[test]
fn shell_quote_neutralizes_metacharacters() {
for raw in [
"X; rm -rf ~",
"a && b",
"`whoami`",
"$(whoami)",
"a | b",
"x > /etc/passwd",
"* .rs",
] {
let q = shell_quote(raw);
assert!(q.starts_with('\'') && q.ends_with('\''), "{q}");
assert_eq!(q, format!("'{}'", raw.replace('\'', "'\\''")), "{q}");
}
}
#[test]
fn shell_ctx_quotes_injection_payload_as_one_token() {
let payload = "X; touch /tmp/PWN; echo END";
let cmd = expand_in(
"echo got-${n}",
&s(&["n"]),
&[payload.to_string()],
Ctx::Shell,
)
.unwrap();
assert_eq!(cmd, "echo got-'X; touch /tmp/PWN; echo END'");
let outside_quotes: String = {
let mut keep = String::new();
let mut in_q = false;
for c in cmd.chars() {
if c == '\'' {
in_q = !in_q;
} else if !in_q {
keep.push(c);
}
}
keep
};
assert!(
!outside_quotes.contains(';'),
"unquoted `;`: {outside_quotes}"
);
}
#[test]
fn shell_ctx_quotes_various_metachar_args() {
for payload in ["a; b", "a && b", "`id`", "$(id)", "it's", "has space"] {
let cmd =
expand_in("run ${x}", &s(&["x"]), &[payload.to_string()], Ctx::Shell).unwrap();
assert_eq!(cmd, format!("run {}", shell_quote(payload)), "{cmd}");
}
}
#[test]
fn shell_ctx_quotes_each_at_arg_separately() {
let cmd = expand_in("cmd ${@}", &[], &s(&["a b", "c;d", "e"]), Ctx::Shell).unwrap();
assert_eq!(cmd, "cmd 'a b' 'c;d' 'e'");
}
#[test]
fn shell_ctx_legit_path_still_substitutes_quoted() {
let cmd = expand_in("gh pr diff ${pr}", &s(&["pr"]), &s(&["244"]), Ctx::Shell).unwrap();
assert_eq!(cmd, "gh pr diff '244'");
}
#[test]
fn shell_ctx_empty_var_quotes_to_empty_token() {
let cmd = expand_in("echo ${nope}", &[], &[], Ctx::Shell).unwrap();
assert_eq!(cmd, "echo ''");
}
#[test]
fn shell_ctx_dollar_escape_unchanged() {
let cmd = expand_in("echo $$HOME", &[], &[], Ctx::Shell).unwrap();
assert_eq!(cmd, "echo $HOME");
}
#[test]
fn expand_template_injection_payload_does_not_execute() {
let out = expand_template(
"$(echo got-${n})",
&s(&["n"]),
&["A; echo INJECTED".to_string()],
)
.unwrap();
assert_eq!(out, "got-A; echo INJECTED");
}
#[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 parse_drafted_alias_accepts_one_block() {
let (name, alias) =
parse_drafted_alias("[alias.echo]\ndescription = \"echo it\"\ntemplate = \"say ${@}\"")
.unwrap();
assert_eq!(name, "echo");
assert_eq!(alias.description.as_deref(), Some("echo it"));
assert_eq!(alias.template.as_deref(), Some("say ${@}"));
}
#[test]
fn parse_drafted_alias_strips_fences_first() {
let (name, _) =
parse_drafted_alias("```toml\n[alias.echo]\ndescription = \"e\"\n```").unwrap();
assert_eq!(name, "echo");
}
#[test]
fn parse_drafted_alias_rejects_zero_entries() {
let err = parse_drafted_alias("# nothing here").unwrap_err();
assert!(
format!("{err:#}").contains("no [alias.NAME] block"),
"{err:#}"
);
}
#[test]
fn parse_drafted_alias_rejects_two_entries() {
let raw = "[alias.a]\ndescription = \"a\"\n[alias.b]\ndescription = \"b\"";
let err = parse_drafted_alias(raw).unwrap_err();
assert!(format!("{err:#}").contains("2 alias blocks"), "{err:#}");
}
#[test]
fn parse_drafted_alias_rejects_unknown_field() {
let raw = "[alias.x]\ndescription = \"x\"\nmade_up_key = true";
let err = parse_drafted_alias(raw).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("did not parse"), "{msg}");
assert!(msg.contains("made_up_key"), "{msg}");
assert!(msg.contains("raw model output"), "{msg}");
}
#[test]
fn alias_sample_section_includes_schema_examples() {
let section = alias_sample_section();
assert!(section.contains("[alias.review]"), "got:\n{section}");
assert!(section.contains("${pr}"), "got:\n{section}");
assert!(!section.contains("Named sessions"), "got:\n{section}");
}
#[test]
fn file_defines_alias_detects_duplicate() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("roba.toml");
std::fs::write(
&path,
"[profile.x]\nreadonly = true\n\n[alias.review]\ndescription = \"r\"\n",
)
.unwrap();
assert!(file_defines_alias(&path, "review").unwrap());
assert!(!file_defines_alias(&path, "nope").unwrap());
}
#[test]
fn file_defines_alias_missing_file_is_false() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("absent.toml");
assert!(!file_defines_alias(&path, "anything").unwrap());
}
#[test]
fn builtin_collision_is_detectable() {
assert!(is_builtin_subcommand("history"));
assert!(!is_builtin_subcommand("my-custom-verb"));
}
#[test]
fn builtin_set_is_derived_from_the_clap_tree() {
for name in [
"history",
"last",
"cost",
"profile",
"alias",
"doctor",
"completions",
"worktree",
"show",
"config",
"help",
] {
assert!(
is_builtin_subcommand(name),
"derived builtin set is missing `{name}`"
);
}
assert!(!is_builtin_subcommand("skill"));
assert!(!is_builtin_subcommand("agent"));
assert!(!is_builtin_subcommand("external"));
}
#[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"));
}
#[test]
fn list_hides_agent_column_when_no_alias_pins_one() {
let mut aliases = HashMap::new();
aliases.insert(
"cm".to_string(),
Alias {
description: Some("Commit message".to_string()),
..Alias::default()
},
);
let out = render_alias_list(&aliases);
assert!(out.contains("NAME"), "got:\n{out}");
assert!(out.contains("DESCRIPTION"), "got:\n{out}");
assert!(!out.contains("AGENT"), "got:\n{out}");
assert!(!out.contains(" -"), "no stray agent dash: \n{out}");
}
#[test]
fn list_shows_agent_column_when_one_alias_pins_one() {
let mut aliases = HashMap::new();
aliases.insert(
"cm".to_string(),
Alias {
description: Some("Commit message".to_string()),
..Alias::default()
},
);
aliases.insert(
"review".to_string(),
Alias {
description: Some("Review a PR".to_string()),
agent: Some("reviewer".to_string()),
..Alias::default()
},
);
let out = render_alias_list(&aliases);
assert!(out.contains("AGENT"), "got:\n{out}");
assert!(out.contains("reviewer"), "got:\n{out}");
assert!(
out.lines()
.any(|l| l.starts_with("cm") && l.trim_end().ends_with('-')),
"agentless row should fill with `-`:\n{out}"
);
}
}