Skip to main content

ctl_core/
served.rs

1//! Files a repository serves, rendered from templates that declare where they
2//! go.
3//!
4//! A template declares its target in Jinja's own syntax, as top-level
5//! `{%- set -%}` exports, instead of a manifest beside it or a mirrored
6//! directory tree:
7//!
8//! ```jinja
9//! {%- set path = "tasks/q" -%}
10//! {%- set executable = true -%}
11//! #!/usr/bin/env bash
12//! #MISE tools = { "github:victor-software-house/qctl" = "{{ versions["qctl"] }}" }
13//! ```
14//!
15//! `path` is the directory, the repository root when unsaid. `name` is the
16//! file name, the template's own name without its suffix when unsaid, so most
17//! templates declare one line. `executable` is the one mode bit git records
18//! for a regular file: unsaid means 0644. Trim declarations with `{%- … -%}`
19//! so a shebang stays on line one. Any other export is the template's own.
20//!
21//! Rendering uses [`crate::surface::environment`]: undefined names fail, the
22//! trailing newline is kept, and ctl-core's operator fragments are available.
23//! Which templates a repository has, and what context they see, stay with the
24//! CLI that serves them.
25
26use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28use std::{fmt, fs};
29
30use garde::Validate;
31use minijinja::value::Value;
32use minijinja::{Environment, State};
33use serde::{Deserialize, Serialize};
34
35use crate::validate::{inside_the_repo, one_file_name};
36
37/// What a template declares about the file it serves, read as one schema
38/// from its exports and checked once.
39#[derive(Debug, Deserialize, Validate)]
40#[garde(allow_unvalidated)]
41struct Declared {
42    #[serde(default)]
43    #[garde(custom(inside_the_repo))]
44    path: PathBuf,
45    /// Seeded with the template's own name before parsing, so never absent.
46    #[garde(custom(one_file_name))]
47    name: String,
48    #[serde(default)]
49    executable: bool,
50}
51
52/// A rendered template and the file it serves, checked and not yet written.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct Served {
55    target: PathBuf,
56    body: String,
57    executable: bool,
58}
59
60/// Why a template could not be served, naming the template.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct ServeError {
63    /// The template, as the caller named it.
64    pub template: String,
65    /// What went wrong.
66    pub message: String,
67}
68
69impl fmt::Display for ServeError {
70    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(formatter, "{}: {}", self.template, self.message)
72    }
73}
74
75impl std::error::Error for ServeError {}
76
77/// Render one template with `context` and read the file it declares.
78///
79/// `template` names the template, usually its path relative to the
80/// repository; its file name minus `suffix` is the default target name.
81///
82/// # Errors
83///
84/// When the file name does not end in `suffix`, the template does not parse
85/// or render, or its declarations do not fit the schema: a `path` outside the
86/// repository, a `name` that is not one file name, or a value of the wrong
87/// type.
88pub fn render(
89    template: &Path,
90    source: &str,
91    suffix: &str,
92    context: &impl Serialize,
93) -> Result<Served, ServeError> {
94    let failed = |message: String| ServeError {
95        template: template.display().to_string(),
96        message,
97    };
98    let own_name = template
99        .file_name()
100        .and_then(|name| name.to_str())
101        .and_then(|name| name.strip_suffix(suffix))
102        .filter(|name| !name.is_empty())
103        .ok_or_else(|| failed(format!("template name does not end in {suffix}")))?;
104    let environment: Environment<'static> = crate::surface::environment()
105        .map_err(|error| failed(format!("register ctl-core fragments: {error}")))?;
106    let name = template.display().to_string();
107    let compiled = environment
108        .template_from_named_str(&name, source)
109        .map_err(|error| failed(format!("parse: {error}")))?;
110    let captured = compiled
111        .render_captured(Value::from_serialize(context))
112        .map_err(|error| failed(format!("render: {error}")))?;
113    let declared = declarations(captured.state(), own_name)
114        .map_err(|message| failed(format!("declarations: {message}")))?;
115    Ok(Served {
116        target: declared.path.join(&declared.name),
117        body: captured.output().to_owned(),
118        executable: declared.executable,
119    })
120}
121
122/// The schema read out of an evaluated template at once, over a seeded
123/// `name`. Undefined and `none` exports count as unsaid.
124fn declarations(state: &State<'_, '_>, own_name: &str) -> Result<Declared, String> {
125    let mut exports: BTreeMap<&str, Value> = state
126        .exports()
127        .into_iter()
128        .filter_map(|name| state.lookup(name).map(|value| (name, value)))
129        .filter(|(_, value)| !value.is_undefined() && !value.is_none())
130        .collect();
131    exports
132        .entry("name")
133        .or_insert_with(|| Value::from(own_name));
134    let declared = Declared::deserialize(Value::from_serialize(&exports))
135        .map_err(|error| error.to_string())?;
136    declared.validate().map_err(|report| report.to_string())?;
137    Ok(declared)
138}
139
140impl Served {
141    /// Where the file goes, relative to the repository root.
142    #[must_use]
143    pub fn target(&self) -> &Path {
144        &self.target
145    }
146
147    /// The rendered text.
148    #[must_use]
149    pub fn body(&self) -> &str {
150        &self.body
151    }
152
153    /// Whether the file is written 0755 rather than 0644.
154    #[must_use]
155    pub fn executable(&self) -> bool {
156        self.executable
157    }
158
159    /// The same target with `body` in place of the rendered text, for a file
160    /// whose template renders only one block of it.
161    #[cfg(feature = "baseline")]
162    pub(crate) fn with_body(self, body: String) -> Self {
163        Self { body, ..self }
164    }
165
166    /// Write the file under `root`, creating its directories, and set its mode
167    /// on Unix. Returns the full path.
168    ///
169    /// # Errors
170    ///
171    /// When the target or any directory above it is a symlink, which could
172    /// point outside the repository, when the created directory does not
173    /// resolve inside `root`, or when the file or its directories cannot be
174    /// written.
175    pub fn write(&self, root: &Path) -> Result<PathBuf, ServeError> {
176        let failed = |message: String| ServeError {
177            template: self.target.display().to_string(),
178            message,
179        };
180        let mut walked = root.to_path_buf();
181        for part in self.target.components() {
182            walked.push(part);
183            if fs::symlink_metadata(&walked).is_ok_and(|meta| meta.file_type().is_symlink()) {
184                return Err(failed(format!(
185                    "{} must not be a symlink",
186                    walked.strip_prefix(root).unwrap_or(&walked).display()
187                )));
188            }
189        }
190        let path = root.join(&self.target);
191        if let Some(parent) = path.parent() {
192            fs::create_dir_all(parent)
193                .map_err(|error| failed(format!("create {}: {error}", parent.display())))?;
194            let inside = fs::canonicalize(root)
195                .and_then(|real_root| Ok(fs::canonicalize(parent)?.starts_with(real_root)))
196                .map_err(|error| failed(format!("resolve {}: {error}", parent.display())))?;
197            if !inside {
198                return Err(failed("target must stay inside the repository".to_owned()));
199            }
200        }
201        fs::write(&path, &self.body).map_err(|error| failed(format!("write: {error}")))?;
202        #[cfg(unix)]
203        {
204            use std::os::unix::fs::PermissionsExt;
205            let mode = if self.executable { 0o755 } else { 0o644 };
206            fs::set_permissions(&path, fs::Permissions::from_mode(mode))
207                .map_err(|error| failed(format!("chmod: {error}")))?;
208        }
209        Ok(path)
210    }
211}