Skip to main content

devset_core/
profile.rs

1//! What a profile declares, in its `profile.toml`.
2//!
3//! A profile is a directory: `profile.toml`, and `files/` mirroring the target. Every path
4//! `[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,
6//! or a block's lines.
7//!
8//! ```text
9//! rust/
10//! ├── profile.toml
11//! └── files/
12//!     ├── rustfmt.toml
13//!     └── .github/workflows/ci.yml
14//! ```
15
16use alloc::collections::BTreeMap;
17use core::fmt;
18
19use derive_more::Display;
20use schemars::JsonSchema;
21use semver::VersionReq;
22use serde::de::value::MapAccessDeserializer;
23use serde::de::{self, MapAccess, Visitor};
24use serde::{Deserialize, Deserializer, Serialize};
25
26pub use crate::format::Format;
27pub use crate::part::{Comment, Scope};
28use crate::path::RelPath;
29pub use crate::settings::{MergeSpec, OnConflict};
30use crate::source::{Source, SourceSpec};
31pub use crate::vars::{VarName, VarSpec};
32
33/// A profile's manifest, at its root.
34pub(crate) const MANIFEST: &str = "profile.toml";
35
36/// The directory holding a profile's payload, mirroring the target.
37pub(crate) const PAYLOAD: &str = "files";
38
39/// `profile.toml`.
40#[derive(Debug, Deserialize, JsonSchema)]
41#[serde(deny_unknown_fields)]
42pub struct Manifest {
43    /// Identity and requirements.
44    pub profile: Meta,
45    /// Merge defaults.
46    #[serde(default)]
47    pub merge: MergeSpec,
48    /// Variables templates use; the target answers them.
49    #[serde(default)]
50    pub vars: BTreeMap<VarName, VarSpec>,
51    /// Managed files, by path in the target and under `files/`.
52    #[serde(default)]
53    pub files: BTreeMap<RelPath, FileSpec>,
54}
55
56/// The `[profile]` table.
57#[derive(Clone, Debug, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct Meta {
60    /// Names the profile in output, in collisions, and to `update`.
61    pub name: String,
62    /// For humans; a git layer is pinned by commit.
63    #[serde(default)]
64    pub version: Option<String>,
65    /// One line for humans.
66    #[serde(default)]
67    pub description: Option<String>,
68    /// The devset versions the profile works with.
69    #[serde(default)]
70    #[schemars(with = "Option<String>")]
71    pub devset: Option<VersionReq>,
72    /// Profiles this one builds on, each a layer of its own before it.
73    #[serde(default)]
74    pub requires: Vec<Requirement>,
75}
76
77/// A profile another profile builds on.
78///
79/// A string is a sibling: a path relative to the requiring profile, inside its source and at its
80/// revision. A table is a git source, written as a `[[layers]]` entry is.
81#[derive(Clone, Debug, PartialEq, Eq, JsonSchema)]
82#[schemars(untagged)]
83pub enum Requirement {
84    /// A profile beside this one, by relative path.
85    Sibling(String),
86    /// A profile in a git repository, at its own ref.
87    Git(#[schemars(with = "SourceSpec")] Source),
88}
89
90impl<'de> Deserialize<'de> for Requirement {
91    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
92        /// Reads a string as a sibling and a table as a git source.
93        struct Visit;
94
95        impl<'de> Visitor<'de> for Visit {
96            type Value = Requirement;
97
98            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99                f.write_str("a relative path, or a table naming a git source")
100            }
101
102            fn visit_str<E: de::Error>(self, path: &str) -> Result<Requirement, E> {
103                Ok(Requirement::Sibling(path.to_owned()))
104            }
105
106            fn visit_map<M: MapAccess<'de>>(self, map: M) -> Result<Requirement, M::Error> {
107                let spec = SourceSpec::deserialize(MapAccessDeserializer::new(map))?;
108                if spec.git.is_none() {
109                    return Err(de::Error::custom(
110                        "a requirement table names a git source; a sibling is a relative path",
111                    ));
112                }
113                Source::try_from(spec).map(Requirement::Git).map_err(de::Error::custom)
114            }
115        }
116
117        deserializer.deserialize_any(Visit)
118    }
119}
120
121/// A profile's `[files."path"]` entry.
122#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
123#[serde(deny_unknown_fields)]
124pub struct FileSpec {
125    /// How devset manages the file; `owned` unless set.
126    #[serde(default)]
127    pub policy: Option<Policy>,
128    /// How a merged result is checked; inferred from the extension unless set.
129    #[serde(default)]
130    pub validate: Option<Format>,
131    /// Whether the file is a template, rendered with the target's answers.
132    #[serde(default)]
133    pub template: bool,
134    /// How much of the file devset owns: all of it, the keys the payload defines, or a block.
135    #[serde(default)]
136    pub scope: Scope,
137    /// The comment syntax of a block's markers, where devset does not know the file type's.
138    #[serde(default)]
139    pub comment: Option<Comment>,
140    /// Whether devset writes the file executable, mode 755, as a script it ships must be.
141    #[serde(default)]
142    pub executable: bool,
143}
144
145/// How devset manages a file.
146#[derive(
147    Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Display,
148)]
149#[serde(rename_all = "lowercase")]
150#[display(rename_all = "lowercase")]
151pub enum Policy {
152    /// The profile is authoritative; local edits are drift.
153    #[default]
154    Owned,
155    /// Local edits are kept, and merged with the profile's.
156    Merge,
157    /// Written once if absent; the target's from then on.
158    Once,
159}
160
161impl Policy {
162    /// Whether it is [`Policy::Owned`], the default.
163    #[expect(
164        clippy::trivially_copy_pass_by_ref,
165        reason = "serde's `skip_serializing_if` passes a reference"
166    )]
167    pub(crate) const fn is_owned(&self) -> bool {
168        matches!(self, Self::Owned)
169    }
170}