use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::{fmt, fs};
use garde::Validate;
use minijinja::value::Value;
use minijinja::{Environment, State};
use serde::{Deserialize, Serialize};
use crate::validate::{inside_the_repo, one_file_name};
#[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,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Served {
target: PathBuf,
body: String,
executable: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServeError {
pub template: String,
pub message: String,
}
impl fmt::Display for ServeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.template, self.message)
}
}
impl std::error::Error for ServeError {}
pub fn render(
template: &Path,
source: &str,
suffix: &str,
context: &impl Serialize,
) -> Result<Served, ServeError> {
let failed = |message: String| ServeError {
template: template.display().to_string(),
message,
};
let own_name = template
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(suffix))
.filter(|name| !name.is_empty())
.ok_or_else(|| failed(format!("template name does not end in {suffix}")))?;
let environment: Environment<'static> = crate::surface::environment()
.map_err(|error| failed(format!("register ctl-core fragments: {error}")))?;
let name = template.display().to_string();
let compiled = environment
.template_from_named_str(&name, source)
.map_err(|error| failed(format!("parse: {error}")))?;
let captured = compiled
.render_captured(Value::from_serialize(context))
.map_err(|error| failed(format!("render: {error}")))?;
let declared = declarations(captured.state(), own_name)
.map_err(|message| failed(format!("declarations: {message}")))?;
Ok(Served {
target: declared.path.join(&declared.name),
body: captured.output().to_owned(),
executable: declared.executable,
})
}
fn declarations(state: &State<'_, '_>, own_name: &str) -> Result<Declared, String> {
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();
exports
.entry("name")
.or_insert_with(|| Value::from(own_name));
let declared = Declared::deserialize(Value::from_serialize(&exports))
.map_err(|error| error.to_string())?;
declared.validate().map_err(|report| report.to_string())?;
Ok(declared)
}
impl Served {
#[must_use]
pub fn target(&self) -> &Path {
&self.target
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
#[must_use]
pub fn executable(&self) -> bool {
self.executable
}
pub fn write(&self, root: &Path) -> Result<PathBuf, ServeError> {
let failed = |message: String| ServeError {
template: self.target.display().to_string(),
message,
};
let mut walked = root.to_path_buf();
for part in self.target.components() {
walked.push(part);
if fs::symlink_metadata(&walked).is_ok_and(|meta| meta.file_type().is_symlink()) {
return Err(failed(format!(
"{} must not be a symlink",
walked.strip_prefix(root).unwrap_or(&walked).display()
)));
}
}
let path = root.join(&self.target);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| failed(format!("create {}: {error}", parent.display())))?;
let inside = fs::canonicalize(root)
.and_then(|real_root| Ok(fs::canonicalize(parent)?.starts_with(real_root)))
.map_err(|error| failed(format!("resolve {}: {error}", parent.display())))?;
if !inside {
return Err(failed("target must stay inside the repository".to_owned()));
}
}
fs::write(&path, &self.body).map_err(|error| failed(format!("write: {error}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = if self.executable { 0o755 } else { 0o644 };
fs::set_permissions(&path, fs::Permissions::from_mode(mode))
.map_err(|error| failed(format!("chmod: {error}")))?;
}
Ok(path)
}
}