Skip to main content

topcoat_ui/
registry.rs

1use std::{
2    collections::BTreeMap,
3    fmt::Write as _,
4    path::{Path, PathBuf},
5};
6
7use serde::Deserialize;
8use sha2::{Digest, Sha256};
9
10/// The manifest file naming the components within a registry.
11pub const MANIFEST_FILE: &str = "registry.toml";
12
13/// The `registry.toml` format version this build understands. Stored in the
14/// manifest's `version` field so older and newer formats can be told apart; a
15/// manifest declaring a newer version than this is rejected.
16pub const MANIFEST_VERSION: u32 = 1;
17
18/// The registry name used when a project does not specify one. It is also the
19/// name under which the built-in registry is recorded in a project's install
20/// state and given on the `topcoat ui` command line. It is an alias for the
21/// [`DEFAULT_REGISTRY_CRATE`] crate, which the `topcoat` facade pulls in under
22/// its `ui` feature.
23pub const DEFAULT_REGISTRY: &str = "topcoat";
24
25/// The crate that provides the built-in registry. It is referred to by the name
26/// [`DEFAULT_REGISTRY`] everywhere a registry is named, and, unlike other
27/// registries, need not be a direct dependency of the project: the `topcoat`
28/// facade pulls it in transitively under its `ui` feature.
29pub const DEFAULT_REGISTRY_CRATE: &str = "topcoat-ui-registry";
30
31/// The parsed `registry.toml` manifest. Written by hand: it records no hashes,
32/// since a component's hash is computed from its source (see [`content_hash`]).
33/// The registry's identity is its crate name, so the manifest names only the
34/// format version and the components.
35#[derive(Deserialize)]
36struct Manifest {
37    /// The manifest format version (see [`MANIFEST_VERSION`]).
38    version: u32,
39    #[serde(default)]
40    themes: BTreeMap<String, ThemeEntry>,
41    #[serde(default)]
42    components: BTreeMap<String, Entry>,
43}
44
45#[derive(Deserialize)]
46struct Entry {
47    source: String,
48    #[serde(default)]
49    dependencies: Vec<Dependency>,
50}
51
52#[derive(Deserialize)]
53struct ThemeEntry {
54    source: String,
55}
56
57/// Another component that must be installed alongside a component.
58#[derive(Clone, Debug, Deserialize)]
59#[serde(untagged)]
60pub enum Dependency {
61    /// A component in the same registry, named directly.
62    Same(String),
63    /// A component in another registry, identified by that registry's crate
64    /// name. The crate must itself be a dependency of the project.
65    Other { registry: String, name: String },
66}
67
68/// A component registry loaded from a crate's registry directory.
69pub struct Registry {
70    dir: PathBuf,
71    themes: BTreeMap<String, ThemeEntry>,
72    components: BTreeMap<String, Entry>,
73}
74
75impl Registry {
76    /// Loads a registry by reading and parsing the `registry.toml` in `dir` (a
77    /// registry crate's declared registry directory).
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if the manifest cannot be read or parsed, or if it
82    /// declares a format version newer than [`MANIFEST_VERSION`].
83    pub fn load(dir: PathBuf) -> Result<Self, Error> {
84        let manifest_path = dir.join(MANIFEST_FILE);
85        let raw = std::fs::read_to_string(&manifest_path).map_err(|source| Error::Read {
86            path: manifest_path,
87            source,
88        })?;
89        let manifest: Manifest = toml::from_str(&raw)?;
90        if manifest.version > MANIFEST_VERSION {
91            return Err(Error::UnsupportedVersion {
92                found: manifest.version,
93                supported: MANIFEST_VERSION,
94            });
95        }
96        Ok(Self {
97            dir,
98            themes: manifest.themes,
99            components: manifest.components,
100        })
101    }
102
103    /// The names of every component in the registry, sorted.
104    pub fn names(&self) -> impl Iterator<Item = &str> {
105        self.components.keys().map(String::as_str)
106    }
107
108    /// Looks up a component by its registry name.
109    #[must_use]
110    pub fn get(&self, name: &str) -> Option<Component<'_>> {
111        self.components
112            .get_key_value(name)
113            .map(|(name, entry)| Component {
114                name,
115                entry,
116                dir: &self.dir,
117            })
118    }
119
120    /// The names of every theme the registry offers, sorted.
121    pub fn theme_names(&self) -> impl Iterator<Item = &str> {
122        self.themes.keys().map(String::as_str)
123    }
124
125    /// Looks up a theme by its registry name.
126    #[must_use]
127    pub fn theme(&self, name: &str) -> Option<Theme<'_>> {
128        self.themes.get_key_value(name).map(|(name, entry)| Theme {
129            name,
130            entry,
131            dir: &self.dir,
132        })
133    }
134}
135
136/// A single component within a [`Registry`].
137pub struct Component<'a> {
138    name: &'a str,
139    entry: &'a Entry,
140    dir: &'a Path,
141}
142
143impl Component<'_> {
144    /// The name used to add the component, e.g. `button`.
145    #[must_use]
146    pub fn name(&self) -> &str {
147        self.name
148    }
149
150    /// Computes the component's content hash by reading and hashing its source
151    /// (see [`content_hash`]).
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if the component's source file cannot be read.
156    pub fn hash(&self) -> Result<String, Error> {
157        Ok(content_hash(&self.read_source()?))
158    }
159
160    /// The file name written into the user's components directory.
161    #[must_use]
162    pub fn file_name(&self) -> &str {
163        Path::new(&self.entry.source)
164            .file_name()
165            .and_then(|name| name.to_str())
166            .unwrap_or(&self.entry.source)
167    }
168
169    /// Reads the component's Rust source from the registry.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the source file cannot be read.
174    pub fn read_source(&self) -> Result<String, Error> {
175        let path = self.dir.join(&self.entry.source);
176        std::fs::read_to_string(&path).map_err(|source| Error::Read { path, source })
177    }
178
179    /// The other components this component depends on.
180    #[must_use]
181    pub fn dependencies(&self) -> &[Dependency] {
182        &self.entry.dependencies
183    }
184}
185
186/// A single theme within a [`Registry`]: a CSS file that becomes a project's
187/// Tailwind input, copied into the project at `init` time.
188pub struct Theme<'a> {
189    name: &'a str,
190    entry: &'a ThemeEntry,
191    dir: &'a Path,
192}
193
194impl Theme<'_> {
195    /// The name used to select the theme, e.g. `neutral`.
196    #[must_use]
197    pub fn name(&self) -> &str {
198        self.name
199    }
200
201    /// The file name written into the user's project. Every theme installs to
202    /// the same `styles.css` (it becomes the project's Tailwind input), rather
203    /// than carrying its registry source name (e.g. `neutral.css`) into the project.
204    #[must_use]
205    pub fn file_name(&self) -> &'static str {
206        "styles.css"
207    }
208
209    /// Computes the theme's content hash by reading and hashing its source (see
210    /// [`content_hash`]).
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if the theme's source file cannot be read.
215    pub fn hash(&self) -> Result<String, Error> {
216        Ok(content_hash(&self.read_source()?))
217    }
218
219    /// Reads the theme's CSS source from the registry.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if the source file cannot be read.
224    pub fn read_source(&self) -> Result<String, Error> {
225        let path = self.dir.join(&self.entry.source);
226        std::fs::read_to_string(&path).map_err(|source| Error::Read { path, source })
227    }
228}
229
230/// Computes the content hash recorded for a component, the sha256 of its source
231/// prefixed with `sha256:`. Hashing the same source always yields the same
232/// value, so a project can tell its installed component apart from an updated
233/// one by comparing the hash it recorded against a fresh hash of the registry's
234/// current source.
235#[must_use]
236pub fn content_hash(source: &str) -> String {
237    format!("sha256:{}", hex(Sha256::digest(source.as_bytes()).as_ref()))
238}
239
240/// Lowercase hex encoding of a byte slice.
241fn hex(bytes: &[u8]) -> String {
242    let mut out = String::with_capacity(bytes.len() * 2);
243    for byte in bytes {
244        write!(out, "{byte:02x}").expect("writing to a String cannot fail");
245    }
246    out
247}
248
249/// An error loading a registry or one of its components.
250#[derive(Debug, thiserror::Error)]
251pub enum Error {
252    #[error("failed to read {path:?}")]
253    Read {
254        path: PathBuf,
255        #[source]
256        source: std::io::Error,
257    },
258    #[error("failed to parse registry manifest")]
259    Parse(#[from] toml::de::Error),
260    #[error(
261        "registry manifest has format version {found}, but this build supports up to {supported}"
262    )]
263    UnsupportedVersion { found: u32, supported: u32 },
264}