Skip to main content

kcode_web_libs/
lib.rs

1//! Six-operation managed web-library boundary.
2
3mod browser;
4mod model;
5mod publication;
6mod repository;
7mod storage;
8
9pub use model::File;
10
11use std::fmt;
12use std::path::{Path, PathBuf};
13
14pub(crate) const PUBLISHED_DIRECTORY: &str = ".published";
15pub(crate) const COORDINATOR_DIRECTORY: &str = ".coordinator";
16
17/// One opened optimistic snapshot of a managed web library.
18///
19/// `files` is the complete editable UTF-8 source tree. Call [`Lib::write`] after
20/// changing it. A successful write refreshes the private generation fence.
21pub struct Lib {
22    root: PathBuf,
23    name: String,
24    expected_generation: String,
25    pub files: Vec<File>,
26}
27
28/// Creates a managed web library with a minimal version-0.1.0 ES module.
29pub fn create(web_libs_root: impl AsRef<Path>, name: &str) -> Result<Lib> {
30    repository::create(web_libs_root.as_ref(), name)
31}
32
33/// Opens the current complete source generation of a managed web library.
34pub fn open(web_libs_root: impl AsRef<Path>, name: &str) -> Result<Lib> {
35    repository::open(web_libs_root.as_ref(), name)
36}
37
38/// Returns the manifest version and root `Documentation.md`.
39pub fn docs(web_libs_root: impl AsRef<Path>, name: &str) -> Result<(String, String)> {
40    repository::docs(web_libs_root.as_ref(), name)
41}
42
43impl Lib {
44    pub(crate) fn from_parts(
45        root: PathBuf,
46        name: String,
47        expected_generation: String,
48        mut files: Vec<File>,
49    ) -> Self {
50        files.sort_by(|left, right| left.path.cmp(&right.path));
51        Self {
52            root,
53            name,
54            expected_generation,
55            files,
56        }
57    }
58
59    /// Atomically replaces the complete source tree.
60    ///
61    /// This fails with `stale_snapshot` if another opened handle committed
62    /// first. Reopen and reconcile rather than retrying the old snapshot.
63    pub fn write(&mut self) -> Result<()> {
64        let generation = repository::write(
65            &self.root,
66            &self.name,
67            &self.expected_generation,
68            &self.files,
69        )?;
70        self.expected_generation = generation;
71        self.files.sort_by(|left, right| left.path.cmp(&right.path));
72        Ok(())
73    }
74
75    /// Validates exactly the current in-memory source in Chromium.
76    pub fn check(&self) -> Result<()> {
77        let source = model::validate_tree(&self.files, &self.name)?;
78        browser::check(&self.root, &self.name, &self.files, &source.manifest)
79    }
80
81    /// Rechecks and atomically installs exactly the current in-memory source.
82    pub fn publish(&self) -> Result<()> {
83        let source = model::validate_tree(&self.files, &self.name)?;
84        browser::check(&self.root, &self.name, &self.files, &source.manifest)?;
85        publication::publish(&self.root, &self.name, &self.files, &source)
86    }
87}
88
89impl fmt::Debug for Lib {
90    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91        formatter
92            .debug_struct("Lib")
93            .field("files", &self.files)
94            .field("root", &self.root)
95            .field("name", &self.name)
96            .field("expected_generation", &"[PRIVATE]")
97            .finish()
98    }
99}
100
101pub type Result<T> = std::result::Result<T, Error>;
102
103/// A bounded, non-secret operational error.
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct Error(String);
106
107impl Error {
108    pub(crate) fn new(message: impl Into<String>) -> Self {
109        let mut message = message.into();
110        const LIMIT: usize = 12 * 1024;
111        if message.len() > LIMIT {
112            let mut end = LIMIT;
113            while !message.is_char_boundary(end) {
114                end -= 1;
115            }
116            message.truncate(end);
117            message.push_str("\n[diagnostic truncated]");
118        }
119        Self(message)
120    }
121
122    pub(crate) fn invalid_name(message: impl Into<String>) -> Self {
123        Self::new(format!("invalid_name: {}", message.into()))
124    }
125
126    pub(crate) fn invalid_source(message: impl Into<String>) -> Self {
127        Self::new(format!("invalid_source: {}", message.into()))
128    }
129
130    pub(crate) fn io(action: &str, error: impl fmt::Display) -> Self {
131        Self::new(format!("io: {action}: {error}"))
132    }
133}
134
135impl fmt::Display for Error {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        formatter.write_str(&self.0)
138    }
139}
140
141impl std::error::Error for Error {}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use tempfile::TempDir;
147
148    #[test]
149    fn complete_replacement_commits_and_removes_omitted_files() {
150        let root = TempDir::new().unwrap();
151        let mut library = create(root.path(), "example").unwrap();
152
153        library
154            .files
155            .push(File::new("nested/value.js", "export const value = 7;"));
156        library.write().unwrap();
157        library.files.retain(|file| file.path != "nested/value.js");
158        library.write().unwrap();
159        let reopened = open(root.path(), "example").unwrap();
160        assert!(
161            !reopened
162                .files
163                .iter()
164                .any(|file| file.path == "nested/value.js")
165        );
166    }
167
168    #[test]
169    fn stale_handles_must_reopen() {
170        let root = TempDir::new().unwrap();
171        let mut first = create(root.path(), "example").unwrap();
172        let mut second = open(root.path(), "example").unwrap();
173
174        first.files.push(File::new("first.js", "export {};"));
175        first.write().unwrap();
176
177        second.files.push(File::new("second.js", "export {};"));
178        let error = second.write().unwrap_err();
179        assert!(error.to_string().starts_with("stale_snapshot:"));
180    }
181}