Skip to main content

ctl_core/
served.rs

1//! Files a CLI generates into a repository from templates, each template
2//! declaring its own target path and mode.
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/toy" -%}
10//! {%- set executable = true -%}
11//! #!/usr/bin/env bash
12//! #MISE tools = { "github:example/toy" = "{{ versions["toy"] }}" }
13//! ```
14//!
15//! `path` is the directory, the repository root when omitted. `name` is the
16//! file name, the template's own name without its suffix when omitted, so most
17//! templates declare one line. `executable` is the one mode bit git records
18//! for a regular file: when omitted, 0644. Trim declarations with `{%- … -%}`
19//! so a shebang stays on line one. Any other export is the template's own.
20//!
21//! Rendering uses [`surface::environment`](crate::surface::environment):
22//! undefined names fail, the trailing newline is kept, and ctl-core's shared
23//! fragments are available. Which templates a repository has, and what
24//! context they see, stay with the CLI that generates them.
25
26use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28use std::{fmt, fs, io};
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 becomes, 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 becomes, checked and not yet written.
53///
54/// [`render`] builds one. Read it with [`Self::target`], [`Self::body`], and
55/// [`Self::executable`], or write it with [`Self::write`].
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct Served {
58    target: PathBuf,
59    body: String,
60    executable: bool,
61}
62
63/// Why a template could not be rendered or written, naming the template.
64///
65/// `Display` prints `template: message`.
66///
67/// A file that could not be written keeps its [`io::ErrorKind`], which
68/// [`Self::io_failure`] returns. So a caller can treat a refused permission
69/// apart from a full disk. A field can join this type in a minor release.
70#[derive(Clone, Debug, PartialEq, Eq)]
71#[non_exhaustive]
72pub struct ServeError {
73    /// The template, as the caller named it. A write error names the target
74    /// path instead.
75    pub template: String,
76    /// What went wrong, in words a user can act on.
77    pub message: String,
78    /// The I/O failure, for a file that could not be written.
79    io_failure: Option<io::ErrorKind>,
80}
81
82impl ServeError {
83    /// A refusal of `template` that no I/O error caused.
84    const fn refusal(template: String, message: String) -> Self {
85        Self {
86            template,
87            message,
88            io_failure: None,
89        }
90    }
91
92    /// A failure of `template` that `error` caused, described by `message`.
93    fn failed_io(template: String, message: String, error: &io::Error) -> Self {
94        Self {
95            io_failure: Some(error.kind()),
96            ..Self::refusal(template, message)
97        }
98    }
99
100    /// The I/O failure when the file or a directory above it could not be
101    /// written or resolved, such as
102    /// [`io::ErrorKind::PermissionDenied`]. `None` for a template that did not
103    /// render or declared a target it may not write.
104    #[must_use]
105    pub const fn io_failure(&self) -> Option<io::ErrorKind> {
106        self.io_failure
107    }
108}
109
110impl fmt::Display for ServeError {
111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(formatter, "{}: {}", self.template, self.message)
113    }
114}
115
116impl std::error::Error for ServeError {}
117
118/// Render one template with `context`, and read the target path, name, and
119/// mode it declares.
120///
121/// `template` names the template, usually its path relative to the
122/// repository. Its file name minus `suffix` is the default target name.
123///
124/// # Errors
125///
126/// Returns a [`ServeError`] in any of these cases:
127///
128/// - the file name does not end in `suffix`;
129/// - the template does not parse or render;
130/// - its declarations do not fit the schema, such as a `path` outside the
131///   repository, a `name` that is not one file name, or a wrongly typed value.
132pub fn render(
133    template: &Path,
134    source: &str,
135    suffix: &str,
136    context: &impl Serialize,
137) -> Result<Served, ServeError> {
138    let failed = |message: String| ServeError::refusal(template.display().to_string(), message);
139    let own_name = template
140        .file_name()
141        .and_then(|name| name.to_str())
142        .and_then(|name| name.strip_suffix(suffix))
143        .filter(|name| !name.is_empty())
144        .ok_or_else(|| failed(format!("template name does not end in {suffix}")))?;
145    let environment: Environment<'static> = crate::surface::environment()
146        .map_err(|error| failed(format!("register ctl-core fragments: {error}")))?;
147    let name = template.display().to_string();
148    let compiled = environment
149        .template_from_named_str(&name, source)
150        .map_err(|error| failed(format!("parse: {error}")))?;
151    let captured = compiled
152        .render_captured(Value::from_serialize(context))
153        .map_err(|error| failed(format!("render: {error}")))?;
154    let declared = declarations(captured.state(), own_name)
155        .map_err(|message| failed(format!("declarations: {message}")))?;
156    Ok(Served {
157        target: declared.path.join(&declared.name),
158        body: captured.output().to_owned(),
159        executable: declared.executable,
160    })
161}
162
163/// The schema read out of an evaluated template at once, over a seeded
164/// `name`. Undefined and `none` exports count as unsaid.
165fn declarations(state: &State<'_, '_>, own_name: &str) -> Result<Declared, String> {
166    let mut exports: BTreeMap<&str, Value> = state
167        .exports()
168        .into_iter()
169        .filter_map(|name| state.lookup(name).map(|value| (name, value)))
170        .filter(|(_, value)| !value.is_undefined() && !value.is_none())
171        .collect();
172    exports
173        .entry("name")
174        .or_insert_with(|| Value::from(own_name));
175    let declared = Declared::deserialize(Value::from_serialize(&exports))
176        .map_err(|error| error.to_string())?;
177    declared.validate().map_err(|report| report.to_string())?;
178    Ok(declared)
179}
180
181impl Served {
182    /// Where the file goes, relative to the repository root.
183    #[must_use]
184    pub fn target(&self) -> &Path {
185        &self.target
186    }
187
188    /// The rendered text.
189    #[must_use]
190    pub fn body(&self) -> &str {
191        &self.body
192    }
193
194    /// Whether the file is written 0755 rather than 0644.
195    #[must_use]
196    pub fn executable(&self) -> bool {
197        self.executable
198    }
199
200    /// Write the file under `root`, creating its directories, and set its mode
201    /// on Unix. Returns the full path.
202    ///
203    /// # Errors
204    ///
205    /// Returns a [`ServeError`] in any of these cases:
206    ///
207    /// - the target or a directory above it is a symlink, which could point
208    ///   outside the repository;
209    /// - the created directory does not resolve inside `root`;
210    /// - the file or its directories cannot be written.
211    pub fn write(&self, root: &Path) -> Result<PathBuf, ServeError> {
212        let failed =
213            |message: String| ServeError::refusal(self.target.display().to_string(), message);
214        let io_failed = |message: String, error: &io::Error| {
215            ServeError::failed_io(self.target.display().to_string(), message, error)
216        };
217        let mut walked = root.to_path_buf();
218        for part in self.target.components() {
219            walked.push(part);
220            if fs::symlink_metadata(&walked).is_ok_and(|meta| meta.file_type().is_symlink()) {
221                return Err(failed(format!(
222                    "{} must not be a symlink",
223                    walked.strip_prefix(root).unwrap_or(&walked).display()
224                )));
225            }
226        }
227        let path = root.join(&self.target);
228        if let Some(parent) = path.parent() {
229            fs::create_dir_all(parent).map_err(|error| {
230                io_failed(format!("create {}: {error}", parent.display()), &error)
231            })?;
232            let inside = fs::canonicalize(root)
233                .and_then(|real_root| Ok(fs::canonicalize(parent)?.starts_with(real_root)))
234                .map_err(|error| {
235                    io_failed(format!("resolve {}: {error}", parent.display()), &error)
236                })?;
237            if !inside {
238                return Err(failed("target must stay inside the repository".to_owned()));
239            }
240        }
241        fs::write(&path, &self.body)
242            .map_err(|error| io_failed(format!("write: {error}"), &error))?;
243        #[cfg(unix)]
244        {
245            use std::os::unix::fs::PermissionsExt;
246            let mode = if self.executable { 0o755 } else { 0o644 };
247            fs::set_permissions(&path, fs::Permissions::from_mode(mode))
248                .map_err(|error| io_failed(format!("chmod: {error}"), &error))?;
249        }
250        Ok(path)
251    }
252}