Skip to main content

devset_core/
target.rs

1//! The directory devset manages, and what its `.devset/` records.
2//!
3//! ```text
4//! .devset/
5//! ├── config.toml     the sources, the layers, and the target's word on settings (committed)
6//! ├── lock.toml       each source's revision, each layer's digest and features (committed)
7//! ├── answers.toml    answers to the layers' variables (committed)
8//! ├── state.toml      what devset last wrote, by file and part, and each scaffold (committed)
9//! ├── base/           those bytes, by digest, as merge bases (committed)
10//! └── conflicts/      an unfinished update: merges awaiting resolution (ignored)
11//!     └── .devset/    what it withheld, and what it would take to undo it
12//! ```
13
14use alloc::collections::{BTreeMap, BTreeSet};
15use core::str;
16use std::io;
17
18use camino::{Utf8Path, Utf8PathBuf};
19use derive_more::Display;
20use schemars::JsonSchema;
21use serde::de::{self, DeserializeOwned};
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, TomlError, Value, ser};
24
25use crate::digest::{Digest, Fingerprint};
26use crate::errors::{ParseError, Result, TargetError};
27use crate::graph::Enabler;
28use crate::name::{FeatureName, ProfileName, ProfileRef, ScaffoldId, SourceName};
29use crate::part::{Scope, Slot};
30use crate::path::RelPath;
31use crate::profile::{Format, MergeSpec, Policy};
32use crate::source::{Oid, Source, SourceSpec};
33use crate::vars::VarName;
34
35/// devset's directory in a target.
36pub(crate) const DIR: &str = ".devset";
37
38/// Intent: the layers, in order.
39pub(crate) const CONFIG: &str = "config.toml";
40
41/// What each layer resolved to.
42pub(crate) const LOCK: &str = "lock.toml";
43
44/// What devset last recorded for each managed path.
45pub(crate) const STATE: &str = "state.toml";
46
47/// Conflicted merge results awaiting resolution, by path.
48pub(crate) const CONFLICTS: &str = "conflicts";
49
50/// In an unfinished update's directory, the lock that `on-conflict = "apply-none"` withheld.
51pub(crate) const PENDING: &str = "pending.toml";
52
53/// In an unfinished update's directory, what the update changed.
54pub(crate) const UNDO: &str = "undo.toml";
55
56/// In an unfinished update's directory, the bytes it replaced, by digest.
57pub(crate) const UNDO_BLOBS: &str = "undo";
58
59/// Recorded bases, named by digest.
60pub(crate) const BASE: &str = "base";
61
62/// The target's answers to the layers' variables.
63pub(crate) const ANSWERS: &str = "answers.toml";
64
65/// A directory devset manages.
66#[derive(Debug)]
67pub struct Target {
68    /// The directory holding `.devset/`.
69    root: Utf8PathBuf,
70    /// `config.toml`, parsed.
71    config: Config,
72    /// `config.toml` as written: layers are appended to it, so the user's formatting survives.
73    config_text: String,
74    /// `lock.toml`.
75    lock: Lock,
76    /// `pending.toml`, while conflicts wait in `.devset/conflicts/`.
77    pending: Option<Lock>,
78    /// `state.toml`.
79    state: State,
80    /// `answers.toml`, with answers given since.
81    answers: BTreeMap<VarName, String>,
82    /// Variables answered since loading, which must be declared.
83    given: BTreeSet<VarName>,
84    /// Scaffolds to write again, whatever was decided.
85    rescaffold: BTreeSet<ScaffoldId>,
86    /// Digest of `state.toml` as read, to catch a concurrent writer.
87    state_digest: Option<Digest>,
88}
89
90/// What a new target's `config.toml` says before it has a source or a layer.
91const SKELETON: &str = "\
92# devset applies the profiles below to this directory, and keeps them up to date.
93# The manual: https://atomix-labs.github.io/devset/
94#
95# Where profiles come from, each named once: a git repository at a ref, or a directory.
96#
97#   [sources]
98#   atxp = { git = \"https://github.com/atomix-labs/atxp\", tag = \"v0.4.0\" }
99#
100# The profiles to apply, in order, by source and name, with the features to turn on.
101#
102#   [[layers]]
103#   profile  = \"atxp/rust\"
104#   features = [\"docs\"]
105";
106
107/// `.devset/config.toml`: the sources, the layers, and the target's word on every setting they
108/// carry.
109#[derive(Debug, Default, Deserialize, JsonSchema)]
110#[serde(deny_unknown_fields)]
111pub struct Config {
112    /// Where profiles come from, each named once.
113    #[serde(default)]
114    pub sources: BTreeMap<SourceName, Source>,
115    /// Profiles to apply, in order.
116    #[serde(default)]
117    pub layers: Vec<LayerSpec>,
118    /// Merge settings; these override every layer's.
119    #[serde(default)]
120    pub merge: MergeSpec,
121    /// Overrides of the layers' `[files]` entries.
122    #[serde(default)]
123    pub files: BTreeMap<RelPath, Override>,
124}
125
126/// A `[[layers]]` entry: a profile, and the features the target turns on.
127#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128#[serde(deny_unknown_fields, rename_all = "kebab-case")]
129pub struct LayerSpec {
130    /// The profile, `source/name`.
131    pub profile: ProfileRef,
132    /// Its features to turn on, beside its default ones.
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub features: Vec<FeatureName>,
135    /// Whether its default features are on; unless a profile that requires it turns them on.
136    #[serde(default = "yes", skip_serializing_if = "is_yes")]
137    pub default_features: bool,
138}
139
140impl LayerSpec {
141    /// A layer of `profile`, its default features on and no other.
142    #[must_use]
143    pub const fn new(profile: ProfileRef) -> Self {
144        Self { profile, features: Vec::new(), default_features: true }
145    }
146}
147
148/// `true`, serde's default for a flag that is on unless set.
149const fn yes() -> bool {
150    true
151}
152
153/// Whether `flag` is on, its default.
154#[expect(
155    clippy::trivially_copy_pass_by_ref,
156    reason = "serde's `skip_serializing_if` passes a reference"
157)]
158const fn is_yes(flag: &bool) -> bool {
159    *flag
160}
161
162/// A `[files."path"]` entry in `config.toml`, overriding the providing layer's.
163#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165pub struct Override {
166    /// The layer that provides the file, when several do.
167    #[serde(default)]
168    pub from: Option<ProfileName>,
169    /// How devset manages the file.
170    #[serde(default)]
171    pub policy: Option<Policy>,
172    /// How a merged result is checked.
173    #[serde(default)]
174    pub validate: Option<Format>,
175}
176
177/// What was decided of a scaffold, and where it wrote its files: `state.toml`'s record.
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct Scaffold {
181    /// Whether the group was written, or found the target's own.
182    pub decision: Scaffolded,
183    /// Each file it wrote, by its path as the profile writes it, at its path in the target: a
184    /// changed answer moves no file a scaffold wrote.
185    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
186    pub files: BTreeMap<RelPath, RelPath>,
187}
188
189/// What was decided of a scaffold, once and for good.
190#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Display)]
191#[serde(rename_all = "lowercase")]
192#[display(rename_all = "lowercase")]
193pub enum Scaffolded {
194    /// Its sentinels were absent, so its files were written.
195    Written,
196    /// A sentinel was there: the target has its own, and the group was left out.
197    Found,
198}
199
200impl Target {
201    /// The target containing `dir`: the nearest ancestor holding `.devset/config.toml`.
202    ///
203    /// # Errors
204    /// - [`TargetError::NotFound`], no ancestor holds one.
205    /// - [`TargetError::OldConfig`], its `config.toml` is devset 0.1's.
206    /// - [`Error::Parse`](crate::Error::Parse), one of its files does not parse.
207    pub fn find(dir: &Utf8Path) -> Result<Self> {
208        let root = enclosing(dir).ok_or_else(|| TargetError::NotFound { dir: dir.to_owned() })?;
209        Self::load(root)
210    }
211
212    /// The target rooted at `root`, or a new one there, its `config.toml` a commented skeleton.
213    /// Never one inside another target.
214    ///
215    /// # Errors
216    /// - [`TargetError::Nested`], `root` lies inside another target.
217    /// - [`TargetError::OldConfig`], its `config.toml` is devset 0.1's.
218    /// - [`Error::Parse`](crate::Error::Parse), one of its files does not parse.
219    pub fn open_or_new(root: &Utf8Path) -> Result<Self> {
220        match enclosing(root) {
221            Some(found) if found == root => Self::load(root),
222            Some(outer) => {
223                Err(TargetError::Nested { dir: root.to_owned(), root: outer.to_owned() }.into())
224            },
225            None => Ok(Self {
226                root: root.to_owned(),
227                config: Config::default(),
228                config_text: SKELETON.to_owned(),
229                lock: Lock::default(),
230                pending: None,
231                state: State::default(),
232                answers: BTreeMap::new(),
233                given: BTreeSet::new(),
234                rescaffold: BTreeSet::new(),
235                state_digest: None,
236            }),
237        }
238    }
239
240    /// The directory holding `.devset/`.
241    #[must_use]
242    pub fn root(&self) -> &Utf8Path {
243        &self.root
244    }
245
246    /// `config.toml`.
247    #[must_use]
248    pub const fn config(&self) -> &Config {
249        &self.config
250    }
251
252    /// Whether `config.toml` exists yet: a new target's is written by its first
253    /// [`commit`](crate::commit()).
254    #[must_use]
255    pub fn exists(&self) -> bool {
256        self.dir().join(CONFIG).is_file()
257    }
258
259    /// Names `source` `name` in `[sources]`; written by the next [`commit`](crate::commit()).
260    ///
261    /// Nothing changes when it already does.
262    ///
263    /// # Errors
264    /// - [`TargetError::SourceExists`], `name` already names another source.
265    /// - [`Error::Parse`](crate::Error::Parse), `sources` in `config.toml` is not a table.
266    pub fn add_source(&mut self, name: SourceName, source: Source) -> Result<()> {
267        match self.config.sources.get(&name) {
268            Some(named) if *named == source => return Ok(()),
269            Some(named) => {
270                return Err(TargetError::SourceExists { name, location: named.to_string() }.into());
271            },
272            None => {},
273        }
274        let spec = ser::to_document(&SourceSpec::from(source)).map_err(io::Error::other)?;
275        let inline = spec.into_table().into_inline_table();
276        self.edit("`sources` must be a table", |doc| {
277            let sources = doc.entry("sources").or_insert_with(|| Item::Table(Table::new()));
278            let Some(sources) = sources.as_table_like_mut() else { return false };
279            sources.insert(name.as_str(), Item::Value(Value::InlineTable(inline)));
280            true
281        })
282    }
283
284    /// Appends `layer` as the last layer; written by the next [`commit`](crate::commit()).
285    ///
286    /// # Errors
287    /// - [`TargetError::DuplicateLayer`], a layer already applies the profile.
288    /// - [`TargetError::NoSuchSource`], `[sources]` does not name its source.
289    /// - [`Error::Parse`](crate::Error::Parse), `layers` in `config.toml` is not a list of tables.
290    pub fn add_layer(&mut self, layer: LayerSpec) -> Result<()> {
291        if self.layer(&layer.profile.profile).is_some() {
292            return Err(TargetError::DuplicateLayer { layer: layer.profile.profile }.into());
293        }
294        if !self.config.sources.contains_key(&layer.profile.source) {
295            let sources = self.config.sources.keys().cloned().collect();
296            let (name, layers) = (layer.profile.source.clone(), vec![layer.profile]);
297            return Err(TargetError::NoSuchSource { name, sources, layers }.into());
298        }
299        let table = ser::to_document(&layer).map_err(io::Error::other)?.into_table();
300        self.edit("`layers` must be a list of tables", |doc| {
301            match doc.entry("layers").or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())) {
302                Item::ArrayOfTables(layers) => layers.push(table),
303                Item::Value(Value::Array(layers)) => layers.push(table.into_inline_table()),
304                Item::None | Item::Value(_) | Item::Table(_) => return false,
305            }
306            true
307        })
308    }
309
310    /// The layer that applies the profile named `name`.
311    #[must_use]
312    pub fn layer(&self, name: &ProfileName) -> Option<&LayerSpec> {
313        self.config.layers.iter().find(|layer| layer.profile.profile == *name)
314    }
315
316    /// Removes the layer that applies the profile named `name`; written by the next
317    /// [`commit`](crate::commit()). Its source stays, for another layer to use.
318    ///
319    /// Every `[files]` override that picks it, `from = "<name>"`, goes with it.
320    ///
321    /// # Errors
322    /// [`Error::Parse`](crate::Error::Parse), `config.toml` changed shape so that the layer
323    /// cannot be found in it.
324    pub fn remove_layer(&mut self, name: &ProfileName) -> Result<()> {
325        let Some(index) = self.config.layers.iter().position(|l| l.profile.profile == *name) else {
326            return Ok(());
327        };
328        self.edit("`layers` must be a list of tables", |doc| {
329            match doc.get_mut("layers") {
330                Some(Item::ArrayOfTables(layers)) if index < layers.len() => {
331                    layers.remove(index);
332                },
333                Some(Item::Value(Value::Array(layers))) if index < layers.len() => {
334                    layers.remove(index);
335                },
336                Some(_) | None => return false,
337            }
338            if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
339                let picks =
340                    |item: &Item| item.get("from").and_then(Item::as_str) == Some(name.as_str());
341                let picked: Vec<String> = files
342                    .iter()
343                    .filter(|(_, item)| picks(item))
344                    .map(|(key, _)| key.to_owned())
345                    .collect();
346                for key in picked {
347                    files.remove(&key);
348                }
349            }
350            true
351        })
352    }
353
354    /// Removes the `[files."path"]` override; written by the next [`commit`](crate::commit()).
355    ///
356    /// # Errors
357    /// [`Error::Parse`](crate::Error::Parse), as [`add_layer`](Self::add_layer).
358    pub fn remove_override(&mut self, path: &RelPath) -> Result<()> {
359        self.edit("`files` must be a table", |doc| {
360            if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
361                files.remove(path.as_str());
362            }
363            true
364        })
365    }
366
367    /// Applies `edit` to `config.toml`, keeping its formatting and comments, and reads it again.
368    ///
369    /// `edit` returns `false` when the file is not the shape it expects, which `shape` says. A
370    /// skeleton's comments stay at the top, above the first table.
371    fn edit(&mut self, shape: &str, edit: impl FnOnce(&mut DocumentMut) -> bool) -> Result<()> {
372        let file = label(CONFIG);
373        let failed = |text: &str, message: String| ParseError {
374            file: file.clone(),
375            text: text.to_owned(),
376            span: None,
377            message,
378        };
379        let mut doc: DocumentMut = self
380            .config_text
381            .parse()
382            .map_err(|e: TomlError| failed(&self.config_text, e.to_string()))?;
383        let lead = doc.is_empty().then(|| doc.trailing().as_str().map(str::to_owned)).flatten();
384        if !edit(&mut doc) {
385            return Err(failed(&self.config_text, shape.to_owned()).into());
386        }
387        if let Some(lead) = lead.filter(|lead| !lead.trim().is_empty()) {
388            doc.set_trailing("");
389            if let Some((_, first)) = doc.iter_mut().next() {
390                let prefix = format!("{}\n\n", lead.trim_end());
391                match first {
392                    Item::Table(table) => table.decor_mut().set_prefix(prefix),
393                    Item::ArrayOfTables(tables) => {
394                        if let Some(table) = tables.get_mut(0) {
395                            table.decor_mut().set_prefix(prefix);
396                        }
397                    },
398                    Item::Value(_) | Item::None => {},
399                }
400            }
401        }
402        let text = doc.to_string();
403        self.config = from_toml(text.as_bytes(), &file)?;
404        self.config_text = text;
405        Ok(())
406    }
407
408    /// Answers `name`; written by the next [`commit`](crate::commit()).
409    pub fn answer(&mut self, name: VarName, value: String) {
410        self.given.insert(name.clone());
411        self.answers.insert(name, value);
412    }
413
414    /// Every answer: recorded, or given since.
415    #[must_use]
416    pub const fn answers(&self) -> &BTreeMap<VarName, String> {
417        &self.answers
418    }
419
420    /// Writes the absent files of `scaffold` again at the next [`survey`](crate::survey()),
421    /// whatever was decided of it.
422    pub fn rescaffold(&mut self, scaffold: ScaffoldId) {
423        self.rescaffold.insert(scaffold);
424    }
425
426    /// The scaffolds to write again.
427    pub(crate) const fn rescaffolds(&self) -> &BTreeSet<ScaffoldId> {
428        &self.rescaffold
429    }
430
431    /// What was decided of each scaffold, as `state.toml` recorded it.
432    pub(crate) const fn scaffolds(&self) -> &BTreeMap<ScaffoldId, Scaffold> {
433        &self.state.scaffolds
434    }
435
436    /// Variables answered since loading.
437    pub(crate) fn given(&self) -> impl Iterator<Item = &VarName> {
438        self.given.iter()
439    }
440
441    /// `.devset/`.
442    pub(crate) fn dir(&self) -> Utf8PathBuf {
443        self.root.join(DIR)
444    }
445
446    /// `config.toml` as it should be written.
447    pub(crate) fn config_text(&self) -> &str {
448        &self.config_text
449    }
450
451    /// The lock in force: the pending one while an update is withheld.
452    fn lock(&self) -> &Lock {
453        self.pending.as_ref().unwrap_or(&self.lock)
454    }
455
456    /// The commit the lock pins `source` to.
457    ///
458    /// Found by source, not position, so removing or reordering layers leaves the others pinned.
459    #[must_use]
460    pub fn pinned(&self, source: &Source) -> Option<&Oid> {
461        let locked = self.lock().profiles.iter();
462        locked.filter(|locked| locked.source == *source).find_map(|locked| locked.rev.as_ref())
463    }
464
465    /// The digest the lock recorded for the profile `name` from `source`.
466    pub(crate) fn locked(&self, source: &Source, name: &ProfileName) -> Option<Digest> {
467        let lock = self.lock();
468        let locked = lock.profiles.iter().find(|l| l.source == *source && l.name == *name);
469        locked.map(|locked| locked.digest)
470    }
471
472    /// Where the conflicted merge of `path` waits.
473    pub(crate) fn sidecar(&self, path: &RelPath) -> Utf8PathBuf {
474        path.under(&self.dir().join(CONFLICTS))
475    }
476
477    /// An unfinished update's own files, beside its sidecars.
478    ///
479    /// Named `.devset`, which no managed path may contain, so it never meets a sidecar; and inside
480    /// `conflicts/`, so deleting that directory discards the whole update.
481    pub(crate) fn unfinished(&self) -> Utf8PathBuf {
482        self.dir().join(CONFLICTS).join(DIR)
483    }
484
485    /// The recorded base with fingerprint `record`, or `None` when it is missing or damaged.
486    pub(crate) fn base(&self, record: &Fingerprint) -> Result<Option<Vec<u8>>> {
487        let blob = read_optional(&self.dir().join(BASE).join(record.exact.to_string()))?;
488        Ok(blob.filter(|bytes| Digest::of(bytes) == record.exact))
489    }
490
491    /// Every recorded file and part.
492    pub(crate) fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
493        self.state.records()
494    }
495
496    /// Digest of `state.toml` as read.
497    pub(crate) const fn state_digest(&self) -> Option<Digest> {
498        self.state_digest
499    }
500
501    /// Reads the target at `root`.
502    fn load(root: &Utf8Path) -> Result<Self> {
503        let dir = root.join(DIR);
504        let config_raw = fs_err::read(dir.join(CONFIG))?;
505        let config = read_config(&config_raw)?;
506        let config_text = String::from_utf8(config_raw).map_err(io::Error::other)?;
507        let state_raw = read_optional(&dir.join(STATE))?;
508        let state_digest = state_raw.as_deref().map(Digest::of);
509        let state = state_raw.map(|raw| from_toml(&raw, &label(STATE))).transpose()?;
510        let lock = |name: &str| -> Result<Option<Lock>> {
511            let raw = read_optional(&dir.join(name))?;
512            raw.map(|raw| Lock::read(&raw, &label(name))).transpose()
513        };
514        Ok(Self {
515            root: root.to_owned(),
516            config,
517            config_text,
518            lock: lock(LOCK)?.unwrap_or_default(),
519            pending: lock(&format!("{CONFLICTS}/{DIR}/{PENDING}"))?,
520            state: state.unwrap_or_default(),
521            state_digest,
522            answers: read_toml(&dir, ANSWERS)?.unwrap_or_default(),
523            given: BTreeSet::new(),
524            rescaffold: BTreeSet::new(),
525        })
526    }
527}
528
529/// `config.toml`, refused when it is devset 0.1's: its layers named locations.
530fn read_config(raw: &[u8]) -> Result<Config> {
531    let file = label(CONFIG);
532    let table: toml::Table = from_toml(raw, &file)?;
533    let located = |layer: &toml::Value| {
534        layer
535            .as_table()
536            .is_some_and(|layer| layer.contains_key("git") || layer.contains_key("path"))
537    };
538    let layers = table.get("layers").and_then(toml::Value::as_array);
539    if layers.is_some_and(|layers| layers.iter().any(located)) {
540        return Err(TargetError::OldConfig.into());
541    }
542    from_toml(raw, &file)
543}
544
545/// `name` in `.devset/`, as users know it.
546pub(crate) fn label(name: &str) -> String {
547    format!("{DIR}/{name}")
548}
549
550/// The nearest ancestor of `dir`, itself included, holding `.devset/config.toml`.
551fn enclosing(dir: &Utf8Path) -> Option<&Utf8Path> {
552    dir.ancestors().find(|d| d.join(DIR).join(CONFIG).is_file())
553}
554
555/// `lock.toml`.
556#[derive(Debug, Default, Serialize, Deserialize)]
557#[serde(deny_unknown_fields)]
558pub(crate) struct Lock {
559    /// Format version.
560    version: V2,
561    /// One per active profile, in the order they apply.
562    #[serde(default, skip_serializing_if = "Vec::is_empty")]
563    profiles: Vec<Locked>,
564}
565
566/// What one active profile resolved to.
567#[derive(Debug, Serialize, Deserialize)]
568#[serde(deny_unknown_fields)]
569pub(crate) struct Locked {
570    /// The profile's name.
571    pub name: ProfileName,
572    /// Commit of its source, for a versioned one; every profile of a source has one.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub rev: Option<Oid>,
575    /// Digest of its manifest, rendered payload and features.
576    pub digest: Digest,
577    /// Its source, as configured when locked.
578    pub source: Source,
579    /// Every feature that was on, with who turned it on.
580    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
581    pub features: BTreeMap<FeatureName, Vec<String>>,
582}
583
584impl Locked {
585    /// Who turned each of `features` on, as the lock writes it.
586    pub(crate) fn features(
587        features: &BTreeMap<FeatureName, BTreeSet<Enabler>>,
588    ) -> BTreeMap<FeatureName, Vec<String>> {
589        features
590            .iter()
591            .map(|(feature, by)| (feature.clone(), by.iter().map(ToString::to_string).collect()))
592            .collect()
593    }
594}
595
596impl Lock {
597    /// A lock of `profiles`, in order.
598    pub(crate) const fn new(profiles: Vec<Locked>) -> Self {
599        Self { version: V2, profiles }
600    }
601
602    /// The lock in `raw`, read from `file`; devset 0.1's is empty, so every source resolves anew.
603    fn read(raw: &[u8], file: &str) -> Result<Self> {
604        let table: toml::Table = from_toml(raw, file)?;
605        if table.get("version").and_then(toml::Value::as_integer) == Some(1) {
606            return Ok(Self::default());
607        }
608        from_toml(raw, file)
609    }
610}
611
612/// `state.toml`.
613#[derive(Debug, Default, Serialize, Deserialize)]
614#[serde(deny_unknown_fields)]
615pub(crate) struct State {
616    /// Format version; devset 0.1's reads as this one.
617    version: V2,
618    /// What was decided of each scaffold.
619    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
620    scaffolds: BTreeMap<ScaffoldId, Scaffold>,
621    /// Each managed file's recorded base.
622    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
623    files: BTreeMap<RelPath, FileRecord>,
624    /// Each part's recorded base, by file, then by the profile that owns it.
625    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
626    parts: BTreeMap<RelPath, BTreeMap<String, PartRecord>>,
627}
628
629/// What devset recorded for one file or part.
630#[derive(Clone, Copy, Debug)]
631pub(crate) struct Record {
632    /// How much of the file it is.
633    pub scope: Scope,
634    /// Its content, as recorded.
635    pub fingerprint: Fingerprint,
636    /// How it was managed when recorded.
637    pub policy: Policy,
638}
639
640/// What devset recorded for one whole file: the digests of its content, and its policy.
641#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
642#[serde(deny_unknown_fields)]
643struct FileRecord {
644    /// As [`Fingerprint::exact`].
645    exact: Digest,
646    /// As [`Fingerprint::canonical`].
647    canonical: Digest,
648    /// How it was managed; `owned`, the default, is not written.
649    #[serde(default, skip_serializing_if = "Policy::is_owned")]
650    policy: Policy,
651}
652
653/// What devset recorded for one part: its scope, the digests of its content, and its policy.
654#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
655#[serde(deny_unknown_fields)]
656struct PartRecord {
657    /// How much of the file the part is.
658    scope: Scope,
659    /// As [`Fingerprint::exact`].
660    exact: Digest,
661    /// As [`Fingerprint::canonical`].
662    canonical: Digest,
663    /// How it was managed; `owned`, the default, is not written.
664    #[serde(default, skip_serializing_if = "Policy::is_owned")]
665    policy: Policy,
666}
667
668impl State {
669    /// A state recording `records`, and what was decided of `scaffolds`.
670    pub(crate) fn new(
671        records: BTreeMap<Slot, Record>, scaffolds: BTreeMap<ScaffoldId, Scaffold>,
672    ) -> Self {
673        let mut state = Self { scaffolds, ..Self::default() };
674        for (Slot { path, part }, Record { scope, fingerprint, policy }) in records {
675            let Fingerprint { exact, canonical } = fingerprint;
676            match part {
677                None => {
678                    state.files.insert(path, FileRecord { exact, canonical, policy });
679                },
680                Some(owner) => {
681                    let record = PartRecord { scope, exact, canonical, policy };
682                    state.parts.entry(path).or_default().insert(owner, record);
683                },
684            }
685        }
686        state
687    }
688
689    /// Every record, whole files first.
690    fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
691        let files = self.files.iter().map(|(path, record)| {
692            let FileRecord { exact, canonical, policy } = *record;
693            let fingerprint = Fingerprint { exact, canonical };
694            (Slot::whole(path.clone()), Record { scope: Scope::File, fingerprint, policy })
695        });
696        let parts = self.parts.iter().flat_map(|(path, parts)| {
697            parts.iter().map(|(owner, record)| {
698                let slot = Slot { path: path.clone(), part: Some(owner.clone()) };
699                let PartRecord { scope, exact, canonical, policy } = *record;
700                (slot, Record { scope, fingerprint: Fingerprint { exact, canonical }, policy })
701            })
702        });
703        files.chain(parts)
704    }
705
706    /// The bases it references.
707    pub(crate) fn bases(&self) -> impl Iterator<Item = Digest> {
708        self.records().map(|(_, record)| record.fingerprint.exact)
709    }
710}
711
712/// Format version `1`: `undo.toml`'s.
713#[derive(Clone, Copy, Debug, Default)]
714pub(crate) struct V1;
715
716impl Serialize for V1 {
717    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
718        serializer.serialize_u8(1)
719    }
720}
721
722impl<'de> Deserialize<'de> for V1 {
723    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
724        match u8::deserialize(deserializer)? {
725            1 => Ok(Self),
726            other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
727        }
728    }
729}
730
731/// Format version `2`: `lock.toml`'s and `state.toml`'s; version `1` reads as it.
732#[derive(Clone, Copy, Debug, Default)]
733pub(crate) struct V2;
734
735impl Serialize for V2 {
736    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
737        serializer.serialize_u8(2)
738    }
739}
740
741impl<'de> Deserialize<'de> for V2 {
742    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
743        match u8::deserialize(deserializer)? {
744            1 | 2 => Ok(Self),
745            other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
746        }
747    }
748}
749
750/// Parses TOML `bytes` read from `file`.
751pub(crate) fn from_toml<T: DeserializeOwned>(bytes: &[u8], file: &str) -> Result<T> {
752    let failed = |text: &str, span, message| ParseError {
753        file: file.into(),
754        text: text.into(),
755        span,
756        message,
757    };
758    let text = str::from_utf8(bytes).map_err(|e| failed("", None, e.to_string()))?;
759    Ok(toml::from_str(text).map_err(|e| failed(text, e.span(), e.message().to_owned()))?)
760}
761
762/// `name` in the `.devset/` at `dir`, parsed; `None` if it does not exist.
763pub(crate) fn read_toml<T: DeserializeOwned>(dir: &Utf8Path, name: &str) -> Result<Option<T>> {
764    read_optional(&dir.join(name))?.map(|raw| from_toml(&raw, &label(name))).transpose()
765}
766
767/// The contents of `path`, or `None` if it does not exist.
768pub(crate) fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>> {
769    match fs_err::read(path) {
770        Ok(bytes) => Ok(Some(bytes)),
771        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
772        Err(e) => Err(e.into()),
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::{LayerSpec, Lock, State, Target};
779    use crate::errors::{Error, TargetError};
780    use crate::source::Source;
781
782    #[test]
783    fn a_new_target_keeps_its_skeleton_above_what_is_added() {
784        let dir = camino_tempfile::tempdir().expect("a directory");
785        let mut target = Target::open_or_new(dir.path()).expect("a new target");
786        target
787            .add_source("house".parse().expect("a name"), Source::Dir("../p".into()))
788            .expect("added");
789        target.add_layer(LayerSpec::new("house/base".parse().expect("a layer"))).expect("added");
790        let text = target.config_text();
791        assert!(text.starts_with("# devset applies"), "the skeleton leads: {text}");
792        assert!(
793            text.contains("\n\n[sources]\nhouse = { path = \"../p\" }\n"),
794            "a source, inline: {text}"
795        );
796        assert!(text.ends_with("[[layers]]\nprofile = \"house/base\"\n"), "then the layer: {text}");
797        let same = target.add_source("house".parse().expect("a name"), Source::Dir("../p".into()));
798        assert!(same.is_ok(), "naming a source again as it is changes nothing");
799        let other = target.add_source("house".parse().expect("a name"), Source::Dir("../q".into()));
800        assert!(matches!(other, Err(Error::Target(TargetError::SourceExists { .. }))), "{other:?}");
801        let twice = target.add_layer(LayerSpec::new("house/base".parse().expect("a layer")));
802        assert!(
803            matches!(twice, Err(Error::Target(TargetError::DuplicateLayer { .. }))),
804            "{twice:?}"
805        );
806        let unknown = target.add_layer(LayerSpec::new("nope/other".parse().expect("a layer")));
807        assert!(
808            matches!(unknown, Err(Error::Target(TargetError::NoSuchSource { .. }))),
809            "{unknown:?}"
810        );
811    }
812
813    #[test]
814    fn devset_0_1_records() {
815        let dir = camino_tempfile::tempdir().expect("a directory");
816        let devset = dir.path().join(".devset");
817        fs_err::create_dir_all(&devset).expect("a directory");
818        fs_err::write(devset.join("config.toml"), "[[layers]]\npath = \"../p\"\n")
819            .expect("written");
820        let old = Target::find(dir.path()).expect_err("an old config");
821        assert!(matches!(old, Error::Target(TargetError::OldConfig)), "refused: {old}");
822        let lock =
823            Lock::read(b"version = 1\n[[layers]]\ndigest = \"x\"\n", "lock.toml").expect("read");
824        assert!(lock.profiles.is_empty(), "an old lock pins nothing");
825        let state: State = toml::from_str(
826            "version = 1\n[files.\"a\"]\nexact = \"0000000000000000000000000000000000000000000000000000000000000000\"\ncanonical = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
827        )
828        .expect("an old state reads");
829        assert_eq!(state.records().count(), 1, "with its records");
830        let written = toml::to_string(&state).expect("written");
831        assert!(written.starts_with("version = 2\n"), "as version 2: {written}");
832    }
833}