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::{Array, ArrayOfTables, DocumentMut, Item, Table, TableLike, 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    /// Turns on `features` of the layer that applies the profile named `name`, beside those it
355    /// lists, and its default features on or off where `defaults` says; written by the next
356    /// [`commit`](crate::commit()). Returns what changed: the features it did not list, and the
357    /// defaults, where they change.
358    ///
359    /// # Errors
360    /// - [`TargetError::NoSuchLayer`], no layer applies the profile.
361    /// - [`Error::Parse`](crate::Error::Parse), as [`remove_layer`](Self::remove_layer).
362    pub fn turn_on(
363        &mut self, name: &ProfileName, features: &[FeatureName], defaults: Option<bool>,
364    ) -> Result<(Vec<FeatureName>, Option<bool>)> {
365        let (index, layer) = self.find_layer(name)?;
366        let mut listed = layer.features.clone();
367        let added: Vec<FeatureName> =
368            features.iter().filter(|feature| !listed.contains(feature)).cloned().collect();
369        listed.extend(added.iter().cloned());
370        let defaults = defaults.filter(|&on| on != layer.default_features);
371        if !added.is_empty() || defaults.is_some() {
372            self.edit_layer(index, |table| {
373                set_features(table, &listed);
374                match defaults {
375                    Some(true) => {
376                        table.remove("default-features");
377                    },
378                    Some(false) => {
379                        table.insert("default-features", toml_edit::value(false));
380                    },
381                    None => {},
382                }
383            })?;
384        }
385        Ok((added, defaults))
386    }
387
388    /// Turns off `features` of the layer that applies the profile named `name`: takes them out of
389    /// those it lists; written by the next [`commit`](crate::commit()). The layer stays.
390    ///
391    /// # Errors
392    /// - [`TargetError::NoSuchLayer`], no layer applies the profile.
393    /// - [`TargetError::NotListed`], the layer does not list one of `features`.
394    /// - [`Error::Parse`](crate::Error::Parse), as [`remove_layer`](Self::remove_layer).
395    pub fn turn_off(&mut self, name: &ProfileName, features: &[FeatureName]) -> Result<()> {
396        let (index, layer) = self.find_layer(name)?;
397        if let Some(feature) = features.iter().find(|feature| !layer.features.contains(feature)) {
398            let (feature, listed) = (feature.clone(), layer.features.clone());
399            return Err(TargetError::NotListed { layer: name.clone(), feature, listed }.into());
400        }
401        let listed: Vec<FeatureName> =
402            layer.features.into_iter().filter(|feature| !features.contains(feature)).collect();
403        self.edit_layer(index, |table| set_features(table, &listed))
404    }
405
406    /// The position of the layer that applies the profile named `name`, and the layer.
407    fn find_layer(&self, name: &ProfileName) -> Result<(usize, LayerSpec)> {
408        let layers = &self.config.layers;
409        let found = layers.iter().enumerate().find(|(_, layer)| layer.profile.profile == *name);
410        found.map(|(index, layer)| (index, layer.clone())).ok_or_else(|| {
411            let names = layers.iter().map(|layer| layer.profile.profile.to_string()).collect();
412            TargetError::NoSuchLayer { name: name.to_string(), names }.into()
413        })
414    }
415
416    /// Applies `edit` to the `index`th layer's table in `config.toml`.
417    fn edit_layer(&mut self, index: usize, edit: impl FnOnce(&mut dyn TableLike)) -> Result<()> {
418        self.edit("`layers` must be a list of tables", |doc| {
419            let table: &mut dyn TableLike = match doc.get_mut("layers") {
420                Some(Item::ArrayOfTables(layers)) => match layers.get_mut(index) {
421                    Some(layer) => layer,
422                    None => return false,
423                },
424                Some(Item::Value(Value::Array(layers))) => {
425                    match layers.get_mut(index).and_then(Value::as_inline_table_mut) {
426                        Some(layer) => layer,
427                        None => return false,
428                    }
429                },
430                Some(_) | None => return false,
431            };
432            edit(table);
433            true
434        })
435    }
436
437    /// Removes the `[files."path"]` override; written by the next [`commit`](crate::commit()).
438    ///
439    /// # Errors
440    /// [`Error::Parse`](crate::Error::Parse), as [`add_layer`](Self::add_layer).
441    pub fn remove_override(&mut self, path: &RelPath) -> Result<()> {
442        self.edit("`files` must be a table", |doc| {
443            if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
444                files.remove(path.as_str());
445            }
446            true
447        })
448    }
449
450    /// Applies `edit` to `config.toml`, keeping its formatting and comments, and reads it again.
451    ///
452    /// `edit` returns `false` when the file is not the shape it expects, which `shape` says. A
453    /// skeleton's comments stay at the top, above the first table.
454    fn edit(&mut self, shape: &str, edit: impl FnOnce(&mut DocumentMut) -> bool) -> Result<()> {
455        let file = label(CONFIG);
456        let failed = |text: &str, message: String| ParseError {
457            file: file.clone(),
458            text: text.to_owned(),
459            span: None,
460            message,
461        };
462        let mut doc: DocumentMut = self
463            .config_text
464            .parse()
465            .map_err(|e: TomlError| failed(&self.config_text, e.to_string()))?;
466        let lead = doc.is_empty().then(|| doc.trailing().as_str().map(str::to_owned)).flatten();
467        if !edit(&mut doc) {
468            return Err(failed(&self.config_text, shape.to_owned()).into());
469        }
470        if let Some(lead) = lead.filter(|lead| !lead.trim().is_empty()) {
471            doc.set_trailing("");
472            if let Some((_, first)) = doc.iter_mut().next() {
473                let prefix = format!("{}\n\n", lead.trim_end());
474                match first {
475                    Item::Table(table) => table.decor_mut().set_prefix(prefix),
476                    Item::ArrayOfTables(tables) => {
477                        if let Some(table) = tables.get_mut(0) {
478                            table.decor_mut().set_prefix(prefix);
479                        }
480                    },
481                    Item::Value(_) | Item::None => {},
482                }
483            }
484        }
485        let text = doc.to_string();
486        self.config = from_toml(text.as_bytes(), &file)?;
487        self.config_text = text;
488        Ok(())
489    }
490
491    /// Answers `name`; written by the next [`commit`](crate::commit()).
492    pub fn answer(&mut self, name: VarName, value: String) {
493        self.given.insert(name.clone());
494        self.answers.insert(name, value);
495    }
496
497    /// Every answer: recorded, or given since.
498    #[must_use]
499    pub const fn answers(&self) -> &BTreeMap<VarName, String> {
500        &self.answers
501    }
502
503    /// Writes the absent files of `scaffold` again at the next [`survey`](crate::survey()),
504    /// whatever was decided of it.
505    pub fn rescaffold(&mut self, scaffold: ScaffoldId) {
506        self.rescaffold.insert(scaffold);
507    }
508
509    /// The scaffolds to write again.
510    pub(crate) const fn rescaffolds(&self) -> &BTreeSet<ScaffoldId> {
511        &self.rescaffold
512    }
513
514    /// What was decided of each scaffold, as `state.toml` recorded it.
515    pub(crate) const fn scaffolds(&self) -> &BTreeMap<ScaffoldId, Scaffold> {
516        &self.state.scaffolds
517    }
518
519    /// Variables answered since loading.
520    pub(crate) fn given(&self) -> impl Iterator<Item = &VarName> {
521        self.given.iter()
522    }
523
524    /// `.devset/`.
525    pub(crate) fn dir(&self) -> Utf8PathBuf {
526        self.root.join(DIR)
527    }
528
529    /// `config.toml` as it should be written.
530    pub(crate) fn config_text(&self) -> &str {
531        &self.config_text
532    }
533
534    /// The lock in force: the pending one while an update is withheld.
535    fn lock(&self) -> &Lock {
536        self.pending.as_ref().unwrap_or(&self.lock)
537    }
538
539    /// The commit the lock pins `source` to.
540    ///
541    /// Found by source, not position, so removing or reordering layers leaves the others pinned.
542    #[must_use]
543    pub fn pinned(&self, source: &Source) -> Option<&Oid> {
544        let locked = self.lock().profiles.iter();
545        locked.filter(|locked| locked.source == *source).find_map(|locked| locked.rev.as_ref())
546    }
547
548    /// The digest the lock recorded for the profile `name` from `source`.
549    pub(crate) fn locked(&self, source: &Source, name: &ProfileName) -> Option<Digest> {
550        let lock = self.lock();
551        let locked = lock.profiles.iter().find(|l| l.source == *source && l.name == *name);
552        locked.map(|locked| locked.digest)
553    }
554
555    /// Where the conflicted merge of `path` waits.
556    pub(crate) fn sidecar(&self, path: &RelPath) -> Utf8PathBuf {
557        path.under(&self.dir().join(CONFLICTS))
558    }
559
560    /// An unfinished update's own files, beside its sidecars.
561    ///
562    /// Named `.devset`, which no managed path may contain, so it never meets a sidecar; and inside
563    /// `conflicts/`, so deleting that directory discards the whole update.
564    pub(crate) fn unfinished(&self) -> Utf8PathBuf {
565        self.dir().join(CONFLICTS).join(DIR)
566    }
567
568    /// The recorded base with fingerprint `record`, or `None` when it is missing or damaged.
569    pub(crate) fn base(&self, record: &Fingerprint) -> Result<Option<Vec<u8>>> {
570        let blob = read_optional(&self.dir().join(BASE).join(record.exact.to_string()))?;
571        Ok(blob.filter(|bytes| Digest::of(bytes) == record.exact))
572    }
573
574    /// Every recorded file and part.
575    pub(crate) fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
576        self.state.records()
577    }
578
579    /// Digest of `state.toml` as read.
580    pub(crate) const fn state_digest(&self) -> Option<Digest> {
581        self.state_digest
582    }
583
584    /// Reads the target at `root`.
585    fn load(root: &Utf8Path) -> Result<Self> {
586        let dir = root.join(DIR);
587        let config_raw = fs_err::read(dir.join(CONFIG))?;
588        let config = read_config(&config_raw)?;
589        let config_text = String::from_utf8(config_raw).map_err(io::Error::other)?;
590        let state_raw = read_optional(&dir.join(STATE))?;
591        let state_digest = state_raw.as_deref().map(Digest::of);
592        let state = state_raw.map(|raw| from_toml(&raw, &label(STATE))).transpose()?;
593        let lock = |name: &str| -> Result<Option<Lock>> {
594            let raw = read_optional(&dir.join(name))?;
595            raw.map(|raw| Lock::read(&raw, &label(name))).transpose()
596        };
597        Ok(Self {
598            root: root.to_owned(),
599            config,
600            config_text,
601            lock: lock(LOCK)?.unwrap_or_default(),
602            pending: lock(&format!("{CONFLICTS}/{DIR}/{PENDING}"))?,
603            state: state.unwrap_or_default(),
604            state_digest,
605            answers: read_toml(&dir, ANSWERS)?.unwrap_or_default(),
606            given: BTreeSet::new(),
607            rescaffold: BTreeSet::new(),
608        })
609    }
610}
611
612/// Writes `features` as the layer's `features`, in the file's form where it has one; none takes
613/// the key out.
614fn set_features(table: &mut dyn TableLike, features: &[FeatureName]) {
615    if features.is_empty() {
616        table.remove("features");
617        return;
618    }
619    let mut array: Array = features.iter().map(FeatureName::as_str).collect();
620    array.fmt();
621    match table.get_mut("features").and_then(Item::as_value_mut) {
622        Some(value) => {
623            let decor = value.decor().clone();
624            *value = Value::Array(array);
625            *value.decor_mut() = decor;
626        },
627        None => {
628            table.insert("features", toml_edit::value(array));
629        },
630    }
631}
632
633/// `config.toml`, refused when it is devset 0.1's, its layers named locations, or when it lists
634/// one layer twice.
635fn read_config(raw: &[u8]) -> Result<Config> {
636    let file = label(CONFIG);
637    let table: toml::Table = from_toml(raw, &file)?;
638    let located = |layer: &toml::Value| {
639        layer
640            .as_table()
641            .is_some_and(|layer| layer.contains_key("git") || layer.contains_key("path"))
642    };
643    let layers = table.get("layers").and_then(toml::Value::as_array);
644    if layers.is_some_and(|layers| layers.iter().any(located)) {
645        return Err(TargetError::OldConfig.into());
646    }
647    let config: Config = from_toml(raw, &file)?;
648    let mut named = BTreeSet::new();
649    if let Some(layer) = config.layers.iter().find(|layer| !named.insert(&layer.profile.profile)) {
650        return Err(TargetError::LayerTwice { layer: layer.profile.profile.clone() }.into());
651    }
652    Ok(config)
653}
654
655/// `name` in `.devset/`, as users know it.
656pub(crate) fn label(name: &str) -> String {
657    format!("{DIR}/{name}")
658}
659
660/// The nearest ancestor of `dir`, itself included, holding `.devset/config.toml`.
661fn enclosing(dir: &Utf8Path) -> Option<&Utf8Path> {
662    dir.ancestors().find(|d| d.join(DIR).join(CONFIG).is_file())
663}
664
665/// `lock.toml`.
666#[derive(Debug, Default, Serialize, Deserialize)]
667#[serde(deny_unknown_fields)]
668pub(crate) struct Lock {
669    /// Format version.
670    version: V2,
671    /// One per active profile, in the order they apply.
672    #[serde(default, skip_serializing_if = "Vec::is_empty")]
673    profiles: Vec<Locked>,
674}
675
676/// What one active profile resolved to.
677#[derive(Debug, Serialize, Deserialize)]
678#[serde(deny_unknown_fields)]
679pub(crate) struct Locked {
680    /// The profile's name.
681    pub name: ProfileName,
682    /// Commit of its source, for a versioned one; every profile of a source has one.
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub rev: Option<Oid>,
685    /// Digest of its manifest, rendered payload and features.
686    pub digest: Digest,
687    /// Its source, as configured when locked.
688    pub source: Source,
689    /// Every feature that was on, with who turned it on.
690    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
691    pub features: BTreeMap<FeatureName, Vec<String>>,
692}
693
694impl Locked {
695    /// Who turned each of `features` on, as the lock writes it.
696    pub(crate) fn features(
697        features: &BTreeMap<FeatureName, BTreeSet<Enabler>>,
698    ) -> BTreeMap<FeatureName, Vec<String>> {
699        features
700            .iter()
701            .map(|(feature, by)| (feature.clone(), by.iter().map(ToString::to_string).collect()))
702            .collect()
703    }
704}
705
706impl Lock {
707    /// A lock of `profiles`, in order.
708    pub(crate) const fn new(profiles: Vec<Locked>) -> Self {
709        Self { version: V2, profiles }
710    }
711
712    /// The lock in `raw`, read from `file`; devset 0.1's is empty, so every source resolves anew.
713    fn read(raw: &[u8], file: &str) -> Result<Self> {
714        let table: toml::Table = from_toml(raw, file)?;
715        if table.get("version").and_then(toml::Value::as_integer) == Some(1) {
716            return Ok(Self::default());
717        }
718        from_toml(raw, file)
719    }
720}
721
722/// `state.toml`.
723#[derive(Debug, Default, Serialize, Deserialize)]
724#[serde(deny_unknown_fields)]
725pub(crate) struct State {
726    /// Format version; devset 0.1's reads as this one.
727    version: V2,
728    /// What was decided of each scaffold.
729    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
730    scaffolds: BTreeMap<ScaffoldId, Scaffold>,
731    /// Each managed file's recorded base.
732    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
733    files: BTreeMap<RelPath, FileRecord>,
734    /// Each part's recorded base, by file, then by the profile that owns it.
735    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
736    parts: BTreeMap<RelPath, BTreeMap<String, PartRecord>>,
737}
738
739/// What devset recorded for one file or part.
740#[derive(Clone, Copy, Debug)]
741pub(crate) struct Record {
742    /// How much of the file it is.
743    pub scope: Scope,
744    /// Its content, as recorded.
745    pub fingerprint: Fingerprint,
746    /// How it was managed when recorded.
747    pub policy: Policy,
748}
749
750/// What devset recorded for one whole file: the digests of its content, and its policy.
751#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
752#[serde(deny_unknown_fields)]
753struct FileRecord {
754    /// As [`Fingerprint::exact`].
755    exact: Digest,
756    /// As [`Fingerprint::canonical`].
757    canonical: Digest,
758    /// How it was managed; `owned`, the default, is not written.
759    #[serde(default, skip_serializing_if = "Policy::is_owned")]
760    policy: Policy,
761}
762
763/// What devset recorded for one part: its scope, the digests of its content, and its policy.
764#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
765#[serde(deny_unknown_fields)]
766struct PartRecord {
767    /// How much of the file the part is.
768    scope: Scope,
769    /// As [`Fingerprint::exact`].
770    exact: Digest,
771    /// As [`Fingerprint::canonical`].
772    canonical: Digest,
773    /// How it was managed; `owned`, the default, is not written.
774    #[serde(default, skip_serializing_if = "Policy::is_owned")]
775    policy: Policy,
776}
777
778impl State {
779    /// A state recording `records`, and what was decided of `scaffolds`.
780    pub(crate) fn new(
781        records: BTreeMap<Slot, Record>, scaffolds: BTreeMap<ScaffoldId, Scaffold>,
782    ) -> Self {
783        let mut state = Self { scaffolds, ..Self::default() };
784        for (Slot { path, part }, Record { scope, fingerprint, policy }) in records {
785            let Fingerprint { exact, canonical } = fingerprint;
786            match part {
787                None => {
788                    state.files.insert(path, FileRecord { exact, canonical, policy });
789                },
790                Some(owner) => {
791                    let record = PartRecord { scope, exact, canonical, policy };
792                    state.parts.entry(path).or_default().insert(owner, record);
793                },
794            }
795        }
796        state
797    }
798
799    /// Every record, whole files first.
800    fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
801        let files = self.files.iter().map(|(path, record)| {
802            let FileRecord { exact, canonical, policy } = *record;
803            let fingerprint = Fingerprint { exact, canonical };
804            (Slot::whole(path.clone()), Record { scope: Scope::File, fingerprint, policy })
805        });
806        let parts = self.parts.iter().flat_map(|(path, parts)| {
807            parts.iter().map(|(owner, record)| {
808                let slot = Slot { path: path.clone(), part: Some(owner.clone()) };
809                let PartRecord { scope, exact, canonical, policy } = *record;
810                (slot, Record { scope, fingerprint: Fingerprint { exact, canonical }, policy })
811            })
812        });
813        files.chain(parts)
814    }
815
816    /// The bases it references.
817    pub(crate) fn bases(&self) -> impl Iterator<Item = Digest> {
818        self.records().map(|(_, record)| record.fingerprint.exact)
819    }
820}
821
822/// Format version `1`: `undo.toml`'s.
823#[derive(Clone, Copy, Debug, Default)]
824pub(crate) struct V1;
825
826impl Serialize for V1 {
827    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
828        serializer.serialize_u8(1)
829    }
830}
831
832impl<'de> Deserialize<'de> for V1 {
833    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
834        match u8::deserialize(deserializer)? {
835            1 => Ok(Self),
836            other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
837        }
838    }
839}
840
841/// Format version `2`: `lock.toml`'s and `state.toml`'s; version `1` reads as it.
842#[derive(Clone, Copy, Debug, Default)]
843pub(crate) struct V2;
844
845impl Serialize for V2 {
846    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
847        serializer.serialize_u8(2)
848    }
849}
850
851impl<'de> Deserialize<'de> for V2 {
852    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
853        match u8::deserialize(deserializer)? {
854            1 | 2 => Ok(Self),
855            other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
856        }
857    }
858}
859
860/// Parses TOML `bytes` read from `file`.
861pub(crate) fn from_toml<T: DeserializeOwned>(bytes: &[u8], file: &str) -> Result<T> {
862    let failed = |text: &str, span, message| ParseError {
863        file: file.into(),
864        text: text.into(),
865        span,
866        message,
867    };
868    let text = str::from_utf8(bytes).map_err(|e| failed("", None, e.to_string()))?;
869    Ok(toml::from_str(text).map_err(|e| failed(text, e.span(), e.message().to_owned()))?)
870}
871
872/// `name` in the `.devset/` at `dir`, parsed; `None` if it does not exist.
873pub(crate) fn read_toml<T: DeserializeOwned>(dir: &Utf8Path, name: &str) -> Result<Option<T>> {
874    read_optional(&dir.join(name))?.map(|raw| from_toml(&raw, &label(name))).transpose()
875}
876
877/// The contents of `path`, or `None` if it does not exist.
878pub(crate) fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>> {
879    match fs_err::read(path) {
880        Ok(bytes) => Ok(Some(bytes)),
881        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
882        Err(e) => Err(e.into()),
883    }
884}
885
886#[cfg(test)]
887mod tests {
888    use super::{LayerSpec, Lock, State, Target};
889    use crate::errors::{Error, TargetError};
890    use crate::source::Source;
891
892    #[test]
893    fn a_new_target_keeps_its_skeleton_above_what_is_added() {
894        let dir = camino_tempfile::tempdir().expect("a directory");
895        let mut target = Target::open_or_new(dir.path()).expect("a new target");
896        target
897            .add_source("house".parse().expect("a name"), Source::Dir("../p".into()))
898            .expect("added");
899        target.add_layer(LayerSpec::new("house/base".parse().expect("a layer"))).expect("added");
900        let text = target.config_text();
901        assert!(text.starts_with("# devset applies"), "the skeleton leads: {text}");
902        assert!(
903            text.contains("\n\n[sources]\nhouse = { path = \"../p\" }\n"),
904            "a source, inline: {text}"
905        );
906        assert!(text.ends_with("[[layers]]\nprofile = \"house/base\"\n"), "then the layer: {text}");
907        let same = target.add_source("house".parse().expect("a name"), Source::Dir("../p".into()));
908        assert!(same.is_ok(), "naming a source again as it is changes nothing");
909        let other = target.add_source("house".parse().expect("a name"), Source::Dir("../q".into()));
910        assert!(matches!(other, Err(Error::Target(TargetError::SourceExists { .. }))), "{other:?}");
911        let twice = target.add_layer(LayerSpec::new("house/base".parse().expect("a layer")));
912        assert!(
913            matches!(twice, Err(Error::Target(TargetError::DuplicateLayer { .. }))),
914            "{twice:?}"
915        );
916        let unknown = target.add_layer(LayerSpec::new("nope/other".parse().expect("a layer")));
917        assert!(
918            matches!(unknown, Err(Error::Target(TargetError::NoSuchSource { .. }))),
919            "{unknown:?}"
920        );
921    }
922
923    #[test]
924    fn devset_0_1_records() {
925        let dir = camino_tempfile::tempdir().expect("a directory");
926        let devset = dir.path().join(".devset");
927        fs_err::create_dir_all(&devset).expect("a directory");
928        fs_err::write(devset.join("config.toml"), "[[layers]]\npath = \"../p\"\n")
929            .expect("written");
930        let old = Target::find(dir.path()).expect_err("an old config");
931        assert!(matches!(old, Error::Target(TargetError::OldConfig)), "refused: {old}");
932        let lock =
933            Lock::read(b"version = 1\n[[layers]]\ndigest = \"x\"\n", "lock.toml").expect("read");
934        assert!(lock.profiles.is_empty(), "an old lock pins nothing");
935        let state: State = toml::from_str(
936            "version = 1\n[files.\"a\"]\nexact = \"0000000000000000000000000000000000000000000000000000000000000000\"\ncanonical = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
937        )
938        .expect("an old state reads");
939        assert_eq!(state.records().count(), 1, "with its records");
940        let written = toml::to_string(&state).expect("written");
941        assert!(written.starts_with("version = 2\n"), "as version 2: {written}");
942    }
943}