Skip to main content

devset_core/
profile.rs

1//! What a profile declares, in its `profile.toml`.
2//!
3//! A profile is a directory in a source: `profile.toml`, and `files/` mirroring the target. Every
4//! path `[files]` lists must exist under `files/`; nothing else there is applied. An entry owns its
5//! whole file unless its [`Scope`] says less, when its payload is the part: a partial document, or
6//! a block's lines. A path may use variables, `crates/{{ name }}/Cargo.toml`, and its payload is
7//! found under `files/` as written.
8//!
9//! ```text
10//! mdbook/
11//! ├── profile.toml
12//! └── files/
13//!     ├── {{ book_dir }}/book.toml
14//!     └── {{ book_dir }}/theme/katex.css
15//! ```
16//!
17//! A profile builds on others by name, `[requires]`, and offers features, `[features]`, which add
18//! files, parts, requirements and features of what it requires; an entry applies only when its
19//! [`When`] holds. A scaffold, `[scaffolds]`, is a group of starter files written once, when none
20//! of its sentinels is there.
21
22use alloc::collections::{BTreeMap, BTreeSet};
23use core::fmt;
24use core::str::FromStr;
25
26use derive_more::Display;
27use schemars::JsonSchema;
28use semver::VersionReq;
29use serde::{Deserialize, Serialize};
30use serde_with::DeserializeFromStr;
31
32use crate::errors::{NameError, ProfileError, Result};
33pub use crate::format::Format;
34pub use crate::name::{FeatureName, ProfileName, ScaffoldName};
35pub use crate::part::{Comment, Scope};
36use crate::path::RelPath;
37pub use crate::settings::{MergeSpec, OnConflict};
38use crate::source::{Source, SourceSpec};
39pub use crate::vars::{VarName, VarSpec};
40
41/// A profile's manifest, at its root.
42pub(crate) const MANIFEST: &str = "profile.toml";
43
44/// The directory holding a profile's payload, mirroring the target.
45pub(crate) const PAYLOAD: &str = "files";
46
47/// `profile.toml`.
48#[derive(Clone, Debug, Deserialize, JsonSchema)]
49#[serde(deny_unknown_fields)]
50pub struct Manifest {
51    /// Identity.
52    pub profile: Meta,
53    /// Profiles this one builds on, by name: each a layer before it.
54    #[serde(default)]
55    #[schemars(with = "BTreeMap<ProfileName, RequirementSpec>")]
56    pub requires: BTreeMap<ProfileName, Requirement>,
57    /// What a target or a requirer may turn on.
58    #[serde(default)]
59    pub features: Features,
60    /// Variables templates and paths use; the target answers them.
61    #[serde(default)]
62    pub vars: BTreeMap<VarName, VarSpec>,
63    /// Merge defaults.
64    #[serde(default)]
65    pub merge: MergeSpec,
66    /// Groups of starter files, each written once, when none of its sentinels is there.
67    #[serde(default)]
68    pub scaffolds: BTreeMap<ScaffoldName, ScaffoldSpec>,
69    /// Managed files, by path in the target, as found under `files/`; a path may use variables.
70    #[serde(default)]
71    pub files: BTreeMap<RelPath, FileSpec>,
72}
73
74/// The `[profile]` table.
75#[derive(Clone, Debug, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct Meta {
78    /// Names the profile: in its source, among the layers, in output, and in `from`.
79    pub name: ProfileName,
80    /// For humans; a git source is pinned by commit.
81    #[serde(default)]
82    pub version: Option<String>,
83    /// One line for humans.
84    #[serde(default)]
85    pub description: Option<String>,
86    /// The devset versions the profile works with.
87    #[serde(default)]
88    #[schemars(with = "Option<String>")]
89    pub devset: Option<VersionReq>,
90}
91
92/// A profile another builds on, keyed by its name in `[requires]`.
93///
94/// In the requirer's own source unless it names another by `git`.
95#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
96#[serde(try_from = "RequirementSpec")]
97pub struct Requirement {
98    /// Its features to turn on.
99    pub features: Vec<FeatureName>,
100    /// Whether its default features are on; unless a requirer or the target turns them on.
101    pub default_features: bool,
102    /// Whether it applies only when a feature activates it, with `dep:` or `name/feature`.
103    pub optional: bool,
104    /// Another source it comes from; `None` for the requirer's own.
105    pub source: Option<Source>,
106}
107
108/// A requirement as written.
109#[derive(Debug, Deserialize, JsonSchema)]
110#[serde(deny_unknown_fields, rename_all = "kebab-case")]
111struct RequirementSpec {
112    /// Its features to turn on.
113    #[serde(default)]
114    features: Vec<FeatureName>,
115    /// Whether its default features are on; `true` unless set.
116    #[serde(default = "yes")]
117    default_features: bool,
118    /// Whether it applies only when a feature activates it.
119    #[serde(default)]
120    optional: bool,
121    /// The repository of another source.
122    #[serde(default)]
123    git: Option<String>,
124    /// Its tag; only with `git`.
125    #[serde(default)]
126    tag: Option<String>,
127    /// Its branch; only with `git`.
128    #[serde(default)]
129    branch: Option<String>,
130    /// Its commit; only with `git`.
131    #[serde(default)]
132    rev: Option<String>,
133    /// The directory in the repository its profiles are in; only with `git`.
134    #[serde(default)]
135    path: Option<String>,
136}
137
138/// `true`, serde's default for a flag that is on unless set.
139const fn yes() -> bool {
140    true
141}
142
143impl TryFrom<RequirementSpec> for Requirement {
144    type Error = String;
145
146    fn try_from(spec: RequirementSpec) -> Result<Self, String> {
147        let RequirementSpec { features, default_features, optional, git, tag, branch, rev, path } =
148            spec;
149        let located = tag.is_some() || branch.is_some() || rev.is_some() || path.is_some();
150        let source = match git {
151            Some(git) => {
152                let spec = SourceSpec { git: Some(git), tag, branch, rev, path };
153                Some(Source::try_from(spec).map_err(|e| e.to_string())?)
154            },
155            None if located => {
156                return Err("`tag`, `branch`, `rev` and `path` name another source, with `git`; \
157                            a profile in this one is required by name alone"
158                    .to_owned());
159            },
160            None => None,
161        };
162        Ok(Self { features, default_features, optional, source })
163    }
164}
165
166/// The `[features]` table: `default`, and every feature with what it turns on.
167#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
168#[serde(try_from = "BTreeMap<String, Vec<String>>")]
169#[schemars(with = "BTreeMap<String, Vec<String>>")]
170pub struct Features {
171    /// The features on unless a requirer or the target sets `default-features = false`.
172    pub default: Vec<FeatureName>,
173    /// Every feature, and what turning it on turns on.
174    pub declared: BTreeMap<FeatureName, Vec<FeatureRef>>,
175}
176
177impl TryFrom<BTreeMap<String, Vec<String>>> for Features {
178    type Error = NameError;
179
180    fn try_from(table: BTreeMap<String, Vec<String>>) -> Result<Self, NameError> {
181        let mut features = Self::default();
182        for (name, list) in table {
183            if name == "default" {
184                features.default =
185                    list.into_iter().map(FeatureName::try_from).collect::<Result<_, _>>()?;
186            } else {
187                let refs = list.iter().map(|each| each.parse()).collect::<Result<_, _>>()?;
188                features.declared.insert(FeatureName::try_from(name)?, refs);
189            }
190        }
191        Ok(features)
192    }
193}
194
195/// What a feature turns on.
196#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, DeserializeFromStr)]
197pub enum FeatureRef {
198    /// Another feature of the profile: `katex`.
199    Feature(FeatureName),
200    /// An optional requirement, activated: `dep:lychee`.
201    Dep(ProfileName),
202    /// A feature of a requirement: `github-ci/pages`, which activates it too; or, when `weak`,
203    /// `mdbook?/agents`, only if it is active by other means.
204    Of {
205        /// The requirement.
206        profile: ProfileName,
207        /// Its feature.
208        feature: FeatureName,
209        /// Whether it leaves the requirement inactive.
210        weak: bool,
211    },
212}
213
214impl FromStr for FeatureRef {
215    type Err = NameError;
216
217    fn from_str(written: &str) -> Result<Self, NameError> {
218        if let Some(profile) = written.strip_prefix("dep:") {
219            return Ok(Self::Dep(profile.parse()?));
220        }
221        let Some((profile, feature)) = written.split_once('/') else {
222            return Ok(Self::Feature(written.parse()?));
223        };
224        let (profile, weak) =
225            profile.strip_suffix('?').map_or((profile, false), |profile| (profile, true));
226        Ok(Self::Of { profile: profile.parse()?, feature: feature.parse()?, weak })
227    }
228}
229
230impl fmt::Display for FeatureRef {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            Self::Feature(feature) => write!(f, "{feature}"),
234            Self::Dep(profile) => write!(f, "dep:{profile}"),
235            Self::Of { profile, feature, weak } => {
236                write!(f, "{profile}{}/{feature}", if *weak { "?" } else { "" })
237            },
238        }
239    }
240}
241
242/// A `[scaffolds.name]` entry: a group of starter files, written together once.
243#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
244#[serde(deny_unknown_fields)]
245pub struct ScaffoldSpec {
246    /// Paths or globs of which any, there before the run, means the target has its own: the group
247    /// is not written. A string or a list; none writes the group when the profile is first
248    /// applied.
249    #[serde(default)]
250    #[schemars(with = "Patterns")]
251    pub unless: Patterns,
252}
253
254/// Paths or globs in the target: a string, or a list of them.
255#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, JsonSchema)]
256#[serde(from = "OneOrMany")]
257pub struct Patterns(pub Vec<String>);
258
259/// One pattern, or several.
260#[derive(Deserialize, JsonSchema)]
261#[serde(untagged)]
262enum OneOrMany {
263    /// One.
264    One(String),
265    /// Several.
266    Many(Vec<String>),
267}
268
269impl From<OneOrMany> for Patterns {
270    fn from(written: OneOrMany) -> Self {
271        match written {
272            OneOrMany::One(one) => Self(vec![one]),
273            OneOrMany::Many(many) => Self(many),
274        }
275    }
276}
277
278/// A profile's `[files."path"]` entry.
279#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
280#[serde(deny_unknown_fields)]
281pub struct FileSpec {
282    /// How devset manages the file; `owned` unless set, and `once` in a scaffold.
283    #[serde(default)]
284    pub policy: Option<Policy>,
285    /// How a merged result is checked; inferred from the extension unless set.
286    #[serde(default)]
287    pub validate: Option<Format>,
288    /// Whether the file is a template, rendered with the answers and the `devset` object.
289    #[serde(default)]
290    pub template: bool,
291    /// How much of the file devset owns: all of it, the keys the payload defines, or a block.
292    #[serde(default)]
293    pub scope: Scope,
294    /// The comment syntax of a block's markers, where devset does not know the file type's.
295    #[serde(default)]
296    pub comment: Option<Comment>,
297    /// Whether devset writes the file executable, mode 755, as a script it ships must be.
298    #[serde(default)]
299    pub executable: bool,
300    /// The scaffold group the file belongs to, written with it.
301    #[serde(default)]
302    pub scaffold: Option<ScaffoldName>,
303    /// For a part, a file under `files/` the target's file starts from when it is absent.
304    #[serde(default)]
305    pub starter: Option<RelPath>,
306    /// When the entry applies; always, unless set.
307    #[serde(default)]
308    pub when: When,
309}
310
311/// When an entry applies: every condition holds.
312#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, JsonSchema)]
313#[serde(deny_unknown_fields)]
314pub struct When {
315    /// Features of this profile that are on.
316    #[serde(default)]
317    pub features: Vec<FeatureName>,
318    /// Profiles active in the target.
319    #[serde(default)]
320    pub profiles: Vec<ProfileName>,
321    /// Variables of this profile, each answered with one of its values.
322    #[serde(default)]
323    pub vars: BTreeMap<VarName, Vec<String>>,
324    /// Paths or globs that will exist when the run finishes, whole files or parts; may use
325    /// variables.
326    #[serde(default)]
327    pub exists: Vec<String>,
328}
329
330/// How devset manages a file.
331#[derive(
332    Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Display,
333)]
334#[serde(rename_all = "lowercase")]
335#[display(rename_all = "lowercase")]
336pub enum Policy {
337    /// The profile is authoritative; local edits are drift.
338    #[default]
339    Owned,
340    /// Local edits are kept, and merged with the profile's.
341    Merge,
342    /// Written once if absent; the target's from then on.
343    Once,
344}
345
346impl Policy {
347    /// Whether it is [`Policy::Owned`], the default.
348    #[expect(
349        clippy::trivially_copy_pass_by_ref,
350        reason = "serde's `skip_serializing_if` passes a reference"
351    )]
352    pub(crate) const fn is_owned(&self) -> bool {
353        matches!(self, Self::Owned)
354    }
355}
356
357impl FileSpec {
358    /// Its policy as written, `once` in a scaffold, or `owned`.
359    #[must_use]
360    pub fn policy(&self) -> Policy {
361        let default = if self.scaffold.is_some() { Policy::Once } else { Policy::Owned };
362        self.policy.unwrap_or(default)
363    }
364}
365
366impl Manifest {
367    /// Checks what the manifest says of itself: every feature, requirement, scaffold and variable
368    /// it names is one it declares, and every entry is one devset can apply.
369    ///
370    /// # Errors
371    /// [`ProfileError::Manifest`], naming what is wrong in `file`.
372    pub(crate) fn check(&self, file: &str) -> Result<()> {
373        let invalid = |message: String| ProfileError::Manifest { file: file.to_owned(), message };
374        let features = &self.features;
375        for feature in &features.default {
376            if !features.declared.contains_key(feature) {
377                return Err(invalid(format!(
378                    "`default` lists `{feature}`, which [features] lacks"
379                ))
380                .into());
381            }
382        }
383        for (feature, refs) in &features.declared {
384            for each in refs {
385                self.check_ref(each).map_err(|why| {
386                    invalid(format!("feature `{feature}` turns on `{each}`, but {why}"))
387                })?;
388            }
389        }
390        for (path, spec) in &self.files {
391            self.check_entry(spec).map_err(|why| invalid(format!("[files.\"{path}\"]: {why}")))?;
392        }
393        Ok(())
394    }
395
396    /// Why `each`, in a feature's list, names nothing this manifest declares.
397    fn check_ref(&self, each: &FeatureRef) -> Result<(), String> {
398        match each {
399            FeatureRef::Feature(feature) if !self.features.declared.contains_key(feature) => {
400                Err(format!("[features] lacks `{feature}`"))
401            },
402            FeatureRef::Dep(profile) => match self.requires.get(profile) {
403                None => Err(format!("[requires] lacks `{profile}`")),
404                Some(requirement) if !requirement.optional => {
405                    Err(format!("`{profile}` is not an optional requirement"))
406                },
407                Some(_) => Ok(()),
408            },
409            FeatureRef::Of { profile, .. } if !self.requires.contains_key(profile) => {
410                Err(format!("[requires] lacks `{profile}`"))
411            },
412            FeatureRef::Feature(_) | FeatureRef::Of { .. } => Ok(()),
413        }
414    }
415
416    /// Why `spec` cannot be applied as written.
417    fn check_entry(&self, spec: &FileSpec) -> Result<(), String> {
418        let when = &spec.when;
419        if let Some(feature) =
420            when.features.iter().find(|f| !self.features.declared.contains_key(*f))
421        {
422            return Err(format!("`when` names feature `{feature}`, which [features] lacks"));
423        }
424        if let Some(name) = when.vars.keys().find(|name| !self.vars.contains_key(*name)) {
425            return Err(format!("`when` names variable `{name}`, which [vars] lacks"));
426        }
427        if let Some(group) = &spec.scaffold {
428            if !self.scaffolds.contains_key(group) {
429                return Err(format!("scaffold `{group}` is not in [scaffolds]"));
430            }
431            if spec.scope != Scope::File || spec.policy.is_some_and(|p| p != Policy::Once) {
432                return Err("a scaffold's file is a whole file, `once`".to_owned());
433            }
434        }
435        if spec.starter.is_some() && spec.scope == Scope::File {
436            return Err("`starter` is for a part: a whole file is its own".to_owned());
437        }
438        Ok(())
439    }
440
441    /// Every payload file the manifest names, as written: each entry's, and each starter.
442    pub(crate) fn payload_paths(&self) -> BTreeSet<RelPath> {
443        let starters = self.files.values().filter_map(|spec| spec.starter.clone());
444        self.files.keys().cloned().chain(starters).collect()
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::{FeatureRef, Manifest};
451
452    /// The manifest `toml`, checked: its error as text if either step fails.
453    fn read(toml: &str) -> Result<Manifest, String> {
454        let manifest: Manifest = toml::from_str(toml).map_err(|e| e.message().to_owned())?;
455        manifest.check("profile.toml").map_err(|e| e.to_string())?;
456        Ok(manifest)
457    }
458
459    const MDBOOK: &str = r#"
460[profile]
461name = "mdbook"
462
463[requires]
464just = {}
465lychee = { optional = true }
466github-ci = { optional = true, features = ["pages"], default-features = false }
467extern = { git = "https://example.com/p", tag = "v1" }
468
469[features]
470default = ["katex", "links"]
471katex = []
472api = ["katex"]
473pages = ["github-ci/pages"]
474links = ["dep:lychee"]
475agents = ["github-ci?/agents"]
476
477[vars.book_dir]
478default = "docs"
479
480[scaffolds.book]
481unless = "{{ book_dir }}/book.toml"
482
483[files."{{ book_dir }}/SUMMARY.md"]
484scaffold = "book"
485
486[files."{{ book_dir }}/book.toml"]
487scope = "keys"
488starter = "book.starter.toml"
489template = true
490
491[files."{{ book_dir }}/theme/api-link.js"]
492when = { features = ["api"], vars = { book_dir = ["docs"] }, exists = ["**/*.rs"] }
493"#;
494
495    #[test]
496    fn a_full_manifest_reads() {
497        let manifest = read(MDBOOK).expect("valid");
498        let features = &manifest.features;
499        assert_eq!(features.default.len(), 2, "the default list");
500        assert_eq!(features.declared.len(), 5, "every feature but `default`");
501        let github = manifest.requires.iter().find(|(name, _)| name.as_str() == "github-ci");
502        let github = github.expect("required").1;
503        assert!(github.optional && !github.default_features, "its flags");
504        assert!(
505            manifest.requires.iter().any(|(_, r)| r.source.is_some()),
506            "one names another source"
507        );
508        assert_eq!(manifest.payload_paths().len(), 4, "every entry's file, and the starter");
509    }
510
511    #[test]
512    fn feature_refs() {
513        for (written, want) in [
514            ("katex", "Feature"),
515            ("dep:lychee", "Dep"),
516            ("github-ci/pages", "Of"),
517            ("mdbook?/agents", "Weak"),
518        ] {
519            let parsed: FeatureRef = written.parse().expect("a reference");
520            let kind = match &parsed {
521                FeatureRef::Feature(_) => "Feature",
522                FeatureRef::Dep(_) => "Dep",
523                FeatureRef::Of { weak: false, .. } => "Of",
524                FeatureRef::Of { weak: true, .. } => "Weak",
525            };
526            assert_eq!(kind, want, "{written}");
527            assert_eq!(parsed.to_string(), written, "written as read");
528        }
529        for bad in ["dep:", "a/b/c", "?x", "a?/", "x?"] {
530            assert!(bad.parse::<FeatureRef>().is_err(), "{bad:?} is not a reference");
531        }
532    }
533
534    #[test]
535    fn mistakes_are_named() {
536        let base = "[profile]\nname = \"p\"\n";
537        for (tail, says) in [
538            ("[features]\ndefault = [\"x\"]\n", "`default` lists `x`"),
539            ("[features]\na = [\"b\"]\n", "[features] lacks `b`"),
540            ("[features]\na = [\"dep:q\"]\n", "[requires] lacks `q`"),
541            ("[requires]\nq = {}\n[features]\na = [\"dep:q\"]\n", "not an optional requirement"),
542            ("[features]\na = [\"q/f\"]\n", "[requires] lacks `q`"),
543            ("[files.\"a\"]\nwhen = { features = [\"x\"] }\n", "feature `x`"),
544            ("[files.\"a\"]\nwhen = { vars = { v = [\"1\"] } }\n", "variable `v`"),
545            ("[files.\"a\"]\nscaffold = \"s\"\n", "scaffold `s` is not"),
546            ("[scaffolds.s]\n[files.\"a\"]\nscaffold = \"s\"\npolicy = \"owned\"\n", "`once`"),
547            ("[scaffolds.s]\n[files.\"a\"]\nscaffold = \"s\"\nscope = \"keys\"\n", "`once`"),
548            ("[files.\"a\"]\nstarter = \"s\"\n", "`starter` is for a part"),
549            ("[vars.devset]\n", "reserved"),
550            ("[requires]\nq = { tag = \"v1\" }\n", "with `git`"),
551            ("[requires]\nq = { path = \"x\" }\n", "with `git`"),
552        ] {
553            let error = read(&format!("{base}{tail}")).expect_err(tail);
554            assert!(error.contains(says), "{tail}: {error}");
555        }
556    }
557}