use std::{path::Path, sync::LazyLock};
use crate::{Result, git_util, step::ShellType};
use itertools::Itertools;
use serde::Serialize;
use tera::Tera;
pub fn render(input: &str, ctx: &Context) -> Result<String> {
let mut tera = Tera::default();
let output = tera.render_str(input, &ctx.ctx)?;
Ok(output)
}
static BASE_CONTEXT: LazyLock<tera::Context> = LazyLock::new(|| {
let mut ctx = tera::Context::new();
let root = git_util::find_work_tree_root();
ctx.insert("color", &console::colors_enabled_stderr());
ctx.insert("root", &root.display().to_string());
ctx
});
#[derive(Clone)]
pub struct Context {
ctx: tera::Context,
}
impl Default for Context {
fn default() -> Self {
Self {
ctx: BASE_CONTEXT.clone(),
}
}
}
impl Context {
pub fn insert<T: Serialize + ?Sized, S: Into<String>>(&mut self, key: S, val: &T) {
self.ctx.insert(key, val);
}
pub fn with_globs<P: AsRef<Path>>(&mut self, globs: &[P]) -> &mut Self {
let globs = globs.iter().map(|m| m.as_ref().to_str().unwrap()).join(" ");
self.insert("globs", &globs);
self
}
pub fn with_files<P: AsRef<Path>>(&mut self, shell_type: ShellType, files: &[P]) -> &mut Self {
let files_list: Vec<String> = files
.iter()
.map(|f| f.as_ref().to_str().unwrap().to_string())
.collect();
self.insert("files_list", &files_list);
let quoted_files = files
.iter()
.map(|m| shell_type.quote(m.as_ref().to_str().unwrap()))
.join(" ");
self.insert("files", "ed_files);
self
}
pub fn with_workspace_indicator<P: AsRef<Path>>(
&mut self,
workspace_indicator: &P,
) -> &mut Self {
let workspace_indicator = workspace_indicator.as_ref();
let workspace_dir = workspace_indicator
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
self.insert("workspace", &workspace_dir.display().to_string());
self.insert(
"workspace_indicator",
&workspace_indicator.display().to_string(),
);
self
}
pub fn with_workspace_files<P: AsRef<Path>>(
&mut self,
shell_type: ShellType,
workspace_dir: &Path,
files: &[P],
) -> &mut Self {
let files = files
.iter()
.map(|m| {
let p = m.as_ref();
let rel = p.strip_prefix(workspace_dir).unwrap_or(p);
shell_type.quote(rel.to_str().unwrap())
})
.join(" ");
self.insert("workspace_files", &files);
self
}
pub fn for_display(&self) -> Self {
let mut ctx = self.clone();
if let Some(truncated) = truncate_quoted_list(self.ctx.get("files")) {
ctx.insert("files", &truncated);
}
if let Some(truncated) = truncate_quoted_list(self.ctx.get("workspace_files")) {
ctx.insert("workspace_files", &truncated);
}
ctx
}
}
fn truncate_quoted_list(value: Option<&tera::Value>) -> Option<String> {
let s = value.and_then(|v| v.as_str())?;
let mut tokens = split_quoted_tokens(s);
let first = tokens.next()?;
tokens.next()?;
Some(format!("{first} …"))
}
fn split_quoted_tokens(s: &str) -> impl Iterator<Item = &str> {
let bytes = s.as_bytes();
let mut start = 0usize;
std::iter::from_fn(move || {
while start < bytes.len() && bytes[start] == b' ' {
start += 1;
}
if start >= bytes.len() {
return None;
}
let token_start = start;
let mut quote: Option<u8> = None;
let mut i = start;
while i < bytes.len() {
let b = bytes[i];
match quote {
Some(q) => {
if b == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == q {
quote = None;
}
}
None => {
if b == b' ' {
break;
}
if b == b'\'' || b == b'"' {
quote = Some(b);
}
}
}
i += 1;
}
let token = &s[token_start..i];
start = i;
Some(token)
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn split(s: &str) -> Vec<&str> {
split_quoted_tokens(s).collect()
}
#[test]
fn split_quoted_tokens_handles_unquoted() {
assert_eq!(split("a b c"), vec!["a", "b", "c"]);
}
#[test]
fn split_quoted_tokens_preserves_single_quoted_spaces() {
assert_eq!(
split("'has space.txt' other.txt"),
vec!["'has space.txt'", "other.txt"]
);
}
#[test]
fn split_quoted_tokens_handles_escaped_quote_in_double() {
assert_eq!(split(r#""a\"b" c"#), vec![r#""a\"b""#, "c"]);
}
#[test]
fn truncate_quoted_list_returns_none_for_single_token() {
let v = json!("only.txt");
assert_eq!(truncate_quoted_list(Some(&v)), None);
}
#[test]
fn truncate_quoted_list_truncates_multiple_tokens() {
let v = json!("first.txt second.txt third.txt");
assert_eq!(
truncate_quoted_list(Some(&v)),
Some("first.txt …".to_string())
);
}
#[test]
fn truncate_quoted_list_preserves_quoted_first_token() {
let v = json!("'a b.txt' other.txt");
assert_eq!(
truncate_quoted_list(Some(&v)),
Some("'a b.txt' …".to_string())
);
}
#[test]
fn truncate_quoted_list_returns_none_for_empty_or_missing() {
assert_eq!(truncate_quoted_list(None), None);
let v = json!("");
assert_eq!(truncate_quoted_list(Some(&v)), None);
let v = json!(" ");
assert_eq!(truncate_quoted_list(Some(&v)), None);
}
}