use std::path::{Path, PathBuf};
use bnto_core::processor::{BatchFile, FileData};
use bnto_core::{BntoError, ProcessContext};
use serde_json::Value;
mod unfilled;
pub(crate) use unfilled::unfilled_item;
const EACH: &str = "each";
const PATH_TOKEN: &str = "{{path}}";
const FILE_TOKEN: &str = "{{file}}";
const ITEM_OPEN: &str = "{{item.";
const CLOSE: &str = "}}";
const FILLED_LATER: &[&str] = &["{{output_dir}}", "{{url}}", "{{input}}"];
enum FillError {
Missing(String),
Reserved(String),
}
pub(crate) fn expand_each(
args: &[Value],
files: &[BatchFile],
ctx: &dyn ProcessContext,
) -> Result<Vec<Value>, BntoError> {
let paths = disk_paths(args, files, ctx)?;
let mut out = Vec::with_capacity(args.len());
for arg in args {
match read_group(arg).map_err(BntoError::InvalidInput)? {
Some(group) => out.extend(fill_group(group, files, &paths)?),
None => out.push(arg.clone()),
}
}
Ok(out)
}
pub(crate) fn check_groups(args: Option<&Value>) -> Result<(), String> {
let Some(Value::Array(args)) = args else {
return Ok(());
};
args.iter().try_for_each(|arg| read_group(arg).map(|_| ()))
}
fn read_group(arg: &Value) -> Result<Option<Vec<&str>>, String> {
let Value::Object(map) = arg else {
return Ok(None);
};
let refused = || "an object in 'args' must be {\"each\": [text, ...]}".to_string();
let Some(Value::Array(items)) = map.get(EACH).filter(|_| map.len() == 1) else {
return Err(refused());
};
let templates = items.iter().map(Value::as_str).collect::<Option<Vec<_>>>();
templates.map(Some).ok_or_else(refused)
}
fn fill_group(
group: Vec<&str>,
files: &[BatchFile],
paths: &[PathBuf],
) -> Result<Vec<Value>, BntoError> {
let mut out = Vec::with_capacity(group.len() * files.len());
for (i, file) in files.iter().enumerate() {
let path = paths.get(i).map(|p| p.to_string_lossy().into_owned());
for template in &group {
let text =
fill(template, file, path.as_deref().unwrap_or("")).map_err(|e| match e {
FillError::Missing(key) => missing(&key, file),
FillError::Reserved(token) => reserved(&token, file),
})?;
out.push(Value::String(text));
}
}
Ok(out)
}
fn fill(template: &str, file: &BatchFile, path: &str) -> Result<String, FillError> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find("{{") {
out.push_str(&rest[..start]);
let (piece, after) = token(&rest[start..], file, path)?;
out.push_str(&piece);
rest = after;
}
out.push_str(rest);
Ok(out)
}
fn token<'a>(at: &'a str, file: &BatchFile, path: &str) -> Result<(String, &'a str), FillError> {
if let Some(after) = at.strip_prefix(PATH_TOKEN) {
return Ok((checked(path.to_string())?, after));
}
if let Some(after) = at.strip_prefix(FILE_TOKEN) {
return Ok((checked(file.filename.clone())?, after));
}
let item = at
.strip_prefix(ITEM_OPEN)
.and_then(|rest| rest.split_once(CLOSE));
let Some((key, after)) = item else {
return Ok((at[..2].to_string(), &at[2..]));
};
let value = file
.metadata
.get(key)
.ok_or_else(|| FillError::Missing(key.to_string()))?;
Ok((checked(text(value))?, after))
}
fn checked(value: String) -> Result<String, FillError> {
match FILLED_LATER.iter().find(|t| value.contains(**t)) {
Some(token) => Err(FillError::Reserved(token.to_string())),
None => Ok(value),
}
}
fn text(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn reserved(token: &str, file: &BatchFile) -> BntoError {
BntoError::InvalidInput(format!(
"{} carries '{token}' in its path, name or metadata, which shell-run fills \
itself; rename it or drop it from the group",
file.filename
))
}
fn missing(key: &str, file: &BatchFile) -> BntoError {
let has = if file.metadata.is_empty() {
"it has no metadata".to_string()
} else {
let keys: Vec<&str> = file.metadata.keys().map(String::as_str).collect();
format!("it has: {}", keys.join(", "))
};
BntoError::InvalidInput(format!(
"'args' reads {{{{item.{key}}}}}, which {} doesn't have ({has})",
file.filename
))
}
fn disk_paths(
args: &[Value],
files: &[BatchFile],
ctx: &dyn ProcessContext,
) -> Result<Vec<PathBuf>, BntoError> {
let reads_path = args.iter().any(|arg| {
matches!(read_group(arg), Ok(Some(group)) if group.iter().any(|t| t.contains(PATH_TOKEN)))
});
if !reads_path {
return Ok(Vec::new());
}
files.iter().map(|file| disk_path(file, ctx)).collect()
}
fn disk_path(file: &BatchFile, ctx: &dyn ProcessContext) -> Result<PathBuf, BntoError> {
match &file.data {
FileData::Path(path) if path.is_absolute() => Ok(path.clone()),
FileData::Path(path) => Ok(ctx.work_dir()?.join(path)),
FileData::Bytes(_) => {
let ext = Path::new(&file.filename)
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
let temp = ctx.temp_file(&ext)?;
file.data.copy_to(&temp).map_err(|e| {
BntoError::ProcessingFailed(format!("Failed to write {}: {e}", file.filename))
})?;
Ok(temp)
}
}
}
#[cfg(test)]
mod tests;