1use 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
41pub(crate) const MANIFEST: &str = "profile.toml";
43
44pub(crate) const PAYLOAD: &str = "files";
46
47#[derive(Clone, Debug, Deserialize, JsonSchema)]
49#[serde(deny_unknown_fields)]
50pub struct Manifest {
51 pub profile: Meta,
53 #[serde(default)]
55 #[schemars(with = "BTreeMap<ProfileName, RequirementSpec>")]
56 pub requires: BTreeMap<ProfileName, Requirement>,
57 #[serde(default)]
59 pub features: Features,
60 #[serde(default)]
62 pub vars: BTreeMap<VarName, VarSpec>,
63 #[serde(default)]
65 pub merge: MergeSpec,
66 #[serde(default)]
68 pub scaffolds: BTreeMap<ScaffoldName, ScaffoldSpec>,
69 #[serde(default)]
71 pub files: BTreeMap<RelPath, FileSpec>,
72}
73
74#[derive(Clone, Debug, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct Meta {
78 pub name: ProfileName,
80 #[serde(default)]
82 pub version: Option<String>,
83 #[serde(default)]
85 pub description: Option<String>,
86 #[serde(default)]
88 #[schemars(with = "Option<String>")]
89 pub devset: Option<VersionReq>,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
96#[serde(try_from = "RequirementSpec")]
97pub struct Requirement {
98 pub features: Vec<FeatureName>,
100 pub default_features: bool,
102 pub optional: bool,
104 pub source: Option<Source>,
106}
107
108#[derive(Debug, Deserialize, JsonSchema)]
110#[serde(deny_unknown_fields, rename_all = "kebab-case")]
111struct RequirementSpec {
112 #[serde(default)]
114 features: Vec<FeatureName>,
115 #[serde(default = "yes")]
117 default_features: bool,
118 #[serde(default)]
120 optional: bool,
121 #[serde(default)]
123 git: Option<String>,
124 #[serde(default)]
126 tag: Option<String>,
127 #[serde(default)]
129 branch: Option<String>,
130 #[serde(default)]
132 rev: Option<String>,
133 #[serde(default)]
135 path: Option<String>,
136}
137
138const 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#[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 pub default: Vec<FeatureName>,
173 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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, DeserializeFromStr)]
197pub enum FeatureRef {
198 Feature(FeatureName),
200 Dep(ProfileName),
202 Of {
205 profile: ProfileName,
207 feature: FeatureName,
209 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#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
244#[serde(deny_unknown_fields)]
245pub struct ScaffoldSpec {
246 #[serde(default)]
250 #[schemars(with = "Patterns")]
251 pub unless: Patterns,
252}
253
254#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, JsonSchema)]
256#[serde(from = "OneOrMany")]
257pub struct Patterns(pub Vec<String>);
258
259#[derive(Deserialize, JsonSchema)]
261#[serde(untagged)]
262enum OneOrMany {
263 One(String),
265 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#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
280#[serde(deny_unknown_fields)]
281pub struct FileSpec {
282 #[serde(default)]
284 pub policy: Option<Policy>,
285 #[serde(default)]
287 pub validate: Option<Format>,
288 #[serde(default)]
290 pub template: bool,
291 #[serde(default)]
293 pub scope: Scope,
294 #[serde(default)]
296 pub comment: Option<Comment>,
297 #[serde(default)]
299 pub executable: bool,
300 #[serde(default)]
302 pub scaffold: Option<ScaffoldName>,
303 #[serde(default)]
305 pub starter: Option<RelPath>,
306 #[serde(default)]
308 pub when: When,
309}
310
311#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, JsonSchema)]
313#[serde(deny_unknown_fields)]
314pub struct When {
315 #[serde(default)]
317 pub features: Vec<FeatureName>,
318 #[serde(default)]
320 pub profiles: Vec<ProfileName>,
321 #[serde(default)]
323 pub vars: BTreeMap<VarName, Vec<String>>,
324 #[serde(default)]
327 pub exists: Vec<String>,
328}
329
330#[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 #[default]
339 Owned,
340 Merge,
342 Once,
344}
345
346impl Policy {
347 #[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 #[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 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 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 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 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 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}