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::{GitRef, 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#:schema https://atomix-labs.github.io/devset/schema/config.json
93# devset applies the profiles below to this directory, and keeps them up to date.
94# The manual: https://atomix-labs.github.io/devset/
95#
96# Where profiles come from, each named once: a git repository at a ref, or a directory.
97#
98#   [sources]
99#   atxp = { git = \"https://github.com/atomix-labs/atxp\", tag = \"v0.11.0\" }
100#
101# The profiles to apply, in order, by source and name, with the features to turn on.
102#
103#   [[layers]]
104#   profile  = \"atxp/rust\"
105#   features = [\"docs\"]
106";
107
108/// `.devset/config.toml`: the sources, the layers, and the target's word on every setting they
109/// carry.
110#[derive(Debug, Default, Deserialize, JsonSchema)]
111#[serde(deny_unknown_fields)]
112pub struct Config {
113    /// Where profiles come from, each named once.
114    #[serde(default)]
115    pub sources: BTreeMap<SourceName, Source>,
116    /// Profiles to apply, in order.
117    #[serde(default)]
118    pub layers: Vec<LayerSpec>,
119    /// Merge settings; these override every layer's.
120    #[serde(default)]
121    pub merge: MergeSpec,
122    /// Overrides of the layers' `[files]` entries.
123    #[serde(default)]
124    pub files: BTreeMap<RelPath, Override>,
125}
126
127/// A `[[layers]]` entry: a profile, and the features the target turns on.
128#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
129#[serde(deny_unknown_fields, rename_all = "kebab-case")]
130pub struct LayerSpec {
131    /// The profile, `source/name`.
132    pub profile: ProfileRef,
133    /// Its features to turn on, beside its default ones.
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub features: Vec<FeatureName>,
136    /// Whether its default features are on; unless a profile that requires it turns them on.
137    #[serde(default = "yes", skip_serializing_if = "is_yes")]
138    pub default_features: bool,
139}
140
141impl LayerSpec {
142    /// A layer of `profile`, its default features on and no other.
143    #[must_use]
144    pub const fn new(profile: ProfileRef) -> Self {
145        Self { profile, features: Vec::new(), default_features: true }
146    }
147}
148
149/// `true`, serde's default for a flag that is on unless set.
150const fn yes() -> bool {
151    true
152}
153
154/// Whether `flag` is on, its default.
155#[expect(
156    clippy::trivially_copy_pass_by_ref,
157    reason = "serde's `skip_serializing_if` passes a reference"
158)]
159const fn is_yes(flag: &bool) -> bool {
160    *flag
161}
162
163/// A `[files."path"]` entry in `config.toml`, overriding the providing layer's.
164#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
165#[serde(deny_unknown_fields)]
166pub struct Override {
167    /// The layer that provides the file, when several do.
168    #[serde(default)]
169    pub from: Option<ProfileName>,
170    /// How devset manages the file.
171    #[serde(default)]
172    pub policy: Option<Policy>,
173    /// How a merged result is checked.
174    #[serde(default)]
175    pub validate: Option<Format>,
176}
177
178/// What was decided of a scaffold, and where it wrote its files: `state.toml`'s record.
179#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct Scaffold {
182    /// Whether the group was written, or found the target's own.
183    pub decision: Scaffolded,
184    /// Each file it wrote, by its path as the profile writes it, at its path in the target: a
185    /// changed answer moves no file a scaffold wrote.
186    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
187    pub files: BTreeMap<RelPath, RelPath>,
188}
189
190/// What was decided of a scaffold, once and for good.
191#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Display)]
192#[serde(rename_all = "lowercase")]
193#[display(rename_all = "lowercase")]
194pub enum Scaffolded {
195    /// Its sentinels were absent, so its files were written.
196    Written,
197    /// A sentinel was there: the target has its own, and the group was left out.
198    Found,
199}
200
201impl Target {
202    /// The target containing `dir`: the nearest ancestor holding `.devset/config.toml`.
203    ///
204    /// # Errors
205    /// - [`TargetError::NotFound`], no ancestor holds one.
206    /// - [`TargetError::OldConfig`], its `config.toml` is devset 0.1's.
207    /// - [`Error::Parse`](crate::Error::Parse), one of its files does not parse.
208    pub fn find(dir: &Utf8Path) -> Result<Self> {
209        let root = enclosing(dir).ok_or_else(|| TargetError::NotFound { dir: dir.to_owned() })?;
210        Self::load(root)
211    }
212
213    /// The target rooted at `root`, or a new one there, its `config.toml` a commented skeleton.
214    /// Never one inside another target.
215    ///
216    /// # Errors
217    /// - [`TargetError::Nested`], `root` lies inside another target.
218    /// - [`TargetError::OldConfig`], its `config.toml` is devset 0.1's.
219    /// - [`Error::Parse`](crate::Error::Parse), one of its files does not parse.
220    pub fn open_or_new(root: &Utf8Path) -> Result<Self> {
221        match enclosing(root) {
222            Some(found) if found == root => Self::load(root),
223            Some(outer) => {
224                Err(TargetError::Nested { dir: root.to_owned(), root: outer.to_owned() }.into())
225            },
226            None => Ok(Self {
227                root: root.to_owned(),
228                config: Config::default(),
229                config_text: SKELETON.to_owned(),
230                lock: Lock::default(),
231                pending: None,
232                state: State::default(),
233                answers: BTreeMap::new(),
234                given: BTreeSet::new(),
235                rescaffold: BTreeSet::new(),
236                state_digest: None,
237            }),
238        }
239    }
240
241    /// The directory holding `.devset/`.
242    #[must_use]
243    pub fn root(&self) -> &Utf8Path {
244        &self.root
245    }
246
247    /// `config.toml`.
248    #[must_use]
249    pub const fn config(&self) -> &Config {
250        &self.config
251    }
252
253    /// Whether `config.toml` exists yet: a new target's is written by its first
254    /// [`commit`](crate::commit()).
255    #[must_use]
256    pub fn exists(&self) -> bool {
257        self.dir().join(CONFIG).is_file()
258    }
259
260    /// Names `source` `name` in `[sources]`; written by the next [`commit`](crate::commit()).
261    ///
262    /// Nothing changes when it already does.
263    ///
264    /// # Errors
265    /// - [`TargetError::SourceExists`], `name` already names another source.
266    /// - [`Error::Parse`](crate::Error::Parse), `sources` in `config.toml` is not a table.
267    pub fn add_source(&mut self, name: SourceName, source: Source) -> Result<()> {
268        match self.config.sources.get(&name) {
269            Some(named) if *named == source => return Ok(()),
270            Some(named) => {
271                return Err(TargetError::SourceExists { name, location: named.to_string() }.into());
272            },
273            None => {},
274        }
275        let spec = ser::to_document(&SourceSpec::from(source)).map_err(io::Error::other)?;
276        let inline = spec.into_table().into_inline_table();
277        self.edit("`sources` must be a table", |doc| {
278            let sources = doc.entry("sources").or_insert_with(|| Item::Table(Table::new()));
279            let Some(sources) = sources.as_table_like_mut() else { return false };
280            sources.insert(name.as_str(), Item::Value(Value::InlineTable(inline)));
281            true
282        })
283    }
284
285    /// Moves the git source `name` to `at`, in place, keeping how `config.toml` writes it; written
286    /// by the next [`commit`](crate::commit()).
287    ///
288    /// # Errors
289    /// - [`TargetError::NoSuchSource`], `[sources]` does not name it.
290    /// - [`TargetError::NotGit`], it is a directory.
291    /// - [`Error::Parse`](crate::Error::Parse), `sources` in `config.toml` is not a table.
292    pub fn repin(&mut self, name: &SourceName, at: GitRef) -> Result<()> {
293        match self.config.sources.get(name) {
294            Some(Source::Git { .. }) => {},
295            Some(Source::Dir(_)) => return Err(TargetError::NotGit { name: name.clone() }.into()),
296            None => {
297                let sources = self.config.sources.keys().cloned().collect();
298                let (name, layers) = (name.clone(), Vec::new());
299                return Err(TargetError::NoSuchSource { name, sources, layers }.into());
300            },
301        }
302        let written = match at {
303            GitRef::Head => None,
304            GitRef::Tag(tag) => Some(("tag", tag)),
305            GitRef::Branch(branch) => Some(("branch", branch)),
306            GitRef::Rev(rev) => Some(("rev", rev.into())),
307        };
308        self.edit("`sources` must be a table of sources", |doc| {
309            let Some(entry) = doc
310                .get_mut("sources")
311                .and_then(Item::as_table_like_mut)
312                .and_then(|sources| sources.get_mut(name.as_str()))
313            else {
314                return false;
315            };
316            let Some(table) = entry.as_table_like_mut() else { return false };
317            let kept = written.as_ref().map(|(key, _)| *key);
318            for key in ["tag", "branch", "rev"] {
319                if Some(key) != kept {
320                    table.remove(key);
321                }
322            }
323            if let Some((key, value)) = written {
324                // A ref of the same kind keeps its place and its spacing.
325                match table.get_mut(key).and_then(Item::as_value_mut) {
326                    Some(old) => {
327                        let decor = old.decor().clone();
328                        *old = Value::from(value);
329                        *old.decor_mut() = decor;
330                    },
331                    None => {
332                        table.insert(key, toml_edit::value(value));
333                    },
334                }
335            }
336            if let Some(inline) = entry.as_inline_table_mut() {
337                inline.fmt();
338            }
339            true
340        })
341    }
342
343    /// Appends `layer` as the last layer; written by the next [`commit`](crate::commit()).
344    ///
345    /// # Errors
346    /// - [`TargetError::DuplicateLayer`], a layer already applies the profile.
347    /// - [`TargetError::NoSuchSource`], `[sources]` does not name its source.
348    /// - [`Error::Parse`](crate::Error::Parse), `layers` in `config.toml` is not a list of tables.
349    pub fn add_layer(&mut self, layer: LayerSpec) -> Result<()> {
350        if self.layer(&layer.profile.profile).is_some() {
351            return Err(TargetError::DuplicateLayer { layer: layer.profile.profile }.into());
352        }
353        if !self.config.sources.contains_key(&layer.profile.source) {
354            let sources = self.config.sources.keys().cloned().collect();
355            let (name, layers) = (layer.profile.source.clone(), vec![layer.profile]);
356            return Err(TargetError::NoSuchSource { name, sources, layers }.into());
357        }
358        let table = ser::to_document(&layer).map_err(io::Error::other)?.into_table();
359        self.edit("`layers` must be a list of tables", |doc| {
360            match doc.entry("layers").or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())) {
361                Item::ArrayOfTables(layers) => layers.push(table),
362                Item::Value(Value::Array(layers)) => layers.push(table.into_inline_table()),
363                Item::None | Item::Value(_) | Item::Table(_) => return false,
364            }
365            true
366        })
367    }
368
369    /// The layer that applies the profile named `name`.
370    #[must_use]
371    pub fn layer(&self, name: &ProfileName) -> Option<&LayerSpec> {
372        self.config.layers.iter().find(|layer| layer.profile.profile == *name)
373    }
374
375    /// Removes the layer that applies the profile named `name`; written by the next
376    /// [`commit`](crate::commit()). Its source stays, for another layer to use.
377    ///
378    /// Every `[files]` override that picks it, `from = "<name>"`, goes with it.
379    ///
380    /// # Errors
381    /// [`Error::Parse`](crate::Error::Parse), `config.toml` changed shape so that the layer
382    /// cannot be found in it.
383    pub fn remove_layer(&mut self, name: &ProfileName) -> Result<()> {
384        let Some(index) = self.config.layers.iter().position(|l| l.profile.profile == *name) else {
385            return Ok(());
386        };
387        self.edit("`layers` must be a list of tables", |doc| {
388            match doc.get_mut("layers") {
389                Some(Item::ArrayOfTables(layers)) if index < layers.len() => {
390                    layers.remove(index);
391                },
392                Some(Item::Value(Value::Array(layers))) if index < layers.len() => {
393                    layers.remove(index);
394                },
395                Some(_) | None => return false,
396            }
397            if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
398                let picks =
399                    |item: &Item| item.get("from").and_then(Item::as_str) == Some(name.as_str());
400                let picked: Vec<String> = files
401                    .iter()
402                    .filter(|(_, item)| picks(item))
403                    .map(|(key, _)| key.to_owned())
404                    .collect();
405                for key in picked {
406                    files.remove(&key);
407                }
408            }
409            true
410        })
411    }
412
413    /// Turns on `features` of the layer that applies the profile named `name`, beside those it
414    /// lists, and its default features on or off where `defaults` says; written by the next
415    /// [`commit`](crate::commit()). Returns what changed: the features it did not list, and the
416    /// defaults, where they change.
417    ///
418    /// # Errors
419    /// - [`TargetError::NoSuchLayer`], no layer applies the profile.
420    /// - [`Error::Parse`](crate::Error::Parse), as [`remove_layer`](Self::remove_layer).
421    pub fn turn_on(
422        &mut self, name: &ProfileName, features: &[FeatureName], defaults: Option<bool>,
423    ) -> Result<(Vec<FeatureName>, Option<bool>)> {
424        let (index, layer) = self.find_layer(name)?;
425        let mut listed = layer.features.clone();
426        let added: Vec<FeatureName> =
427            features.iter().filter(|feature| !listed.contains(feature)).cloned().collect();
428        listed.extend(added.iter().cloned());
429        let defaults = defaults.filter(|&on| on != layer.default_features);
430        if !added.is_empty() || defaults.is_some() {
431            self.edit_layer(index, |table| {
432                set_features(table, &listed);
433                match defaults {
434                    Some(true) => {
435                        table.remove("default-features");
436                    },
437                    Some(false) => {
438                        table.insert("default-features", toml_edit::value(false));
439                    },
440                    None => {},
441                }
442            })?;
443        }
444        Ok((added, defaults))
445    }
446
447    /// Turns off `features` of the layer that applies the profile named `name`: takes them out of
448    /// those it lists; written by the next [`commit`](crate::commit()). The layer stays.
449    ///
450    /// # Errors
451    /// - [`TargetError::NoSuchLayer`], no layer applies the profile.
452    /// - [`TargetError::NotListed`], the layer does not list one of `features`.
453    /// - [`Error::Parse`](crate::Error::Parse), as [`remove_layer`](Self::remove_layer).
454    pub fn turn_off(&mut self, name: &ProfileName, features: &[FeatureName]) -> Result<()> {
455        let (index, layer) = self.find_layer(name)?;
456        if let Some(feature) = features.iter().find(|feature| !layer.features.contains(feature)) {
457            let (feature, listed) = (feature.clone(), layer.features.clone());
458            return Err(TargetError::NotListed { layer: name.clone(), feature, listed }.into());
459        }
460        let listed: Vec<FeatureName> =
461            layer.features.into_iter().filter(|feature| !features.contains(feature)).collect();
462        self.edit_layer(index, |table| set_features(table, &listed))
463    }
464
465    /// The position of the layer that applies the profile named `name`, and the layer.
466    fn find_layer(&self, name: &ProfileName) -> Result<(usize, LayerSpec)> {
467        let layers = &self.config.layers;
468        let found = layers.iter().enumerate().find(|(_, layer)| layer.profile.profile == *name);
469        found.map(|(index, layer)| (index, layer.clone())).ok_or_else(|| {
470            let names = layers.iter().map(|layer| layer.profile.profile.to_string()).collect();
471            TargetError::NoSuchLayer { name: name.to_string(), names }.into()
472        })
473    }
474
475    /// Applies `edit` to the `index`th layer's table in `config.toml`.
476    fn edit_layer(&mut self, index: usize, edit: impl FnOnce(&mut dyn TableLike)) -> Result<()> {
477        self.edit("`layers` must be a list of tables", |doc| {
478            let table: &mut dyn TableLike = match doc.get_mut("layers") {
479                Some(Item::ArrayOfTables(layers)) => match layers.get_mut(index) {
480                    Some(layer) => layer,
481                    None => return false,
482                },
483                Some(Item::Value(Value::Array(layers))) => {
484                    match layers.get_mut(index).and_then(Value::as_inline_table_mut) {
485                        Some(layer) => layer,
486                        None => return false,
487                    }
488                },
489                Some(_) | None => return false,
490            };
491            edit(table);
492            true
493        })
494    }
495
496    /// Removes the `[files."path"]` override; written by the next [`commit`](crate::commit()).
497    ///
498    /// # Errors
499    /// [`Error::Parse`](crate::Error::Parse), as [`add_layer`](Self::add_layer).
500    pub fn remove_override(&mut self, path: &RelPath) -> Result<()> {
501        self.edit("`files` must be a table", |doc| {
502            if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
503                files.remove(path.as_str());
504            }
505            true
506        })
507    }
508
509    /// Applies `edit` to `config.toml`, keeping its formatting and comments, and reads it again.
510    ///
511    /// `edit` returns `false` when the file is not the shape it expects, which `shape` says. A
512    /// skeleton's comments stay at the top, above the first table.
513    fn edit(&mut self, shape: &str, edit: impl FnOnce(&mut DocumentMut) -> bool) -> Result<()> {
514        let file = label(CONFIG);
515        let failed = |text: &str, message: String| ParseError {
516            file: file.clone(),
517            text: text.to_owned(),
518            span: None,
519            message,
520        };
521        let mut doc: DocumentMut = self
522            .config_text
523            .parse()
524            .map_err(|e: TomlError| failed(&self.config_text, e.to_string()))?;
525        let lead = doc.is_empty().then(|| doc.trailing().as_str().map(str::to_owned)).flatten();
526        if !edit(&mut doc) {
527            return Err(failed(&self.config_text, shape.to_owned()).into());
528        }
529        if let Some(lead) = lead.filter(|lead| !lead.trim().is_empty()) {
530            doc.set_trailing("");
531            if let Some((_, first)) = doc.iter_mut().next() {
532                let prefix = format!("{}\n\n", lead.trim_end());
533                match first {
534                    Item::Table(table) => table.decor_mut().set_prefix(prefix),
535                    Item::ArrayOfTables(tables) => {
536                        if let Some(table) = tables.get_mut(0) {
537                            table.decor_mut().set_prefix(prefix);
538                        }
539                    },
540                    Item::Value(_) | Item::None => {},
541                }
542            }
543        }
544        let text = doc.to_string();
545        self.config = from_toml(text.as_bytes(), &file)?;
546        self.config_text = text;
547        Ok(())
548    }
549
550    /// Answers `name`; written by the next [`commit`](crate::commit()).
551    pub fn answer(&mut self, name: VarName, value: String) {
552        self.given.insert(name.clone());
553        self.answers.insert(name, value);
554    }
555
556    /// Every answer: recorded, or given since.
557    #[must_use]
558    pub const fn answers(&self) -> &BTreeMap<VarName, String> {
559        &self.answers
560    }
561
562    /// Writes the absent files of `scaffold` again at the next [`survey`](crate::survey()),
563    /// whatever was decided of it.
564    pub fn rescaffold(&mut self, scaffold: ScaffoldId) {
565        self.rescaffold.insert(scaffold);
566    }
567
568    /// The scaffolds to write again.
569    pub(crate) const fn rescaffolds(&self) -> &BTreeSet<ScaffoldId> {
570        &self.rescaffold
571    }
572
573    /// What was decided of each scaffold, as `state.toml` recorded it.
574    pub(crate) const fn scaffolds(&self) -> &BTreeMap<ScaffoldId, Scaffold> {
575        &self.state.scaffolds
576    }
577
578    /// Variables answered since loading.
579    pub(crate) fn given(&self) -> impl Iterator<Item = &VarName> {
580        self.given.iter()
581    }
582
583    /// `.devset/`.
584    pub(crate) fn dir(&self) -> Utf8PathBuf {
585        self.root.join(DIR)
586    }
587
588    /// `config.toml` as it should be written.
589    pub(crate) fn config_text(&self) -> &str {
590        &self.config_text
591    }
592
593    /// The lock in force: the pending one while an update is withheld.
594    fn lock(&self) -> &Lock {
595        self.pending.as_ref().unwrap_or(&self.lock)
596    }
597
598    /// The commit the lock pins `source` to.
599    ///
600    /// Found by source, not position, so removing or reordering layers leaves the others pinned.
601    #[must_use]
602    pub fn pinned(&self, source: &Source) -> Option<&Oid> {
603        let locked = self.lock().profiles.iter();
604        locked.filter(|locked| locked.source == *source).find_map(|locked| locked.rev.as_ref())
605    }
606
607    /// The digest the lock recorded for the profile `name` from `source`.
608    pub(crate) fn locked(&self, source: &Source, name: &ProfileName) -> Option<Digest> {
609        let lock = self.lock();
610        let locked = lock.profiles.iter().find(|l| l.source == *source && l.name == *name);
611        locked.map(|locked| locked.digest)
612    }
613
614    /// Where the conflicted merge of `path` waits.
615    pub(crate) fn sidecar(&self, path: &RelPath) -> Utf8PathBuf {
616        path.under(&self.dir().join(CONFLICTS))
617    }
618
619    /// An unfinished update's own files, beside its sidecars.
620    ///
621    /// Named `.devset`, which no managed path may contain, so it never meets a sidecar; and inside
622    /// `conflicts/`, so deleting that directory discards the whole update.
623    pub(crate) fn unfinished(&self) -> Utf8PathBuf {
624        self.dir().join(CONFLICTS).join(DIR)
625    }
626
627    /// The recorded base with fingerprint `record`, or `None` when it is missing or damaged.
628    pub(crate) fn base(&self, record: &Fingerprint) -> Result<Option<Vec<u8>>> {
629        let blob = read_optional(&self.dir().join(BASE).join(record.exact.to_string()))?;
630        Ok(blob.filter(|bytes| Digest::of(bytes) == record.exact))
631    }
632
633    /// Every recorded file and part.
634    pub(crate) fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
635        self.state.records()
636    }
637
638    /// Digest of `state.toml` as read.
639    pub(crate) const fn state_digest(&self) -> Option<Digest> {
640        self.state_digest
641    }
642
643    /// Reads the target at `root`.
644    fn load(root: &Utf8Path) -> Result<Self> {
645        let dir = root.join(DIR);
646        let config_raw = fs_err::read(dir.join(CONFIG))?;
647        let config = read_config(&config_raw)?;
648        let config_text = String::from_utf8(config_raw).map_err(io::Error::other)?;
649        let state_raw = read_optional(&dir.join(STATE))?;
650        let state_digest = state_raw.as_deref().map(Digest::of);
651        let state = state_raw.map(|raw| from_toml(&raw, &label(STATE))).transpose()?;
652        let lock = |name: &str| -> Result<Option<Lock>> {
653            let raw = read_optional(&dir.join(name))?;
654            raw.map(|raw| Lock::read(&raw, &label(name))).transpose()
655        };
656        Ok(Self {
657            root: root.to_owned(),
658            config,
659            config_text,
660            lock: lock(LOCK)?.unwrap_or_default(),
661            pending: lock(&format!("{CONFLICTS}/{DIR}/{PENDING}"))?,
662            state: state.unwrap_or_default(),
663            state_digest,
664            answers: read_toml(&dir, ANSWERS)?.unwrap_or_default(),
665            given: BTreeSet::new(),
666            rescaffold: BTreeSet::new(),
667        })
668    }
669}
670
671/// Writes `features` as the layer's `features`, in the file's form where it has one; none takes
672/// the key out.
673fn set_features(table: &mut dyn TableLike, features: &[FeatureName]) {
674    if features.is_empty() {
675        table.remove("features");
676        return;
677    }
678    let mut array: Array = features.iter().map(FeatureName::as_str).collect();
679    array.fmt();
680    match table.get_mut("features").and_then(Item::as_value_mut) {
681        Some(value) => {
682            let decor = value.decor().clone();
683            *value = Value::Array(array);
684            *value.decor_mut() = decor;
685        },
686        None => {
687            table.insert("features", toml_edit::value(array));
688        },
689    }
690}
691
692/// `config.toml`, refused when it is devset 0.1's, its layers named locations, or when it lists
693/// one layer twice.
694fn read_config(raw: &[u8]) -> Result<Config> {
695    let file = label(CONFIG);
696    let table: toml::Table = from_toml(raw, &file)?;
697    let located = |layer: &toml::Value| {
698        layer
699            .as_table()
700            .is_some_and(|layer| layer.contains_key("git") || layer.contains_key("path"))
701    };
702    let layers = table.get("layers").and_then(toml::Value::as_array);
703    if layers.is_some_and(|layers| layers.iter().any(located)) {
704        return Err(TargetError::OldConfig.into());
705    }
706    let config: Config = from_toml(raw, &file)?;
707    let mut named = BTreeSet::new();
708    if let Some(layer) = config.layers.iter().find(|layer| !named.insert(&layer.profile.profile)) {
709        return Err(TargetError::LayerTwice { layer: layer.profile.profile.clone() }.into());
710    }
711    Ok(config)
712}
713
714/// `name` in `.devset/`, as users know it.
715pub(crate) fn label(name: &str) -> String {
716    format!("{DIR}/{name}")
717}
718
719/// The nearest ancestor of `dir`, itself included, holding `.devset/config.toml`.
720fn enclosing(dir: &Utf8Path) -> Option<&Utf8Path> {
721    dir.ancestors().find(|d| d.join(DIR).join(CONFIG).is_file())
722}
723
724/// `lock.toml`.
725#[derive(Debug, Default, Serialize, Deserialize)]
726#[serde(deny_unknown_fields)]
727pub(crate) struct Lock {
728    /// Format version.
729    version: V2,
730    /// One per active profile, in the order they apply.
731    #[serde(default, skip_serializing_if = "Vec::is_empty")]
732    profiles: Vec<Locked>,
733}
734
735/// What one active profile resolved to.
736#[derive(Debug, Serialize, Deserialize)]
737#[serde(deny_unknown_fields)]
738pub(crate) struct Locked {
739    /// The profile's name.
740    pub name: ProfileName,
741    /// Commit of its source, for a versioned one; every profile of a source has one.
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub rev: Option<Oid>,
744    /// Digest of its manifest, rendered payload and features.
745    pub digest: Digest,
746    /// Its source, as configured when locked.
747    pub source: Source,
748    /// Every feature that was on, with who turned it on.
749    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
750    pub features: BTreeMap<FeatureName, Vec<String>>,
751}
752
753impl Locked {
754    /// Who turned each of `features` on, as the lock writes it.
755    pub(crate) fn features(
756        features: &BTreeMap<FeatureName, BTreeSet<Enabler>>,
757    ) -> BTreeMap<FeatureName, Vec<String>> {
758        features
759            .iter()
760            .map(|(feature, by)| (feature.clone(), by.iter().map(ToString::to_string).collect()))
761            .collect()
762    }
763}
764
765impl Lock {
766    /// A lock of `profiles`, in order.
767    pub(crate) const fn new(profiles: Vec<Locked>) -> Self {
768        Self { version: V2, profiles }
769    }
770
771    /// The lock in `raw`, read from `file`; devset 0.1's is empty, so every source resolves anew.
772    fn read(raw: &[u8], file: &str) -> Result<Self> {
773        let table: toml::Table = from_toml(raw, file)?;
774        if table.get("version").and_then(toml::Value::as_integer) == Some(1) {
775            return Ok(Self::default());
776        }
777        from_toml(raw, file)
778    }
779}
780
781/// `state.toml`.
782#[derive(Debug, Default, Serialize, Deserialize)]
783#[serde(deny_unknown_fields)]
784pub(crate) struct State {
785    /// Format version; devset 0.1's reads as this one.
786    version: V2,
787    /// What was decided of each scaffold.
788    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
789    scaffolds: BTreeMap<ScaffoldId, Scaffold>,
790    /// Each managed file's recorded base.
791    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
792    files: BTreeMap<RelPath, FileRecord>,
793    /// Each part's recorded base, by file, then by the profile that owns it.
794    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
795    parts: BTreeMap<RelPath, BTreeMap<String, PartRecord>>,
796}
797
798/// What devset recorded for one file or part.
799#[derive(Clone, Copy, Debug)]
800pub(crate) struct Record {
801    /// How much of the file it is.
802    pub scope: Scope,
803    /// Its content, as recorded.
804    pub fingerprint: Fingerprint,
805    /// How it was managed when recorded.
806    pub policy: Policy,
807}
808
809/// What devset recorded for one whole file: the digests of its content, and its policy.
810#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
811#[serde(deny_unknown_fields)]
812struct FileRecord {
813    /// As [`Fingerprint::exact`].
814    exact: Digest,
815    /// As [`Fingerprint::canonical`].
816    canonical: Digest,
817    /// How it was managed; `owned`, the default, is not written.
818    #[serde(default, skip_serializing_if = "Policy::is_owned")]
819    policy: Policy,
820}
821
822/// What devset recorded for one part: its scope, the digests of its content, and its policy.
823#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
824#[serde(deny_unknown_fields)]
825struct PartRecord {
826    /// How much of the file the part is.
827    scope: Scope,
828    /// As [`Fingerprint::exact`].
829    exact: Digest,
830    /// As [`Fingerprint::canonical`].
831    canonical: Digest,
832    /// How it was managed; `owned`, the default, is not written.
833    #[serde(default, skip_serializing_if = "Policy::is_owned")]
834    policy: Policy,
835}
836
837impl State {
838    /// A state recording `records`, and what was decided of `scaffolds`.
839    pub(crate) fn new(
840        records: BTreeMap<Slot, Record>, scaffolds: BTreeMap<ScaffoldId, Scaffold>,
841    ) -> Self {
842        let mut state = Self { scaffolds, ..Self::default() };
843        for (Slot { path, part }, Record { scope, fingerprint, policy }) in records {
844            let Fingerprint { exact, canonical } = fingerprint;
845            match part {
846                None => {
847                    state.files.insert(path, FileRecord { exact, canonical, policy });
848                },
849                Some(owner) => {
850                    let record = PartRecord { scope, exact, canonical, policy };
851                    state.parts.entry(path).or_default().insert(owner, record);
852                },
853            }
854        }
855        state
856    }
857
858    /// Every record, whole files first.
859    fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
860        let files = self.files.iter().map(|(path, record)| {
861            let FileRecord { exact, canonical, policy } = *record;
862            let fingerprint = Fingerprint { exact, canonical };
863            (Slot::whole(path.clone()), Record { scope: Scope::File, fingerprint, policy })
864        });
865        let parts = self.parts.iter().flat_map(|(path, parts)| {
866            parts.iter().map(|(owner, record)| {
867                let slot = Slot { path: path.clone(), part: Some(owner.clone()) };
868                let PartRecord { scope, exact, canonical, policy } = *record;
869                (slot, Record { scope, fingerprint: Fingerprint { exact, canonical }, policy })
870            })
871        });
872        files.chain(parts)
873    }
874
875    /// The bases it references.
876    pub(crate) fn bases(&self) -> impl Iterator<Item = Digest> {
877        self.records().map(|(_, record)| record.fingerprint.exact)
878    }
879}
880
881/// Format version `1`: `undo.toml`'s.
882#[derive(Clone, Copy, Debug, Default)]
883pub(crate) struct V1;
884
885impl Serialize for V1 {
886    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
887        serializer.serialize_u8(1)
888    }
889}
890
891impl<'de> Deserialize<'de> for V1 {
892    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
893        match u8::deserialize(deserializer)? {
894            1 => Ok(Self),
895            other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
896        }
897    }
898}
899
900/// Format version `2`: `lock.toml`'s and `state.toml`'s; version `1` reads as it.
901#[derive(Clone, Copy, Debug, Default)]
902pub(crate) struct V2;
903
904impl Serialize for V2 {
905    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
906        serializer.serialize_u8(2)
907    }
908}
909
910impl<'de> Deserialize<'de> for V2 {
911    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
912        match u8::deserialize(deserializer)? {
913            1 | 2 => Ok(Self),
914            other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
915        }
916    }
917}
918
919/// Parses TOML `bytes` read from `file`.
920pub(crate) fn from_toml<T: DeserializeOwned>(bytes: &[u8], file: &str) -> Result<T> {
921    let failed = |text: &str, span, message| ParseError {
922        file: file.into(),
923        text: text.into(),
924        span,
925        message,
926    };
927    let text = str::from_utf8(bytes).map_err(|e| failed("", None, e.to_string()))?;
928    Ok(toml::from_str(text).map_err(|e| failed(text, e.span(), e.message().to_owned()))?)
929}
930
931/// `name` in the `.devset/` at `dir`, parsed; `None` if it does not exist.
932pub(crate) fn read_toml<T: DeserializeOwned>(dir: &Utf8Path, name: &str) -> Result<Option<T>> {
933    read_optional(&dir.join(name))?.map(|raw| from_toml(&raw, &label(name))).transpose()
934}
935
936/// The contents of `path`, or `None` if it does not exist.
937pub(crate) fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>> {
938    match fs_err::read(path) {
939        Ok(bytes) => Ok(Some(bytes)),
940        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
941        Err(e) => Err(e.into()),
942    }
943}
944
945#[cfg(test)]
946mod tests {
947    use super::{LayerSpec, Lock, State, Target};
948    use crate::errors::{Error, TargetError};
949    use crate::name::SourceName;
950    use crate::source::{GitRef, Source, SourceSpec};
951
952    #[test]
953    fn a_source_moves_to_another_ref_in_place() {
954        let dir = camino_tempfile::tempdir().expect("a directory");
955        let mut target = Target::open_or_new(dir.path()).expect("a new target");
956        let spec = SourceSpec {
957            git: Some("https://example.com/p".into()),
958            tag: Some("v1.0.0".into()),
959            path: Some("lint".into()),
960            ..SourceSpec::default()
961        };
962        let name: SourceName = "p".parse().expect("a name");
963        target.add_source(name.clone(), Source::try_from(spec).expect("a source")).expect("added");
964        target.repin(&name, GitRef::Tag("v1.1.0".into())).expect("moved");
965        let text = target.config_text();
966        let moved = "p = { git = \"https://example.com/p\", tag = \"v1.1.0\", path = \"lint\" }";
967        assert!(text.contains(moved), "a tag moves in its place: {text}");
968        target.repin(&name, GitRef::Branch("main".into())).expect("moved");
969        let text = target.config_text();
970        let moved = "p = { git = \"https://example.com/p\", path = \"lint\", branch = \"main\" }";
971        assert!(text.contains(moved), "another kind of ref replaces it: {text}");
972        let local: SourceName = "d".parse().expect("a name");
973        target.add_source(local.clone(), Source::Dir("../d".into())).expect("added");
974        let refused = target.repin(&local, GitRef::Tag("v1".into()));
975        assert!(matches!(refused, Err(Error::Target(TargetError::NotGit { .. }))), "{refused:?}");
976    }
977
978    #[test]
979    fn a_source_written_as_a_table_keeps_its_layout() {
980        let dir = camino_tempfile::tempdir().expect("a directory");
981        let config =
982            "[sources.p]\ngit = \"https://example.com/p\"\ntag = \"v1.0.0\"  # the release\n";
983        fs_err::create_dir_all(dir.path().join(".devset")).expect("a directory");
984        fs_err::write(dir.path().join(".devset/config.toml"), config).expect("written");
985        let mut target = Target::find(dir.path()).expect("the target");
986        target.repin(&"p".parse().expect("a name"), GitRef::Tag("v1.1.0".into())).expect("moved");
987        let want =
988            "[sources.p]\ngit = \"https://example.com/p\"\ntag = \"v1.1.0\"  # the release\n";
989        assert_eq!(target.config_text(), want, "only the value changes");
990    }
991
992    #[test]
993    fn a_new_target_keeps_its_skeleton_above_what_is_added() {
994        let dir = camino_tempfile::tempdir().expect("a directory");
995        let mut target = Target::open_or_new(dir.path()).expect("a new target");
996        target
997            .add_source("house".parse().expect("a name"), Source::Dir("../p".into()))
998            .expect("added");
999        target.add_layer(LayerSpec::new("house/base".parse().expect("a layer"))).expect("added");
1000        let text = target.config_text();
1001        let lead =
1002            "#:schema https://atomix-labs.github.io/devset/schema/config.json\n# devset applies";
1003        assert!(text.starts_with(lead), "the skeleton leads, naming its schema: {text}");
1004        assert!(
1005            text.contains("\n\n[sources]\nhouse = { path = \"../p\" }\n"),
1006            "a source, inline: {text}"
1007        );
1008        assert!(text.ends_with("[[layers]]\nprofile = \"house/base\"\n"), "then the layer: {text}");
1009        let same = target.add_source("house".parse().expect("a name"), Source::Dir("../p".into()));
1010        assert!(same.is_ok(), "naming a source again as it is changes nothing");
1011        let other = target.add_source("house".parse().expect("a name"), Source::Dir("../q".into()));
1012        assert!(matches!(other, Err(Error::Target(TargetError::SourceExists { .. }))), "{other:?}");
1013        let twice = target.add_layer(LayerSpec::new("house/base".parse().expect("a layer")));
1014        assert!(
1015            matches!(twice, Err(Error::Target(TargetError::DuplicateLayer { .. }))),
1016            "{twice:?}"
1017        );
1018        let unknown = target.add_layer(LayerSpec::new("nope/other".parse().expect("a layer")));
1019        assert!(
1020            matches!(unknown, Err(Error::Target(TargetError::NoSuchSource { .. }))),
1021            "{unknown:?}"
1022        );
1023    }
1024
1025    #[test]
1026    fn devset_0_1_records() {
1027        let dir = camino_tempfile::tempdir().expect("a directory");
1028        let devset = dir.path().join(".devset");
1029        fs_err::create_dir_all(&devset).expect("a directory");
1030        fs_err::write(devset.join("config.toml"), "[[layers]]\npath = \"../p\"\n")
1031            .expect("written");
1032        let old = Target::find(dir.path()).expect_err("an old config");
1033        assert!(matches!(old, Error::Target(TargetError::OldConfig)), "refused: {old}");
1034        let lock =
1035            Lock::read(b"version = 1\n[[layers]]\ndigest = \"x\"\n", "lock.toml").expect("read");
1036        assert!(lock.profiles.is_empty(), "an old lock pins nothing");
1037        let state: State = toml::from_str(
1038            "version = 1\n[files.\"a\"]\nexact = \"0000000000000000000000000000000000000000000000000000000000000000\"\ncanonical = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
1039        )
1040        .expect("an old state reads");
1041        assert_eq!(state.records().count(), 1, "with its records");
1042        let written = toml::to_string(&state).expect("written");
1043        assert!(written.starts_with("version = 2\n"), "as version 2: {written}");
1044    }
1045}