gled-core 2.30.1

The engine of gled without a user interface: inputs, timing, rendering, Art-Net/DMX output and storage
Documentation
//! The SVG templates in the asset repository's `svg` folder, which any
//! interface can make the project's SVG.

use std::{
    fs,
    path::{Component, Path, PathBuf},
};

use std::time::Instant;

/// How often the folder is looked at again.
const RESCAN: std::time::Duration = std::time::Duration::from_secs(2);

#[derive(Debug, Default)]
pub(super) struct SvgTemplates {
    names: Vec<String>,
    scanned: Option<Instant>,
}

impl SvgTemplates {
    /// The templates, as paths below the `svg` folder, sorted.
    pub(super) fn names(&mut self) -> Vec<String> {
        if self
            .scanned
            .is_none_or(|scanned| scanned.elapsed() >= RESCAN)
        {
            self.scanned = Some(Instant::now());
            let root = folder();
            let mut names = Vec::new();
            collect(&root, &root, &mut names);
            names.sort();
            self.names = names;
        }
        self.names.clone()
    }
}

/// The contents of the template at `name`, which must stay inside the folder.
pub(super) fn read(name: &str) -> Result<String, String> {
    let relative = Path::new(name);
    if relative
        .components()
        .any(|component| !matches!(component, Component::Normal(_)))
    {
        return Err(format!("{name} is not a template"));
    }
    fs::read_to_string(folder().join(relative))
        .map_err(|err| format!("Could not read the SVG template {name}: {err}"))
}

fn folder() -> PathBuf {
    crate::storage::STORAGE_DIR.join("svg")
}

fn collect(root: &Path, dir: &Path, names: &mut Vec<String>) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect(root, &path, names);
        } else if path.extension().is_some_and(|ext| ext == "svg")
            && let Ok(relative) = path.strip_prefix(root)
        {
            names.push(relative.to_string_lossy().replace('\\', "/"));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::read;

    #[test]
    fn names_cannot_leave_the_folder() {
        assert!(read("../projects/secret.json").is_err());
        assert!(read("/etc/passwd").is_err());
        assert!(read("Photonenkollektiv/../../x.svg").is_err());
    }
}