use std::path::Path;
use basis::{Template, TemplatesConfig, templates::invocation};
use crate::{cli::RunArgs, local::ClientError};
pub(crate) fn expand(args: RunArgs) -> Result<RunArgs, ClientError> {
if args.prompt == "-" {
return Ok(args);
}
let workspace = workspace_of(args.workspace.as_deref())?;
match resolve(&args.prompt, &workspace)? {
Some(prompt) => Ok(RunArgs { prompt, ..args }),
None => Ok(args),
}
}
pub(crate) fn resolve(prompt: &str, workspace: &Path) -> Result<Option<String>, ClientError> {
let Some((name, arguments)) = invocation(prompt) else {
return Ok(None);
};
let templates = basis::templates::load(workspace, &TemplatesConfig::default())
.map_err(|error| ClientError::new(format!("load templates: {error}")))?;
match templates.iter().find(|template| template.name == name) {
Some(template) => Ok(Some(template.render(arguments))),
None => Err(unknown(name, &templates)),
}
}
fn unknown(name: &str, templates: &[Template]) -> ClientError {
let available = if templates.is_empty() {
"this workspace defines none — a `.basis/templates/<name>.md` file declares one".to_string()
} else {
let names: Vec<&str> = templates
.iter()
.map(|template| template.name.as_str())
.collect();
format!("available: {}", names.join(", "))
};
ClientError::usage(format!(
"no template named `{name}` — {available}. A prompt that begins with a literal `/` goes in on stdin"
))
.pointing_at("basis spawn -")
}
fn workspace_of(requested: Option<&Path>) -> Result<std::path::PathBuf, ClientError> {
match requested {
Some(path) => Ok(path.to_path_buf()),
None => std::env::current_dir()
.map_err(|error| ClientError::new(format!("no working directory: {error}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn workspace(templates: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for (path, body) in templates {
let file = dir.path().join(".basis/templates").join(path);
std::fs::create_dir_all(file.parent().expect("parent")).expect("create dir");
std::fs::write(file, body).expect("write template");
}
dir
}
const FIX: &str = "---\ndescription: fix something\n---\nFix $1 in $2.";
const COMMIT: &str = "---\ndescription: write a commit\n---\nCommit: $ARGUMENTS";
#[test]
fn a_slash_name_renders_the_template_it_names_with_the_rest_of_the_line() {
let dir = workspace(&[("fix.md", FIX)]);
let prompt = resolve("/fix auth login.rs", dir.path())
.expect("a known template")
.expect("a template invocation");
assert_eq!(prompt, "Fix auth in login.rs.");
}
#[test]
fn a_namespaced_template_is_reached_by_its_namespaced_name() {
let dir = workspace(&[("git/commit.md", COMMIT)]);
let prompt = resolve("/git:commit the parser fix", dir.path())
.expect("a known template")
.expect("a template invocation");
assert_eq!(prompt, "Commit: the parser fix");
}
#[test]
fn a_template_invoked_with_nothing_still_renders() {
let dir = workspace(&[("commit.md", COMMIT)]);
assert_eq!(
resolve("/commit", dir.path()).expect("known").expect("one"),
"Commit: "
);
}
#[test]
fn an_unknown_name_is_refused_with_the_names_that_exist() {
let dir = workspace(&[("fix.md", FIX), ("git/commit.md", COMMIT)]);
let error = resolve("/comit the parser fix", dir.path()).expect_err("refused");
let rendered = format!("{error:?}");
assert!(rendered.contains("no template named `comit`"), "{rendered}");
assert!(rendered.contains("fix"), "the list is on it: {rendered}");
assert!(rendered.contains("git:commit"), "{rendered}");
assert!(rendered.contains("stdin"), "and the escape: {rendered}");
}
#[test]
fn a_workspace_with_no_templates_says_so_rather_than_listing_nothing() {
let dir = workspace(&[]);
let error = resolve("/fix it", dir.path()).expect_err("refused");
assert!(
format!("{error:?}").contains(".basis/templates"),
"{error:?}"
);
}
#[test]
fn a_path_is_a_path_and_passes_straight_through() {
let dir = workspace(&[("fix.md", FIX)]);
for prose in [
"/usr/bin/x crashes on startup",
"/ is the root directory",
"//comment syntax",
"look at /etc/hosts",
] {
assert_eq!(
resolve(prose, dir.path()).expect("prose is never refused"),
None,
"{prose} is prose"
);
}
}
#[test]
fn a_stdin_prompt_is_never_read_as_a_template() {
let dir = workspace(&[("fix.md", FIX)]);
let args = run_args("-", dir.path());
assert_eq!(expand(args).expect("untouched").prompt, "-");
}
fn run_args(prompt: &str, workspace: &Path) -> RunArgs {
use clap::Parser;
let Some(crate::cli::Command::Spawn(args)) = crate::cli::Cli::try_parse_from([
"basis",
"spawn",
prompt,
"-C",
&workspace.to_string_lossy(),
])
.expect("parses")
.command
else {
panic!("spawn parses");
};
assert_eq!(args.workspace, Some(PathBuf::from(workspace)));
args
}
}