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