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