mod discovery;
mod parse;
mod render;
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::context::ContextScope;
pub const DEFAULT_WORKSPACE_TEMPLATES_DIR: &str = ".basis/templates";
pub const DEFAULT_GLOBAL_TEMPLATES_DIR: &str = "templates";
pub const TEMPLATE_EXTENSION: &str = "md";
pub const NAMESPACE_SEPARATOR: &str = ":";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplatesConfig {
pub workspace_subdir: PathBuf,
pub global_dir: Option<PathBuf>,
}
impl Default for TemplatesConfig {
fn default() -> Self {
Self {
workspace_subdir: PathBuf::from(DEFAULT_WORKSPACE_TEMPLATES_DIR),
global_dir: crate::context::ContextConfig::default().global_dir,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateSource {
pub path: PathBuf,
pub scope: ContextScope,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Template {
pub name: String,
pub description: String,
pub argument_hint: Option<String>,
pub body: String,
pub path: PathBuf,
pub scope: ContextScope,
}
impl Template {
pub fn render(&self, args: &str) -> String {
render::render(&self.body, args)
}
}
#[derive(Debug, Error)]
pub enum TemplateError {
#[error("failed to read templates directory {path}: {source}")]
ReadDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to read template file {path}: {source}")]
ReadFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid template frontmatter in {path}: {message}")]
InvalidFrontmatter { path: PathBuf, message: String },
#[error("template {path} has no description; add `description:` to its frontmatter")]
MissingDescription { path: PathBuf },
#[error("duplicate template name '{name}' in {first_path} and {second_path}")]
DuplicateName {
name: String,
first_path: PathBuf,
second_path: PathBuf,
},
#[error("template path {path} is not valid UTF-8, so it cannot name a command")]
NonUtf8Path { path: PathBuf },
}
pub fn discover(workspace: &Path, config: &TemplatesConfig) -> Vec<TemplateSource> {
let mut sources = Vec::new();
let workspace_dir = workspace.join(&config.workspace_subdir);
if workspace_dir.is_dir() {
sources.push(TemplateSource {
path: workspace_dir,
scope: ContextScope::Workspace,
});
}
if let Some(global) = &config.global_dir {
let global_dir = global.join(DEFAULT_GLOBAL_TEMPLATES_DIR);
if global_dir.is_dir()
&& !sources
.iter()
.any(|source| crate::paths::same_dir(&source.path, &global_dir))
{
sources.push(TemplateSource {
path: global_dir,
scope: ContextScope::Global,
});
}
}
sources
}
pub fn load(workspace: &Path, config: &TemplatesConfig) -> Result<Vec<Template>, TemplateError> {
load_sources(&discover(workspace, config))
}
pub fn load_sources(sources: &[TemplateSource]) -> Result<Vec<Template>, TemplateError> {
discovery::load_sources(sources)
}
#[cfg(test)]
mod tests {
use super::*;
fn config(global: Option<PathBuf>) -> TemplatesConfig {
TemplatesConfig {
workspace_subdir: PathBuf::from(DEFAULT_WORKSPACE_TEMPLATES_DIR),
global_dir: global,
}
}
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
std::fs::create_dir_all(dir).expect("create dir");
let path = dir.join(name);
std::fs::write(&path, body).expect("write file");
path
}
#[test]
fn nothing_on_disk_means_no_sources() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(discover(tmp.path(), &config(None)).is_empty());
}
#[test]
fn a_workspace_directory_is_found() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join(DEFAULT_WORKSPACE_TEMPLATES_DIR);
std::fs::create_dir_all(&dir).expect("create templates dir");
let found = discover(tmp.path(), &config(None));
assert_eq!(found.len(), 1);
assert_eq!(found[0].scope, ContextScope::Workspace);
assert_eq!(found[0].path, dir);
}
#[test]
fn the_workspace_directory_outranks_the_global_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
std::fs::create_dir_all(tmp.path().join(DEFAULT_WORKSPACE_TEMPLATES_DIR))
.expect("create workspace templates");
std::fs::create_dir_all(global.join(DEFAULT_GLOBAL_TEMPLATES_DIR)).expect("create global");
let found = discover(tmp.path(), &config(Some(global)));
assert_eq!(found.len(), 2);
assert_eq!(found[0].scope, ContextScope::Workspace);
assert_eq!(found[1].scope, ContextScope::Global);
}
#[test]
fn a_global_directory_alone_is_used() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
std::fs::create_dir_all(global.join(DEFAULT_GLOBAL_TEMPLATES_DIR)).expect("create global");
let found = discover(tmp.path(), &config(Some(global)));
assert_eq!(found.len(), 1);
assert_eq!(found[0].scope, ContextScope::Global);
}
#[test]
fn a_file_where_the_directory_should_be_is_ignored() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join(DEFAULT_WORKSPACE_TEMPLATES_DIR);
std::fs::create_dir_all(dir.parent().expect("parent")).expect("create .basis");
std::fs::write(&dir, "not a directory").expect("write file");
assert!(discover(tmp.path(), &config(None)).is_empty());
}
#[test]
fn the_same_directory_reached_twice_is_reported_once() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
std::fs::create_dir_all(global.join(DEFAULT_GLOBAL_TEMPLATES_DIR))
.expect("create global templates");
let found = discover(
&global,
&TemplatesConfig {
workspace_subdir: PathBuf::from(DEFAULT_GLOBAL_TEMPLATES_DIR),
global_dir: Some(global.clone()),
},
);
assert_eq!(found.len(), 1);
assert_eq!(found[0].scope, ContextScope::Workspace);
}
#[test]
fn loading_an_absent_directory_yields_nothing() {
let tmp = tempfile::tempdir().expect("tempdir");
let loaded = load(tmp.path(), &config(None)).expect("absent is not an error");
assert!(loaded.is_empty());
}
#[test]
fn a_template_carries_its_frontmatter_and_body() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join(DEFAULT_WORKSPACE_TEMPLATES_DIR);
write(
&dir,
"review.md",
"---\ndescription: Review a diff\nargument-hint: <path>\n---\nReview $ARGUMENTS.\n",
);
let loaded = load(tmp.path(), &config(None)).expect("load succeeds");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "review");
assert_eq!(loaded[0].description, "Review a diff");
assert_eq!(loaded[0].argument_hint.as_deref(), Some("<path>"));
assert_eq!(loaded[0].body, "Review $ARGUMENTS.\n");
assert_eq!(loaded[0].scope, ContextScope::Workspace);
}
#[test]
fn a_workspace_template_shadows_a_global_one_of_the_same_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
write(
&tmp.path().join(DEFAULT_WORKSPACE_TEMPLATES_DIR),
"review.md",
"---\ndescription: workspace\n---\nworkspace body\n",
);
write(
&global.join(DEFAULT_GLOBAL_TEMPLATES_DIR),
"review.md",
"---\ndescription: global\n---\nglobal body\n",
);
write(
&global.join(DEFAULT_GLOBAL_TEMPLATES_DIR),
"plan.md",
"---\ndescription: only global\n---\nplan body\n",
);
let loaded = load(tmp.path(), &config(Some(global))).expect("load succeeds");
assert_eq!(loaded.len(), 2);
let review = loaded.iter().find(|t| t.name == "review").expect("review");
assert_eq!(review.description, "workspace");
assert_eq!(review.scope, ContextScope::Workspace);
let plan = loaded.iter().find(|t| t.name == "plan").expect("plan");
assert_eq!(plan.scope, ContextScope::Global);
}
#[test]
fn templates_come_back_ordered_by_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join(DEFAULT_WORKSPACE_TEMPLATES_DIR);
for name in ["zeta", "alpha", "mid"] {
write(
&dir,
&format!("{name}.md"),
&format!("---\ndescription: {name}\n---\nbody\n"),
);
}
let loaded = load(tmp.path(), &config(None)).expect("load succeeds");
let names: Vec<&str> = loaded.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "mid", "zeta"]);
}
#[test]
fn a_missing_description_is_an_error_naming_the_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join(DEFAULT_WORKSPACE_TEMPLATES_DIR);
let path = write(&dir, "bare.md", "just a prompt, no frontmatter\n");
let error = load(tmp.path(), &config(None)).expect_err("rejected");
assert!(matches!(
&error,
TemplateError::MissingDescription { path: reported } if reported == &path
));
}
#[test]
fn render_is_reachable_from_a_loaded_template() {
let tmp = tempfile::tempdir().expect("tempdir");
write(
&tmp.path().join(DEFAULT_WORKSPACE_TEMPLATES_DIR),
"fix.md",
"---\ndescription: fix\n---\nFix $1 in $2.",
);
let loaded = load(tmp.path(), &config(None)).expect("load succeeds");
assert_eq!(loaded[0].render("auth login.rs"), "Fix auth in login.rs.");
}
}