use alloc::collections::{BTreeMap, BTreeSet};
use core::fmt;
use core::str::FromStr;
use derive_more::Display;
use schemars::JsonSchema;
use semver::VersionReq;
use serde::{Deserialize, Serialize};
use serde_with::DeserializeFromStr;
use crate::errors::{NameError, ProfileError, Result};
pub use crate::format::Format;
pub use crate::name::{FeatureName, ProfileName, ScaffoldName};
pub use crate::part::{Comment, Scope};
use crate::path::RelPath;
pub use crate::settings::{MergeSpec, OnConflict};
use crate::source::{Source, SourceSpec};
pub use crate::vars::{VarName, VarSpec};
pub(crate) const MANIFEST: &str = "profile.toml";
pub(crate) const PAYLOAD: &str = "files";
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub profile: Meta,
#[serde(default)]
#[schemars(with = "BTreeMap<ProfileName, RequirementSpec>")]
pub requires: BTreeMap<ProfileName, Requirement>,
#[serde(default)]
pub features: Features,
#[serde(default)]
pub vars: BTreeMap<VarName, VarSpec>,
#[serde(default)]
pub merge: MergeSpec,
#[serde(default)]
pub scaffolds: BTreeMap<ScaffoldName, ScaffoldSpec>,
#[serde(default)]
pub files: BTreeMap<RelPath, FileSpec>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Meta {
pub name: ProfileName,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
#[schemars(with = "Option<String>")]
pub devset: Option<VersionReq>,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(try_from = "RequirementSpec")]
pub struct Requirement {
pub features: Vec<FeatureName>,
pub default_features: bool,
pub optional: bool,
pub source: Option<Source>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
struct RequirementSpec {
#[serde(default)]
features: Vec<FeatureName>,
#[serde(default = "yes")]
default_features: bool,
#[serde(default)]
optional: bool,
#[serde(default)]
git: Option<String>,
#[serde(default)]
tag: Option<String>,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
rev: Option<String>,
#[serde(default)]
path: Option<String>,
}
const fn yes() -> bool {
true
}
impl TryFrom<RequirementSpec> for Requirement {
type Error = String;
fn try_from(spec: RequirementSpec) -> Result<Self, String> {
let RequirementSpec { features, default_features, optional, git, tag, branch, rev, path } =
spec;
let located = tag.is_some() || branch.is_some() || rev.is_some() || path.is_some();
let source = match git {
Some(git) => {
let spec = SourceSpec { git: Some(git), tag, branch, rev, path };
Some(Source::try_from(spec).map_err(|e| e.to_string())?)
},
None if located => {
return Err("`tag`, `branch`, `rev` and `path` name another source, with `git`; \
a profile in this one is required by name alone"
.to_owned());
},
None => None,
};
Ok(Self { features, default_features, optional, source })
}
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(try_from = "BTreeMap<String, Vec<String>>")]
#[schemars(with = "BTreeMap<String, Vec<String>>")]
pub struct Features {
pub default: Vec<FeatureName>,
pub declared: BTreeMap<FeatureName, Vec<FeatureRef>>,
}
impl TryFrom<BTreeMap<String, Vec<String>>> for Features {
type Error = NameError;
fn try_from(table: BTreeMap<String, Vec<String>>) -> Result<Self, NameError> {
let mut features = Self::default();
for (name, list) in table {
if name == "default" {
features.default =
list.into_iter().map(FeatureName::try_from).collect::<Result<_, _>>()?;
} else {
let refs = list.iter().map(|each| each.parse()).collect::<Result<_, _>>()?;
features.declared.insert(FeatureName::try_from(name)?, refs);
}
}
Ok(features)
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, DeserializeFromStr)]
pub enum FeatureRef {
Feature(FeatureName),
Dep(ProfileName),
Of {
profile: ProfileName,
feature: FeatureName,
weak: bool,
},
}
impl FromStr for FeatureRef {
type Err = NameError;
fn from_str(written: &str) -> Result<Self, NameError> {
if let Some(profile) = written.strip_prefix("dep:") {
return Ok(Self::Dep(profile.parse()?));
}
let Some((profile, feature)) = written.split_once('/') else {
return Ok(Self::Feature(written.parse()?));
};
let (profile, weak) =
profile.strip_suffix('?').map_or((profile, false), |profile| (profile, true));
Ok(Self::Of { profile: profile.parse()?, feature: feature.parse()?, weak })
}
}
impl fmt::Display for FeatureRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Feature(feature) => write!(f, "{feature}"),
Self::Dep(profile) => write!(f, "dep:{profile}"),
Self::Of { profile, feature, weak } => {
write!(f, "{profile}{}/{feature}", if *weak { "?" } else { "" })
},
}
}
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ScaffoldSpec {
#[serde(default)]
#[schemars(with = "Patterns")]
pub unless: Patterns,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(from = "OneOrMany")]
pub struct Patterns(pub Vec<String>);
#[derive(Deserialize, JsonSchema)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
impl From<OneOrMany> for Patterns {
fn from(written: OneOrMany) -> Self {
match written {
OneOrMany::One(one) => Self(vec![one]),
OneOrMany::Many(many) => Self(many),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FileSpec {
#[serde(default)]
pub policy: Option<Policy>,
#[serde(default)]
pub validate: Option<Format>,
#[serde(default)]
pub template: bool,
#[serde(default)]
pub scope: Scope,
#[serde(default)]
pub comment: Option<Comment>,
#[serde(default)]
pub executable: bool,
#[serde(default)]
pub scaffold: Option<ScaffoldName>,
#[serde(default)]
pub starter: Option<RelPath>,
#[serde(default)]
pub when: When,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct When {
#[serde(default)]
pub features: Vec<FeatureName>,
#[serde(default)]
pub profiles: Vec<ProfileName>,
#[serde(default)]
pub vars: BTreeMap<VarName, Vec<String>>,
#[serde(default)]
pub exists: Vec<String>,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Display,
)]
#[serde(rename_all = "lowercase")]
#[display(rename_all = "lowercase")]
pub enum Policy {
#[default]
Owned,
Merge,
Once,
}
impl Policy {
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde's `skip_serializing_if` passes a reference"
)]
pub(crate) const fn is_owned(&self) -> bool {
matches!(self, Self::Owned)
}
}
impl FileSpec {
#[must_use]
pub fn policy(&self) -> Policy {
let default = if self.scaffold.is_some() { Policy::Once } else { Policy::Owned };
self.policy.unwrap_or(default)
}
}
impl Manifest {
pub(crate) fn check(&self, file: &str) -> Result<()> {
let invalid = |message: String| ProfileError::Manifest { file: file.to_owned(), message };
let features = &self.features;
for feature in &features.default {
if !features.declared.contains_key(feature) {
return Err(invalid(format!(
"`default` lists `{feature}`, which [features] lacks"
))
.into());
}
}
for (feature, refs) in &features.declared {
for each in refs {
self.check_ref(each).map_err(|why| {
invalid(format!("feature `{feature}` turns on `{each}`, but {why}"))
})?;
}
}
for (path, spec) in &self.files {
self.check_entry(spec).map_err(|why| invalid(format!("[files.\"{path}\"]: {why}")))?;
}
Ok(())
}
fn check_ref(&self, each: &FeatureRef) -> Result<(), String> {
match each {
FeatureRef::Feature(feature) if !self.features.declared.contains_key(feature) => {
Err(format!("[features] lacks `{feature}`"))
},
FeatureRef::Dep(profile) => match self.requires.get(profile) {
None => Err(format!("[requires] lacks `{profile}`")),
Some(requirement) if !requirement.optional => {
Err(format!("`{profile}` is not an optional requirement"))
},
Some(_) => Ok(()),
},
FeatureRef::Of { profile, .. } if !self.requires.contains_key(profile) => {
Err(format!("[requires] lacks `{profile}`"))
},
FeatureRef::Feature(_) | FeatureRef::Of { .. } => Ok(()),
}
}
fn check_entry(&self, spec: &FileSpec) -> Result<(), String> {
let when = &spec.when;
if let Some(feature) =
when.features.iter().find(|f| !self.features.declared.contains_key(*f))
{
return Err(format!("`when` names feature `{feature}`, which [features] lacks"));
}
if let Some(name) = when.vars.keys().find(|name| !self.vars.contains_key(*name)) {
return Err(format!("`when` names variable `{name}`, which [vars] lacks"));
}
if let Some(group) = &spec.scaffold {
if !self.scaffolds.contains_key(group) {
return Err(format!("scaffold `{group}` is not in [scaffolds]"));
}
if spec.scope != Scope::File || spec.policy.is_some_and(|p| p != Policy::Once) {
return Err("a scaffold's file is a whole file, `once`".to_owned());
}
}
if spec.starter.is_some() && spec.scope == Scope::File {
return Err("`starter` is for a part: a whole file is its own".to_owned());
}
Ok(())
}
pub(crate) fn payload_paths(&self) -> BTreeSet<RelPath> {
let starters = self.files.values().filter_map(|spec| spec.starter.clone());
self.files.keys().cloned().chain(starters).collect()
}
}
#[cfg(test)]
mod tests {
use super::{FeatureRef, Manifest};
fn read(toml: &str) -> Result<Manifest, String> {
let manifest: Manifest = toml::from_str(toml).map_err(|e| e.message().to_owned())?;
manifest.check("profile.toml").map_err(|e| e.to_string())?;
Ok(manifest)
}
const MDBOOK: &str = r#"
[profile]
name = "mdbook"
[requires]
just = {}
lychee = { optional = true }
github-ci = { optional = true, features = ["pages"], default-features = false }
extern = { git = "https://example.com/p", tag = "v1" }
[features]
default = ["katex", "links"]
katex = []
api = ["katex"]
pages = ["github-ci/pages"]
links = ["dep:lychee"]
agents = ["github-ci?/agents"]
[vars.book_dir]
default = "docs"
[scaffolds.book]
unless = "{{ book_dir }}/book.toml"
[files."{{ book_dir }}/SUMMARY.md"]
scaffold = "book"
[files."{{ book_dir }}/book.toml"]
scope = "keys"
starter = "book.starter.toml"
template = true
[files."{{ book_dir }}/theme/api-link.js"]
when = { features = ["api"], vars = { book_dir = ["docs"] }, exists = ["**/*.rs"] }
"#;
#[test]
fn a_full_manifest_reads() {
let manifest = read(MDBOOK).expect("valid");
let features = &manifest.features;
assert_eq!(features.default.len(), 2, "the default list");
assert_eq!(features.declared.len(), 5, "every feature but `default`");
let github = manifest.requires.iter().find(|(name, _)| name.as_str() == "github-ci");
let github = github.expect("required").1;
assert!(github.optional && !github.default_features, "its flags");
assert!(
manifest.requires.iter().any(|(_, r)| r.source.is_some()),
"one names another source"
);
assert_eq!(manifest.payload_paths().len(), 4, "every entry's file, and the starter");
}
#[test]
fn feature_refs() {
for (written, want) in [
("katex", "Feature"),
("dep:lychee", "Dep"),
("github-ci/pages", "Of"),
("mdbook?/agents", "Weak"),
] {
let parsed: FeatureRef = written.parse().expect("a reference");
let kind = match &parsed {
FeatureRef::Feature(_) => "Feature",
FeatureRef::Dep(_) => "Dep",
FeatureRef::Of { weak: false, .. } => "Of",
FeatureRef::Of { weak: true, .. } => "Weak",
};
assert_eq!(kind, want, "{written}");
assert_eq!(parsed.to_string(), written, "written as read");
}
for bad in ["dep:", "a/b/c", "?x", "a?/", "x?"] {
assert!(bad.parse::<FeatureRef>().is_err(), "{bad:?} is not a reference");
}
}
#[test]
fn mistakes_are_named() {
let base = "[profile]\nname = \"p\"\n";
for (tail, says) in [
("[features]\ndefault = [\"x\"]\n", "`default` lists `x`"),
("[features]\na = [\"b\"]\n", "[features] lacks `b`"),
("[features]\na = [\"dep:q\"]\n", "[requires] lacks `q`"),
("[requires]\nq = {}\n[features]\na = [\"dep:q\"]\n", "not an optional requirement"),
("[features]\na = [\"q/f\"]\n", "[requires] lacks `q`"),
("[files.\"a\"]\nwhen = { features = [\"x\"] }\n", "feature `x`"),
("[files.\"a\"]\nwhen = { vars = { v = [\"1\"] } }\n", "variable `v`"),
("[files.\"a\"]\nscaffold = \"s\"\n", "scaffold `s` is not"),
("[scaffolds.s]\n[files.\"a\"]\nscaffold = \"s\"\npolicy = \"owned\"\n", "`once`"),
("[scaffolds.s]\n[files.\"a\"]\nscaffold = \"s\"\nscope = \"keys\"\n", "`once`"),
("[files.\"a\"]\nstarter = \"s\"\n", "`starter` is for a part"),
("[vars.devset]\n", "reserved"),
("[requires]\nq = { tag = \"v1\" }\n", "with `git`"),
("[requires]\nq = { path = \"x\" }\n", "with `git`"),
] {
let error = read(&format!("{base}{tail}")).expect_err(tail);
assert!(error.contains(says), "{tail}: {error}");
}
}
}