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}