use crate::cli::Cli;
use crate::config::Templates;
use crate::git;
use anyhow::{Context, Result, bail};
use ctl_core::validate::{inside_the_repo, one_file_name};
use ctl_core::{Surface, surface::add_fragments};
use garde::Validate;
use minijinja::value::Value;
use minijinja::{Environment, State, UndefinedBehavior, context};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Deserialize, Validate)]
#[garde(allow_unvalidated)]
struct Declared {
#[serde(default)]
#[garde(custom(inside_the_repo))]
path: PathBuf,
#[garde(custom(one_file_name))]
name: String,
#[serde(default)]
executable: bool,
}
pub fn write(
root: &Path,
config: &Templates,
versions: &[(String, String)],
) -> Result<Vec<PathBuf>> {
render(root, config, versions, true)
}
pub fn any(root: &Path, config: &Templates) -> Result<bool> {
Ok(!sources(root, config)?.is_empty())
}
pub fn plan(
root: &Path,
config: &Templates,
versions: &[(String, String)],
) -> Result<Vec<PathBuf>> {
render(root, config, versions, false)
}
fn render(
root: &Path,
config: &Templates,
versions: &[(String, String)],
persist: bool,
) -> Result<Vec<PathBuf>> {
let verctl_surface = Surface::new::<Cli>("ver");
let context = context! {
versions => versions
.iter()
.map(|(name, version)| (name.clone(), version.clone()))
.collect::<BTreeMap<_, _>>(),
verctl_surface,
};
let mut environment = Environment::new();
environment.set_undefined_behavior(UndefinedBehavior::Strict);
environment.set_keep_trailing_newline(true);
add_fragments(&mut environment).context("register ctl-core operator fragments")?;
let mut rendered = Vec::new();
for template in sources(root, config)? {
let source = fs::read_to_string(root.join(&template))
.with_context(|| format!("read template {}", template.display()))?;
let name = template.display().to_string();
let compiled = environment
.template_from_named_str(&name, &source)
.with_context(|| format!("parse template {name}"))?;
let captured = compiled
.render_captured(&context)
.with_context(|| format!("render template {}", template.display()))?;
let body = captured.output();
let declared = Declared::parse(&template, config, captured.state())?;
let target = declared.target();
ensure_inside(root, &target).with_context(|| format!("template {}", template.display()))?;
let path = root.join(&target);
if persist {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
fs::write(&path, body).with_context(|| format!("write {}", target.display()))?;
let mode = if declared.executable { 0o755 } else { 0o644 };
fs::set_permissions(&path, fs::Permissions::from_mode(mode))
.with_context(|| format!("chmod {}", target.display()))?;
}
rendered.push(path);
}
Ok(rendered)
}
impl Declared {
fn parse(template: &Path, config: &Templates, state: &State<'_, '_>) -> Result<Self> {
let mut exports: BTreeMap<&str, Value> = state
.exports()
.into_iter()
.filter_map(|name| state.lookup(name).map(|value| (name, value)))
.filter(|(_, value)| !value.is_undefined() && !value.is_none())
.collect();
let own_name = template
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(&config.suffix))
.with_context(|| {
format!(
"{}: template name does not end in {}",
template.display(),
config.suffix
)
})?;
exports
.entry("name")
.or_insert_with(|| Value::from(own_name));
let declared = Self::deserialize(Value::from_serialize(&exports))
.with_context(|| format!("{}: declarations", template.display()))?;
declared
.validate()
.with_context(|| format!("{}: declarations", template.display()))?;
Ok(declared)
}
fn target(&self) -> PathBuf {
self.path.join(&self.name)
}
}
fn sources(root: &Path, config: &Templates) -> Result<Vec<PathBuf>> {
let tracked: Vec<PathBuf> = git::tracked(root)?
.into_iter()
.filter(|path| is_template(path, config))
.collect();
if git::is_repository(root) {
refuse_untracked(root, config, &tracked)?;
}
Ok(tracked)
}
fn is_template(path: &Path, config: &Templates) -> bool {
path.starts_with(&config.source)
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(&config.suffix))
}
fn refuse_untracked(root: &Path, config: &Templates, tracked: &[PathBuf]) -> Result<()> {
let loose: Vec<String> = present(&root.join(&config.source), &config.suffix)?
.iter()
.filter_map(|path| path.strip_prefix(root).ok())
.filter(|path| !tracked.iter().any(|known| known == path))
.filter(|path| !git::is_ignored(root, path))
.map(|path| path.display().to_string())
.collect();
if loose.is_empty() {
return Ok(());
}
bail!(
"a template git does not track renders nothing: {} — commit it or delete it",
loose.join(", ")
);
}
fn present(dir: &Path, suffix: &str) -> Result<Vec<PathBuf>> {
let Ok(entries) = fs::read_dir(dir) else {
return Ok(Vec::new());
};
let mut found = Vec::new();
for entry in entries {
let path = entry.with_context(|| dir.display().to_string())?.path();
let kind = fs::symlink_metadata(&path)
.with_context(|| path.display().to_string())?
.file_type();
if kind.is_symlink() {
continue;
}
if kind.is_dir() {
found.extend(present(&path, suffix)?);
} else if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(suffix))
{
found.push(path);
}
}
found.sort();
Ok(found)
}
fn ensure_inside(root: &Path, target: &Path) -> Result<()> {
if target.is_absolute()
|| target
.components()
.any(|part| matches!(part, Component::ParentDir))
{
bail!("target must stay inside the repo: {}", target.display());
}
let path = root.join(target);
if let Ok(meta) = fs::symlink_metadata(&path)
&& meta.file_type().is_symlink()
{
bail!("target must not be a symlink: {}", path.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use git2::Repository;
use indoc::indoc;
use tempfile::TempDir;
const TEMPLATE: &str = indoc! {r#"
{%- set path = "tasks/ver" -%}
#!/usr/bin/env bash
#MISE tools = { "github:victor-software-house/verctl" = "{{ versions["verctl"] }}", "cargo:ctl-core" = "{{ versions["ctl-core"] }}" }
exec verctl "$@"
"#};
const BEFORE: &str = indoc! {r#"
#!/usr/bin/env bash
#MISE tools = { "github:victor-software-house/verctl" = "0.0.1", "cargo:ctl-core" = "1.0.0" }
exec verctl "$@"
"#};
const SERVED: &str = "tasks/ver/ver";
const TEMPLATE_PATH: &str = ".ctl/templates/ver.jinja";
enum Outcome {
Serves(&'static str),
Fails(&'static str),
}
fn versions(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(name, version)| ((*name).to_owned(), (*version).to_owned()))
.collect()
}
fn repo(files: &[(&str, &str)]) -> TempDir {
let root = TempDir::new().unwrap();
let repository = Repository::init(root.path()).unwrap();
let mut index = repository.index().unwrap();
for (path, body) in files {
let full = root.path().join(path);
fs::create_dir_all(full.parent().unwrap()).unwrap();
fs::write(&full, body).unwrap();
index.add_path(Path::new(path)).unwrap();
}
index.write().unwrap();
root
}
fn read(root: &TempDir, path: &str) -> String {
fs::read_to_string(root.path().join(path)).unwrap()
}
#[test]
fn one_template_serves_every_release_exactly() {
let cases = [
(
"both packages move",
&[("verctl", "0.0.2"), ("ctl-core", "1.2.3")][..],
Outcome::Serves(indoc! {r#"
#!/usr/bin/env bash
#MISE tools = { "github:victor-software-house/verctl" = "0.0.2", "cargo:ctl-core" = "1.2.3" }
exec verctl "$@"
"#}),
),
(
"only the tool moves",
&[("verctl", "1.0.0"), ("ctl-core", "1.0.0")][..],
Outcome::Serves(indoc! {r#"
#!/usr/bin/env bash
#MISE tools = { "github:victor-software-house/verctl" = "1.0.0", "cargo:ctl-core" = "1.0.0" }
exec verctl "$@"
"#}),
),
(
"a release that changes nothing still serves the same bytes",
&[("verctl", "0.0.1"), ("ctl-core", "1.0.0")][..],
Outcome::Serves(BEFORE),
),
(
"a package the template names is not in the release",
&[("verctl", "0.0.2")][..],
Outcome::Fails(TEMPLATE_PATH),
),
("no packages at all", &[][..], Outcome::Fails(TEMPLATE_PATH)),
];
for (label, pairs, outcome) in cases {
let root = repo(&[(TEMPLATE_PATH, TEMPLATE), (SERVED, BEFORE)]);
let result = write(root.path(), &Templates::default(), &versions(pairs));
match outcome {
Outcome::Serves(after) => {
assert_eq!(result.unwrap(), [root.path().join(SERVED)], "{label}");
assert_eq!(read(&root, SERVED), after, "{label}");
}
Outcome::Fails(named) => {
let report = format!("{:#}", result.unwrap_err());
assert!(report.contains(named), "{label}: {report}");
assert_eq!(read(&root, SERVED), BEFORE, "{label}");
}
}
assert_eq!(read(&root, TEMPLATE_PATH), TEMPLATE, "{label}");
}
}
#[test]
fn rendering_twice_serves_the_same_bytes() {
let root = repo(&[(TEMPLATE_PATH, TEMPLATE), (SERVED, BEFORE)]);
let moved = versions(&[("verctl", "0.0.2"), ("ctl-core", "1.2.3")]);
write(root.path(), &Templates::default(), &moved).unwrap();
let once = read(&root, SERVED);
write(root.path(), &Templates::default(), &moved).unwrap();
assert_eq!(read(&root, SERVED), once);
}
#[test]
fn a_plan_reports_the_served_file_without_writing_it() {
let root = repo(&[(TEMPLATE_PATH, TEMPLATE), (SERVED, BEFORE)]);
assert_eq!(
plan(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2"), ("ctl-core", "1.2.3")])
)
.unwrap(),
[root.path().join(SERVED)]
);
assert_eq!(read(&root, SERVED), BEFORE);
}
#[test]
fn a_broken_template_fails_before_writing_the_served_file() {
let root = repo(&[(TEMPLATE_PATH, "pin {{ versions[\n"), (SERVED, BEFORE)]);
assert!(
write(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2")])
)
.is_err()
);
assert_eq!(read(&root, SERVED), BEFORE);
}
#[test]
fn an_untracked_template_stops_the_run_and_one_outside_is_ignored() {
let moved = versions(&[("verctl", "0.0.2"), ("ctl-core", "1.2.3")]);
let stray_template = repo(&[(SERVED, BEFORE)]);
let path = stray_template.path().join(TEMPLATE_PATH);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, TEMPLATE).unwrap();
let error = write(stray_template.path(), &Templates::default(), &moved).unwrap_err();
let text = format!("{error:#}");
assert!(text.contains(TEMPLATE_PATH), "{text}");
assert!(text.contains("commit it or delete it"), "{text}");
assert_eq!(
read(&stray_template, SERVED),
BEFORE,
"the served file is left alone: the run stops before anything is written"
);
let outside = repo(&[("templates/changelog.jinja", "# {{ anything }}\n")]);
assert_eq!(
write(outside.path(), &Templates::default(), &moved).unwrap(),
Vec::<PathBuf>::new()
);
assert_eq!(
read(&outside, "templates/changelog.jinja"),
"# {{ anything }}\n"
);
}
#[test]
fn every_untracked_template_is_named_at_whatever_depth_it_sits() {
let root = repo(&[(SERVED, BEFORE)]);
for path in [
".ctl/templates/ver.jinja",
".ctl/templates/nested/deeper/readme.jinja",
] {
let full = root.path().join(path);
fs::create_dir_all(full.parent().unwrap()).unwrap();
fs::write(&full, TEMPLATE).unwrap();
}
fs::write(root.path().join(".ctl/templates/notes.md"), "not one\n").unwrap();
let error = write(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2"), ("ctl-core", "1.2.3")]),
)
.unwrap_err();
let text = format!("{error:#}");
assert!(text.contains(".ctl/templates/ver.jinja"), "{text}");
assert!(
text.contains(".ctl/templates/nested/deeper/readme.jinja"),
"{text}"
);
assert!(
!text.contains("notes.md"),
"a file without the suffix is not a template: {text}"
);
}
#[test]
fn the_schema_decides_where_a_template_lands() {
let cases = [
(
"nothing declared: the template's own name, at the root",
"",
Ok("ver"),
),
(
"a directory",
r#"{%- set path = "tasks/ver" -%}"#,
Ok("tasks/ver/ver"),
),
(
"a name",
r#"{%- set name = "mise.toml" -%}"#,
Ok("mise.toml"),
),
(
"both",
indoc! {r#"
{%- set path = "examples" -%}
{%- set name = "mise.toml" -%}
"#},
Ok("examples/mise.toml"),
),
(
"a computed declaration is still a declaration",
r#"{%- set path = "tasks/" ~ "ver" -%}"#,
Ok("tasks/ver/ver"),
),
(
"a path that climbs out",
r#"{%- set path = "../escaped" -%}"#,
Err("path: must stay inside the repository"),
),
(
"an absolute path",
r#"{%- set path = "/etc" -%}"#,
Err("path: must stay inside the repository"),
),
(
"a name that is a path",
r#"{%- set name = "tasks/ver" -%}"#,
Err("name: must be one file name"),
),
(
"a name that climbs out",
r#"{%- set name = ".." -%}"#,
Err("name: must be one file name"),
),
(
"a path of the wrong type",
r"{%- set path = 5 -%}",
Err("invalid type"),
),
(
"an executable of the wrong type",
r#"{%- set executable = "yes" -%}"#,
Err("expected a boolean"),
),
];
for (label, declaration, expected) in cases {
let root = repo(&[(TEMPLATE_PATH, &format!("{declaration}served\n"))]);
let result = write(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2")]),
);
match expected {
Ok(target) => {
assert_eq!(result.unwrap(), [root.path().join(target)], "{label}");
assert_eq!(read(&root, target), "served\n", "{label}");
}
Err(complaint) => {
let report = format!("{:#}", result.unwrap_err());
assert!(report.contains(complaint), "{label}: {report}");
assert!(report.contains(TEMPLATE_PATH), "{label}: {report}");
}
}
}
}
#[test]
fn an_export_the_schema_does_not_know_is_the_templates_own() {
let root = repo(&[(
TEMPLATE_PATH,
indoc! {r#"
{%- set tool = "github:victor-software-house/verctl" -%}
{%- set path = "tasks/ver" -%}
{{ tool }} = "{{ versions["verctl"] }}"
"#},
)]);
write(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2")]),
)
.unwrap();
assert_eq!(
read(&root, "tasks/ver/ver"),
"github:victor-software-house/verctl = \"0.0.2\"\n"
);
}
#[test]
fn a_flat_template_names_a_target_at_any_depth() {
let root = repo(&[
(
".ctl/templates/readme.jinja",
"{%- set name = \"README.md\" -%}\nRun verctl@{{ versions[\"verctl\"] }}.\n",
),
("README.md", "Run verctl@0.0.1.\n"),
(TEMPLATE_PATH, TEMPLATE),
(SERVED, BEFORE),
]);
let mut rendered = write(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2"), ("ctl-core", "1.2.3")]),
)
.unwrap();
rendered.sort();
assert_eq!(
rendered,
[root.path().join("README.md"), root.path().join(SERVED)]
);
assert_eq!(read(&root, "README.md"), "Run verctl@0.0.2.\n");
assert!(!root.path().join("README.md.jinja").exists());
assert!(!root.path().join(".ctl/templates/tasks").exists());
}
#[test]
fn a_template_declares_the_one_mode_bit_git_records() {
let root = repo(&[
(
".ctl/templates/ver.jinja",
indoc! {r#"
{%- set path = "tasks/ver" -%}
{%- set executable = true -%}
#!/usr/bin/env bash
exec verctl@{{ versions["verctl"] }} "$@"
"#},
),
(SERVED, BEFORE),
(
".ctl/templates/readme.jinja",
indoc! {r#"
{%- set name = "README.md" -%}
{%- set executable = false -%}
Run verctl@{{ versions["verctl"] }}.
"#},
),
("README.md", "Run verctl@0.0.1.\n"),
]);
write(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2")]),
)
.unwrap();
let mode = |path: &str| {
fs::metadata(root.path().join(path))
.unwrap()
.permissions()
.mode()
& 0o777
};
assert_eq!(mode(SERVED), 0o755);
assert_eq!(mode("README.md"), 0o644);
assert!(
read(&root, SERVED).starts_with("#!/usr/bin/env bash\n"),
"{}",
read(&root, SERVED)
);
}
#[test]
fn a_template_that_says_nothing_about_mode_serves_0644() {
let root = repo(&[(TEMPLATE_PATH, TEMPLATE), (SERVED, BEFORE)]);
fs::set_permissions(root.path().join(SERVED), fs::Permissions::from_mode(0o755)).unwrap();
write(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2"), ("ctl-core", "1.2.3")]),
)
.unwrap();
assert_eq!(
fs::metadata(root.path().join(SERVED))
.unwrap()
.permissions()
.mode()
& 0o777,
0o644
);
}
#[test]
fn the_source_tree_and_suffix_are_configurable() {
let root = repo(&[("served/tasks/ver/ver.tmpl", TEMPLATE), (SERVED, BEFORE)]);
let config = Templates {
source: PathBuf::from("served"),
suffix: ".tmpl".to_owned(),
};
let rendered = write(
root.path(),
&config,
&versions(&[("verctl", "0.0.2"), ("ctl-core", "1.2.3")]),
)
.unwrap();
assert_eq!(rendered, [root.path().join(SERVED)]);
assert_eq!(
read(&root, SERVED),
indoc! {r#"
#!/usr/bin/env bash
#MISE tools = { "github:victor-software-house/verctl" = "0.0.2", "cargo:ctl-core" = "1.2.3" }
exec verctl "$@"
"#}
);
}
#[test]
fn a_project_below_the_repository_root_serves_its_own_templates() {
let root = repo(&[
(&format!("tool/{TEMPLATE_PATH}"), TEMPLATE),
(&format!("tool/{SERVED}"), BEFORE),
(&format!("other/{TEMPLATE_PATH}"), TEMPLATE),
]);
let project = root.path().join("tool");
assert_eq!(
write(
&project,
&Templates::default(),
&versions(&[("verctl", "0.0.2"), ("ctl-core", "0.1.0")])
)
.unwrap(),
[project.join(SERVED)],
"the project's own template, named the way the project names it"
);
assert_eq!(
fs::read_to_string(root.path().join(format!("other/{SERVED}"))).ok(),
None,
"a sibling project's template is not this one's to render"
);
}
#[test]
fn a_repo_with_no_template_serves_nothing() {
let bare = repo(&[("README.md", "nothing to serve\n")]);
assert!(!any(bare.path(), &Templates::default()).unwrap());
let serving = repo(&[(TEMPLATE_PATH, TEMPLATE)]);
assert!(any(serving.path(), &Templates::default()).unwrap());
}
#[test]
fn an_ignored_template_is_disowned_rather_than_forgotten() {
let root = repo(&[(SERVED, BEFORE), (".gitignore", "*.local.jinja\n")]);
let scratch = root.path().join(".ctl/templates/draft.local.jinja");
fs::create_dir_all(scratch.parent().unwrap()).unwrap();
fs::write(&scratch, TEMPLATE).unwrap();
assert_eq!(
write(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2"), ("ctl-core", "1.2.3")])
)
.unwrap(),
Vec::<PathBuf>::new()
);
assert_eq!(read(&root, SERVED), BEFORE);
}
#[test]
fn a_symlink_out_of_the_repository_serves_nothing_of_its_own() {
let elsewhere = TempDir::new().unwrap();
fs::write(elsewhere.path().join("foreign.jinja"), TEMPLATE).unwrap();
let root = repo(&[(SERVED, BEFORE)]);
let templates = root.path().join(".ctl/templates");
fs::create_dir_all(&templates).unwrap();
std::os::unix::fs::symlink(elsewhere.path(), templates.join("linked")).unwrap();
assert_eq!(
write(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2"), ("ctl-core", "1.2.3")])
)
.unwrap(),
Vec::<PathBuf>::new()
);
}
#[test]
fn a_tree_with_no_repository_has_no_templates() {
let root = TempDir::new().unwrap();
let path = root.path().join(TEMPLATE_PATH);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, TEMPLATE).unwrap();
assert_eq!(
write(
root.path(),
&Templates::default(),
&versions(&[("verctl", "0.0.2")])
)
.unwrap(),
Vec::<PathBuf>::new()
);
}
}