kcode-web-libs 0.1.0

Managed plain HTML, CSS, and JavaScript libraries with browser checks and immutable publication
Documentation
//! Six-operation managed web-library boundary.

mod browser;
mod model;
mod publication;
mod repository;
mod storage;

pub use model::File;

use std::fmt;
use std::path::{Path, PathBuf};

pub(crate) const PUBLISHED_DIRECTORY: &str = ".published";
pub(crate) const COORDINATOR_DIRECTORY: &str = ".coordinator";

/// One opened optimistic snapshot of a managed web library.
///
/// `files` is the complete editable UTF-8 source tree. Call [`Lib::write`] after
/// changing it. A successful write refreshes the private generation fence.
pub struct Lib {
    root: PathBuf,
    name: String,
    expected_generation: String,
    pub files: Vec<File>,
}

/// Creates a managed web library with a minimal version-0.1.0 ES module.
pub fn create(web_libs_root: impl AsRef<Path>, name: &str) -> Result<Lib> {
    repository::create(web_libs_root.as_ref(), name)
}

/// Opens the current complete source generation of a managed web library.
pub fn open(web_libs_root: impl AsRef<Path>, name: &str) -> Result<Lib> {
    repository::open(web_libs_root.as_ref(), name)
}

/// Returns the manifest version and root `Documentation.md`.
pub fn docs(web_libs_root: impl AsRef<Path>, name: &str) -> Result<(String, String)> {
    repository::docs(web_libs_root.as_ref(), name)
}

impl Lib {
    pub(crate) fn from_parts(
        root: PathBuf,
        name: String,
        expected_generation: String,
        mut files: Vec<File>,
    ) -> Self {
        files.sort_by(|left, right| left.path.cmp(&right.path));
        Self {
            root,
            name,
            expected_generation,
            files,
        }
    }

    /// Atomically replaces the complete source tree.
    ///
    /// This fails with `stale_snapshot` if another opened handle committed
    /// first. Reopen and reconcile rather than retrying the old snapshot.
    pub fn write(&mut self) -> Result<()> {
        let generation = repository::write(
            &self.root,
            &self.name,
            &self.expected_generation,
            &self.files,
        )?;
        self.expected_generation = generation;
        self.files.sort_by(|left, right| left.path.cmp(&right.path));
        Ok(())
    }

    /// Validates exactly the current in-memory source in Chromium.
    pub fn check(&self) -> Result<()> {
        let source = model::validate_tree(&self.files, &self.name)?;
        browser::check(&self.root, &self.name, &self.files, &source.manifest)
    }

    /// Rechecks and atomically installs exactly the current in-memory source.
    pub fn publish(&self) -> Result<()> {
        let source = model::validate_tree(&self.files, &self.name)?;
        browser::check(&self.root, &self.name, &self.files, &source.manifest)?;
        publication::publish(&self.root, &self.name, &self.files, &source)
    }
}

impl fmt::Debug for Lib {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("Lib")
            .field("files", &self.files)
            .field("root", &self.root)
            .field("name", &self.name)
            .field("expected_generation", &"[PRIVATE]")
            .finish()
    }
}

pub type Result<T> = std::result::Result<T, Error>;

/// A bounded, non-secret operational error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Error(String);

impl Error {
    pub(crate) fn new(message: impl Into<String>) -> Self {
        let mut message = message.into();
        const LIMIT: usize = 12 * 1024;
        if message.len() > LIMIT {
            let mut end = LIMIT;
            while !message.is_char_boundary(end) {
                end -= 1;
            }
            message.truncate(end);
            message.push_str("\n[diagnostic truncated]");
        }
        Self(message)
    }

    pub(crate) fn invalid_name(message: impl Into<String>) -> Self {
        Self::new(format!("invalid_name: {}", message.into()))
    }

    pub(crate) fn invalid_source(message: impl Into<String>) -> Self {
        Self::new(format!("invalid_source: {}", message.into()))
    }

    pub(crate) fn io(action: &str, error: impl fmt::Display) -> Self {
        Self::new(format!("io: {action}: {error}"))
    }
}

impl fmt::Display for Error {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.0)
    }
}

impl std::error::Error for Error {}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn complete_replacement_commits_and_removes_omitted_files() {
        let root = TempDir::new().unwrap();
        let mut library = create(root.path(), "example").unwrap();

        library
            .files
            .push(File::new("nested/value.js", "export const value = 7;"));
        library.write().unwrap();
        library.files.retain(|file| file.path != "nested/value.js");
        library.write().unwrap();
        let reopened = open(root.path(), "example").unwrap();
        assert!(
            !reopened
                .files
                .iter()
                .any(|file| file.path == "nested/value.js")
        );
    }

    #[test]
    fn stale_handles_must_reopen() {
        let root = TempDir::new().unwrap();
        let mut first = create(root.path(), "example").unwrap();
        let mut second = open(root.path(), "example").unwrap();

        first.files.push(File::new("first.js", "export {};"));
        first.write().unwrap();

        second.files.push(File::new("second.js", "export {};"));
        let error = second.write().unwrap_err();
        assert!(error.to_string().starts_with("stale_snapshot:"));
    }
}