Skip to main content

devset_core/
collection.rs

1//! A source's profiles, found by name wherever they are in it, and what `collection.toml` says of
2//! the source.
3//!
4//! Every `profile.toml` in a source is a profile, named by its `[profile] name`, unless it lies in
5//! another profile's `files/`, where it is that profile's payload. Names are unique in a source, so
6//! moving a profile within one breaks no target.
7
8use alloc::collections::BTreeMap;
9use core::slice;
10
11use camino::Utf8Path;
12use schemars::JsonSchema;
13use serde::Deserialize;
14
15use crate::errors::{ProfileError, Result};
16use crate::name::{FeatureName, ProfileName, SourceName};
17use crate::path::RelPath;
18use crate::profile::{MANIFEST, Manifest, PAYLOAD};
19use crate::source::{Cache, Oid, Reader, Source};
20use crate::target::from_toml;
21
22/// The file at a source's root that describes it.
23pub(crate) const COLLECTION: &str = "collection.toml";
24
25/// `collection.toml`: what a source says of itself.
26#[derive(Clone, Debug, Deserialize, JsonSchema)]
27#[serde(deny_unknown_fields)]
28pub struct CollectionFile {
29    /// The `[collection]` table.
30    pub collection: CollectionMeta,
31}
32
33/// The `[collection]` table.
34#[derive(Clone, Debug, Deserialize, JsonSchema)]
35#[serde(deny_unknown_fields)]
36pub struct CollectionMeta {
37    /// The name a target gives the source unless it chooses another.
38    pub name: SourceName,
39    /// One line for humans.
40    #[serde(default)]
41    pub description: Option<String>,
42}
43
44/// Just enough of a manifest to name its profile.
45#[derive(Deserialize)]
46struct Head {
47    /// The `[profile]` table.
48    profile: HeadMeta,
49}
50
51/// Just enough of `[profile]`.
52#[derive(Deserialize)]
53struct HeadMeta {
54    /// The profile's name.
55    name: ProfileName,
56}
57
58/// A source's profiles by name, each with its directory in the source.
59#[derive(Debug)]
60pub(crate) struct Index {
61    /// What `collection.toml` says, if the source has one.
62    meta: Option<CollectionMeta>,
63    /// Each profile's directory, relative to the source's root; `""` for the root.
64    profiles: BTreeMap<ProfileName, String>,
65}
66
67impl Index {
68    /// The profiles `reader` holds; `source` names it in messages.
69    ///
70    /// # Errors
71    /// - [`ProfileError::SameNameInSource`], two profiles have one name.
72    /// - [`Error::Parse`](crate::Error::Parse), a manifest, or `collection.toml`, does not parse.
73    pub(crate) fn read(reader: &Reader, source: &Source) -> Result<Self> {
74        let dirs = profiles(reader.manifests());
75        let paths = dirs.iter().map(|dir| manifest(dir)).collect::<Result<Vec<_>>>()?;
76        let found = reader.read("", &paths)?;
77        let mut profiles = BTreeMap::new();
78        for (dir, path) in dirs.into_iter().zip(&paths) {
79            let Some(raw) = found.get(path) else { continue };
80            let Head { profile } = from_toml(raw, &label(source, path.as_str()))?;
81            if let Some(first) = profiles.insert(profile.name.clone(), dir.clone()) {
82                let (source, name, second) = (source.to_string(), profile.name, dir);
83                return Err(ProfileError::SameNameInSource {
84                    location: source,
85                    name,
86                    first,
87                    second,
88                }
89                .into());
90            }
91        }
92        let collection = RelPath::new(COLLECTION)?;
93        let meta = reader.read("", slice::from_ref(&collection))?;
94        let meta = meta
95            .get(&collection)
96            .map(|raw| from_toml::<CollectionFile>(raw, &label(source, COLLECTION)))
97            .transpose()?
98            .map(|file| file.collection);
99        Ok(Self { meta, profiles })
100    }
101
102    /// Every profile's name, in order.
103    pub(crate) fn names(&self) -> impl Iterator<Item = &ProfileName> {
104        self.profiles.keys()
105    }
106
107    /// The directory of the profile named `name`.
108    ///
109    /// # Errors
110    /// [`ProfileError::NotInSource`], the source has none, with every name it does have.
111    pub(crate) fn find(&self, name: &ProfileName, source: &Source) -> Result<&str> {
112        self.profiles.get(name).map(String::as_str).ok_or_else(|| {
113            let profiles = self.profiles.keys().cloned().collect();
114            ProfileError::NotInSource { location: source.to_string(), name: name.clone(), profiles }
115                .into()
116        })
117    }
118}
119
120/// Of `dirs`, every one holding a `profile.toml`, those that are profiles: not in another's
121/// `files/`.
122fn profiles(mut dirs: Vec<String>) -> Vec<String> {
123    dirs.sort();
124    let mut kept: Vec<String> = Vec::with_capacity(dirs.len());
125    for dir in dirs {
126        let payload = kept.iter().any(|profile| {
127            let files = if profile.is_empty() {
128                format!("{PAYLOAD}/")
129            } else {
130                format!("{profile}/{PAYLOAD}/")
131            };
132            dir.starts_with(&files)
133        });
134        if !payload {
135            kept.push(dir);
136        }
137    }
138    kept
139}
140
141/// The manifest of the profile in `dir`.
142fn manifest(dir: &str) -> Result<RelPath> {
143    Ok(if dir.is_empty() {
144        RelPath::new(MANIFEST)?
145    } else {
146        RelPath::new(&format!("{dir}/{MANIFEST}"))?
147    })
148}
149
150/// Where `file`, relative to `source`'s root, is, as users recognise it.
151pub(crate) fn label(source: &Source, file: &str) -> String {
152    match source {
153        Source::Dir(dir) => dir.join(file).into_string(),
154        Source::Git { url, path, .. } => path
155            .as_ref()
156            .map_or_else(|| format!("{url}/{file}"), |path| format!("{url}/{path}/{file}")),
157    }
158}
159
160/// A source's profiles, as `devset list` shows them.
161#[derive(Debug)]
162pub struct Listing {
163    /// What `collection.toml` says, if the source has one.
164    pub meta: Option<CollectionMeta>,
165    /// Every profile, by name.
166    pub profiles: Vec<Summary>,
167}
168
169/// One profile in a [`Listing`].
170#[derive(Debug)]
171pub struct Summary {
172    /// Its manifest.
173    pub manifest: Manifest,
174    /// Its directory in the source.
175    pub dir: String,
176}
177
178impl Summary {
179    /// Its features, each marked whether it is on by default.
180    pub fn features(&self) -> impl Iterator<Item = (&FeatureName, bool)> {
181        let features = &self.manifest.features;
182        features.declared.keys().map(|name| (name, features.default.contains(name)))
183    }
184}
185
186/// The profiles `source` holds, read with `root` as the target's root: at `pin` if given, else at
187/// what its ref names now.
188///
189/// # Errors
190/// - [`Error::Source`](crate::Error::Source), the source cannot be read.
191/// - [`Error::Parse`](crate::Error::Parse) or [`ProfileError`], a manifest is not valid.
192pub fn list(source: &Source, root: &Utf8Path, pin: Option<&Oid>, cache: &Cache) -> Result<Listing> {
193    let reader = source.open(root, pin, cache)?;
194    let Index { meta, profiles } = Index::read(&reader, source)?;
195    let paths = profiles.values().map(|dir| manifest(dir)).collect::<Result<Vec<_>>>()?;
196    let found = reader.read("", &paths)?;
197    let mut listed = Vec::with_capacity(paths.len());
198    for (dir, path) in profiles.into_values().zip(&paths) {
199        let Some(raw) = found.get(path) else { continue };
200        let file = label(source, path.as_str());
201        let manifest: Manifest = from_toml(raw, &file)?;
202        manifest.check(&file)?;
203        listed.push(Summary { manifest, dir });
204    }
205    Ok(Listing { meta, profiles: listed })
206}
207
208#[cfg(test)]
209mod tests {
210    use super::profiles;
211
212    #[test]
213    fn a_manifest_in_a_payload_is_not_a_profile() {
214        let found = profiles(
215            ["b", "a", "a/files/x", "a/extra", "files/y", "c/files", ""]
216                .map(str::to_owned)
217                .to_vec(),
218        );
219        assert_eq!(
220            found,
221            ["", "a", "a/extra", "b", "c/files"],
222            "files/ under a profile is its payload"
223        );
224    }
225}