Skip to main content

rich_micro/
registry.rs

1//! The layered registry (#567, #583).
2//!
3//! Four layers, in ascending precedence: built-in < user
4//! (`~/.config/rich/micro/`) < trusted project (`.rich/micro/`) < inline (added
5//! in code). A name resolves to the highest layer that has it, as an asset or
6//! as an alias; an alias resolves only within its own layer. Within a layer,
7//! packages load in file-name order and the first to claim a name keeps it;
8//! every later claim is reported as a [`Collision`]. A project's assets load
9//! only when the caller says the project is trusted, under the same rule as
10//! project config: a cloned repository cannot restyle `success` or put images
11//! in your terminal.
12//!
13//! [`MicroRegistry::explain`] shows how a name resolved, reusing
14//! `rich_ext::cli_doc::Precedence`.
15
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use rich_ext::cli_doc::{Explanation, Layer as PrecedenceLayer, Precedence};
21
22use crate::error::MicroError;
23use crate::model::{Layer, MicroAsset, Origin};
24use crate::name::check_name;
25use crate::package::{self, Limits, Loaded};
26
27/// Where each file-backed layer lives. Build one with
28/// [`MicroRoots::from_env`], or set the fields for tests and embedders.
29#[derive(Clone, Debug, Default, PartialEq, Eq)]
30pub struct MicroRoots {
31    /// Load the library's own built-in set ([`crate::builtin`]), compiled
32    /// in. On in [`from_env`](Self::from_env); off in `default()`.
33    pub builtin_set: bool,
34    /// Built-in packages shipped on disk, if any, loaded after the
35    /// library's own set: for distributions that ship more.
36    pub builtin: Option<PathBuf>,
37    /// The user's directory, normally `~/.config/rich/micro/`.
38    pub user: Option<PathBuf>,
39    /// The project's directory, normally `<project>/.rich/micro/`.
40    pub project: Option<PathBuf>,
41    /// Whether to load [`project`](Self::project). Off unless the caller
42    /// decided the project is trusted.
43    pub project_trusted: bool,
44}
45
46impl MicroRoots {
47    /// The user layer from `HOME` (or `USERPROFILE`), as `rich`'s CLI finds
48    /// its config (`~/.config/rich/`), and the project layer under
49    /// `project_root`, **untrusted**: call
50    /// [`trust_project`](Self::trust_project) to load it.
51    pub fn from_env(project_root: Option<&Path>) -> Self {
52        let home = std::env::var_os("HOME")
53            .or_else(|| std::env::var_os("USERPROFILE"))
54            .map(PathBuf::from);
55        MicroRoots {
56            builtin_set: true,
57            builtin: None,
58            user: home.as_deref().map(Self::user_dir),
59            project: project_root.map(Self::project_dir),
60            project_trusted: false,
61        }
62    }
63
64    /// `<home>/.config/rich/micro`.
65    pub fn user_dir(home: &Path) -> PathBuf {
66        home.join(".config").join("rich").join("micro")
67    }
68
69    /// `<project>/.rich/micro`.
70    pub fn project_dir(project_root: &Path) -> PathBuf {
71        project_root.join(".rich").join("micro")
72    }
73
74    /// Load the project layer (or not).
75    #[must_use]
76    pub fn trust_project(mut self, trusted: bool) -> Self {
77        self.project_trusted = trusted;
78        self
79    }
80}
81
82/// A package that could not be loaded, and why.
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct Rejected {
85    pub layer: Layer,
86    pub path: PathBuf,
87    /// The package's path within its pack, for a package in a pack.
88    pub package: Option<String>,
89    pub error: MicroError,
90}
91
92/// Two claims on one name within a layer. The first (in file-name order, or
93/// the order of [`MicroRegistry::add`] calls) keeps it.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct Collision {
96    pub layer: Layer,
97    pub name: String,
98    /// The claim that kept the name.
99    pub kept: String,
100    /// The claim that lost it.
101    pub dropped: String,
102}
103
104/// What loading reported. Loading never fails as a whole: a bad package is
105/// rejected and the rest still load.
106#[derive(Clone, Debug, Default, PartialEq, Eq)]
107pub struct LoadReport {
108    pub rejected: Vec<Rejected>,
109    pub collisions: Vec<Collision>,
110    /// A project directory that exists but was not loaded, because the
111    /// project is not trusted.
112    pub untrusted_project: Option<PathBuf>,
113}
114
115#[derive(Clone, Debug, Default)]
116struct LayerAssets {
117    /// Where the layer came from, for `explain`.
118    origin: Option<String>,
119    assets: BTreeMap<String, Arc<MicroAsset>>,
120    /// alias -> asset name in this layer.
121    aliases: BTreeMap<String, String>,
122}
123
124/// Assets by name, in layers. Cheap to clone (assets are shared).
125///
126/// ```
127/// use rich_micro::{Layer, MicroAsset, MicroRegistry};
128///
129/// let mut registry = MicroRegistry::new();
130/// registry.add(Layer::User, MicroAsset::new("ok", "check mark")?.with_text("ok")?)?;
131/// registry.add(Layer::Inline, MicroAsset::new("ok", "tick")?.with_text("v")?)?;
132/// let asset = registry.resolve("ok").unwrap();
133/// assert_eq!(asset.alt(), "tick");
134/// assert_eq!(asset.origin().layer, Layer::Inline);
135/// # Ok::<(), rich_micro::MicroError>(())
136/// ```
137#[derive(Clone, Debug, Default)]
138pub struct MicroRegistry {
139    layers: [LayerAssets; 4],
140}
141
142impl MicroRegistry {
143    /// An empty registry: every layer present, none holding anything.
144    /// [`builtin`](Self::builtin) has the library's built-in set.
145    pub fn new() -> Self {
146        let mut registry = MicroRegistry::default();
147        registry.layers[Layer::BuiltIn.index()].origin = Some("rs-rich-micro".to_string());
148        registry.layers[Layer::Inline.index()].origin = Some("code".to_string());
149        registry
150    }
151
152    /// A registry holding the library's built-in set ([`crate::builtin`])
153    /// in its built-in layer.
154    ///
155    /// ```
156    /// use rich_micro::MicroRegistry;
157    ///
158    /// let registry = MicroRegistry::builtin();
159    /// let loading = registry.require("status/loading")?;
160    /// assert_eq!(loading.license(), Some("MIT"));
161    /// # Ok::<(), rich_micro::MicroError>(())
162    /// ```
163    pub fn builtin() -> Self {
164        let mut registry = MicroRegistry::new();
165        registry.load_builtin_set(&mut LoadReport::default());
166        registry
167    }
168
169    /// Add the library's built-in set to the built-in layer.
170    pub fn load_builtin_set(&mut self, report: &mut LoadReport) {
171        for (pack, loaded) in crate::builtin::PACKS
172            .iter()
173            .zip(crate::builtin::packs(Layer::BuiltIn))
174        {
175            let path = PathBuf::from(format!("builtin:{pack}"));
176            match loaded {
177                Ok(pack) => {
178                    for asset in pack.assets {
179                        report.collisions.extend(self.insert(Layer::BuiltIn, asset));
180                    }
181                    for (package, error) in pack.rejected {
182                        report.rejected.push(Rejected {
183                            layer: Layer::BuiltIn,
184                            path: path.clone(),
185                            package: Some(package),
186                            error,
187                        });
188                    }
189                }
190                Err(error) => report.rejected.push(Rejected {
191                    layer: Layer::BuiltIn,
192                    path,
193                    package: None,
194                    error,
195                }),
196            }
197        }
198    }
199
200    /// Load every file-backed layer in `roots`, and the built-in set when
201    /// `roots.builtin_set`. The project layer loads only when
202    /// `roots.project_trusted`.
203    pub fn load(roots: &MicroRoots, limits: &Limits) -> (Self, LoadReport) {
204        let mut registry = MicroRegistry::new();
205        let mut report = LoadReport::default();
206        if roots.builtin_set {
207            registry.load_builtin_set(&mut report);
208        }
209        if let Some(dir) = &roots.builtin {
210            registry.load_dir(Layer::BuiltIn, dir, limits, &mut report);
211        }
212        if let Some(dir) = &roots.user {
213            registry.load_dir(Layer::User, dir, limits, &mut report);
214        }
215        if let Some(dir) = &roots.project {
216            if roots.project_trusted {
217                registry.load_dir(Layer::Project, dir, limits, &mut report);
218            } else if dir.is_dir() {
219                report.untrusted_project = Some(dir.clone());
220            }
221        }
222        (registry, report)
223    }
224
225    /// Load every package and pack in `dir` into `layer`, in file-name order.
226    /// Entries are package or pack directories and `.richmicro` / `.zip`
227    /// archives; hidden entries and other files are skipped. An entry named
228    /// like an archive that is not a regular file (a FIFO, socket or device)
229    /// is rejected without being opened. A missing directory is an empty
230    /// layer.
231    pub fn load_dir(&mut self, layer: Layer, dir: &Path, limits: &Limits, report: &mut LoadReport) {
232        if layer != Layer::BuiltIn || self.layers[layer.index()].assets.is_empty() {
233            self.layers[layer.index()].origin = Some(dir.display().to_string());
234        }
235        let entries = match std::fs::read_dir(dir) {
236            Ok(entries) => entries,
237            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
238            Err(error) => {
239                report.rejected.push(Rejected {
240                    layer,
241                    path: dir.to_path_buf(),
242                    package: None,
243                    error: MicroError::Io(format!("{}: {error}", dir.display())),
244                });
245                return;
246            }
247        };
248        let mut paths: Vec<PathBuf> = entries
249            .filter_map(|entry| entry.ok().map(|entry| entry.path()))
250            .filter(|path| {
251                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
252                if name.starts_with('.') || name.is_empty() {
253                    return false;
254                }
255                path.is_dir()
256                    || path
257                        .extension()
258                        .and_then(|e| e.to_str())
259                        .is_some_and(|e| e == "richmicro" || e == "zip")
260            })
261            .collect();
262        paths.sort();
263        for path in paths {
264            match package::load(&path, layer, limits) {
265                Ok(Loaded::Package(asset)) => {
266                    report.collisions.extend(self.insert(layer, *asset));
267                }
268                Ok(Loaded::Pack(pack)) => {
269                    for asset in pack.assets {
270                        report.collisions.extend(self.insert(layer, asset));
271                    }
272                    for (package, error) in pack.rejected {
273                        report.rejected.push(Rejected {
274                            layer,
275                            path: path.clone(),
276                            package: Some(package),
277                            error,
278                        });
279                    }
280                }
281                Err(error) => report.rejected.push(Rejected {
282                    layer,
283                    path,
284                    package: None,
285                    error,
286                }),
287            }
288        }
289    }
290
291    /// Add `asset` to `layer` (its origin's layer is set to it). The first
292    /// claim on a name in a layer keeps it: a later one is dropped and
293    /// returned as a [`Collision`], and so is an alias that clashes.
294    pub fn add(&mut self, layer: Layer, asset: MicroAsset) -> Result<Vec<Collision>, MicroError> {
295        asset.validate()?;
296        Ok(self.insert(layer, asset))
297    }
298
299    fn insert(&mut self, layer: Layer, asset: MicroAsset) -> Vec<Collision> {
300        let origin = Origin {
301            layer,
302            ..asset.origin().clone()
303        };
304        let asset = asset.with_origin(origin);
305        let slot = &mut self.layers[layer.index()];
306        let mut collisions = Vec::new();
307        let claim = |name: &str, asset: &MicroAsset| format!("{name} from {}", asset.origin());
308        let name = asset.name().to_string();
309        let existing = slot.assets.get(&name).cloned().or_else(|| {
310            slot.aliases
311                .get(&name)
312                .and_then(|target| slot.assets.get(target).cloned())
313        });
314        if let Some(existing) = existing {
315            collisions.push(Collision {
316                layer,
317                name: name.clone(),
318                kept: claim(existing.name(), &existing),
319                dropped: claim(&name, &asset),
320            });
321            return collisions;
322        }
323        for alias in asset.aliases() {
324            let taken = slot
325                .assets
326                .get(alias)
327                .or_else(|| slot.aliases.get(alias).and_then(|t| slot.assets.get(t)))
328                .cloned();
329            match taken {
330                Some(existing) => collisions.push(Collision {
331                    layer,
332                    name: alias.clone(),
333                    kept: claim(existing.name(), &existing),
334                    dropped: format!("alias {alias} of {}", claim(&name, &asset)),
335                }),
336                None => {
337                    slot.aliases.insert(alias.clone(), name.clone());
338                }
339            }
340        }
341        slot.assets.insert(name, Arc::new(asset));
342        collisions
343    }
344
345    /// Remove `name` (not an alias) from `layer`, with its aliases.
346    pub fn remove(&mut self, layer: Layer, name: &str) -> Option<Arc<MicroAsset>> {
347        let slot = &mut self.layers[layer.index()];
348        let removed = slot.assets.remove(name)?;
349        slot.aliases.retain(|_, target| target != name);
350        Some(removed)
351    }
352
353    /// The asset `name` (or an alias) means in `layer` alone.
354    pub fn get(&self, layer: Layer, name: &str) -> Option<&Arc<MicroAsset>> {
355        let slot = &self.layers[layer.index()];
356        slot.assets.get(name).or_else(|| {
357            slot.aliases
358                .get(name)
359                .and_then(|target| slot.assets.get(target))
360        })
361    }
362
363    /// The asset `name` resolves to: the highest layer that has it.
364    pub fn resolve(&self, name: &str) -> Option<&Arc<MicroAsset>> {
365        Layer::ALL
366            .iter()
367            .rev()
368            .find_map(|layer| self.get(*layer, name))
369    }
370
371    /// [`resolve`](Self::resolve), with an error that names the asset.
372    pub fn require(&self, name: &str) -> Result<&Arc<MicroAsset>, MicroError> {
373        check_name(name)?;
374        self.resolve(name)
375            .ok_or_else(|| MicroError::UnknownAsset(name.to_string()))
376    }
377
378    /// Every lower layer's claim on `name` that the winner hides, highest
379    /// first.
380    pub fn shadowed(&self, name: &str) -> Vec<&Arc<MicroAsset>> {
381        let mut claims = Layer::ALL
382            .iter()
383            .rev()
384            .filter_map(|layer| self.get(*layer, name));
385        claims.next();
386        claims.collect()
387    }
388
389    /// Every effective asset name (not aliases), sorted.
390    pub fn names(&self) -> Vec<&str> {
391        let mut names: Vec<&str> = self
392            .layers
393            .iter()
394            .flat_map(|slot| slot.assets.keys().map(String::as_str))
395            .collect();
396        names.sort_unstable();
397        names.dedup();
398        names
399    }
400
401    /// Every effective asset, by name.
402    pub fn assets(&self) -> Vec<&Arc<MicroAsset>> {
403        self.names()
404            .into_iter()
405            .filter_map(|name| self.resolve(name))
406            .collect()
407    }
408
409    /// The assets in one layer, by name.
410    pub fn layer(&self, layer: Layer) -> impl Iterator<Item = &Arc<MicroAsset>> {
411        self.layers[layer.index()].assets.values()
412    }
413
414    /// Every layer as a [`Precedence`]: one key per name or alias, its value
415    /// the asset it stands for and where that came from.
416    pub fn precedence(&self) -> Precedence {
417        let mut precedence = Precedence::new();
418        for layer in Layer::ALL {
419            let slot = &self.layers[layer.index()];
420            let mut entry = PrecedenceLayer::new(layer.as_str());
421            if let Some(origin) = &slot.origin {
422                // A layer directory's path is the filesystem's, and may hold
423                // terminal controls: shown, never run.
424                entry = entry.origin(rich_ext::sanitize_terminal_controls(origin));
425            }
426            let mut values: BTreeMap<&str, String> = BTreeMap::new();
427            for (name, asset) in &slot.assets {
428                values.insert(name, describe(asset));
429            }
430            for (alias, target) in &slot.aliases {
431                values.insert(alias, format!("alias of {target}"));
432            }
433            for (key, value) in values {
434                entry = entry.value(key, value);
435            }
436            precedence = precedence.layer(entry);
437        }
438        precedence
439    }
440
441    /// How `name` resolved: the winning layer first, then what it overrides.
442    /// A renderable; `None` when no layer has the name.
443    pub fn explain(&self, name: &str) -> Option<Explanation> {
444        self.precedence().explain(name)
445    }
446}
447
448fn describe(asset: &MicroAsset) -> String {
449    let mut out = format!("{} {}", asset.name(), asset.size());
450    if let Some(version) = asset.version() {
451        out.push_str(&format!(" v{version}"));
452    }
453    if let Some(pack) = &asset.origin().pack {
454        out.push_str(&format!(" (pack {pack})"));
455    }
456    out
457}