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 layers, and the target's word on their settings (committed)
6//! ├── lock.toml       each layer's exact revision and digest (committed)
7//! ├── answers.toml    answers to the layers' variables (committed)
8//! ├── state.toml      what devset last wrote, by file and part (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 schemars::JsonSchema;
20use serde::de::{self, DeserializeOwned};
21use serde::{Deserialize, Deserializer, Serialize, Serializer};
22use toml_edit::{ArrayOfTables, DocumentMut, Item, TomlError, Value, ser};
23
24use crate::digest::{Digest, Fingerprint};
25use crate::errors::{ParseError, Result, TargetError};
26use crate::part::{Scope, Slot};
27use crate::path::RelPath;
28use crate::profile::{Format, MergeSpec, Policy};
29use crate::source::{Oid, Source, SourceSpec};
30use crate::vars::VarName;
31
32/// devset's directory in a target.
33pub(crate) const DIR: &str = ".devset";
34
35/// Intent: the layers, in order.
36pub(crate) const CONFIG: &str = "config.toml";
37
38/// What each layer resolved to.
39pub(crate) const LOCK: &str = "lock.toml";
40
41/// What devset last recorded for each managed path.
42pub(crate) const STATE: &str = "state.toml";
43
44/// Conflicted merge results awaiting resolution, by path.
45pub(crate) const CONFLICTS: &str = "conflicts";
46
47/// In an unfinished update's directory, the lock that `on-conflict = "apply-none"` withheld.
48pub(crate) const PENDING: &str = "pending.toml";
49
50/// In an unfinished update's directory, what the update changed.
51pub(crate) const UNDO: &str = "undo.toml";
52
53/// In an unfinished update's directory, the bytes it replaced, by digest.
54pub(crate) const UNDO_BLOBS: &str = "undo";
55
56/// Recorded bases, named by digest.
57pub(crate) const BASE: &str = "base";
58
59/// The target's answers to the layers' variables.
60pub(crate) const ANSWERS: &str = "answers.toml";
61
62/// A directory devset manages.
63#[derive(Debug)]
64pub struct Target {
65    /// The directory holding `.devset/`.
66    root: Utf8PathBuf,
67    /// `config.toml`, parsed.
68    config: Config,
69    /// `config.toml` as written: layers are appended to it, so the user's formatting survives.
70    config_text: String,
71    /// `lock.toml`.
72    lock: Lock,
73    /// `pending.toml`, while conflicts wait in `.devset/conflicts/`.
74    pending: Option<Lock>,
75    /// `state.toml`.
76    state: State,
77    /// `answers.toml`, with answers given since.
78    answers: BTreeMap<VarName, String>,
79    /// Variables answered since loading, which must be declared.
80    given: BTreeSet<VarName>,
81    /// Digest of `state.toml` as read, to catch a concurrent writer.
82    state_digest: Option<Digest>,
83}
84
85/// `.devset/config.toml`: the layers, and the target's word on every setting they carry.
86#[derive(Debug, Default, Deserialize, JsonSchema)]
87#[serde(deny_unknown_fields)]
88pub struct Config {
89    /// Profiles to apply, in order.
90    #[serde(default)]
91    pub layers: Vec<Source>,
92    /// Merge settings; these override every layer's.
93    #[serde(default)]
94    pub merge: MergeSpec,
95    /// Overrides of the layers' `[files]` entries.
96    #[serde(default)]
97    pub files: BTreeMap<RelPath, Override>,
98}
99
100/// A `[files."path"]` entry in `config.toml`, overriding the providing layer's.
101#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
102#[serde(deny_unknown_fields)]
103pub struct Override {
104    /// The layer that provides the file, when several do.
105    #[serde(default)]
106    pub from: Option<String>,
107    /// How devset manages the file.
108    #[serde(default)]
109    pub policy: Option<Policy>,
110    /// How a merged result is checked.
111    #[serde(default)]
112    pub validate: Option<Format>,
113}
114
115impl Target {
116    /// The target containing `dir`: the nearest ancestor holding `.devset/config.toml`.
117    ///
118    /// # Errors
119    /// - [`TargetError::NotFound`], no ancestor holds one.
120    /// - [`Error::Parse`](crate::Error::Parse), one of its files does not parse.
121    pub fn find(dir: &Utf8Path) -> Result<Self> {
122        let root = enclosing(dir).ok_or_else(|| TargetError::NotFound { dir: dir.to_owned() })?;
123        Self::load(root)
124    }
125
126    /// The target rooted at `root`, or a new, empty one there. Never one inside another target.
127    ///
128    /// # Errors
129    /// - [`TargetError::Nested`], `root` lies inside another target.
130    /// - [`Error::Parse`](crate::Error::Parse), one of its files does not parse.
131    pub fn open_or_new(root: &Utf8Path) -> Result<Self> {
132        match enclosing(root) {
133            Some(found) if found == root => Self::load(root),
134            Some(outer) => {
135                Err(TargetError::Nested { dir: root.to_owned(), root: outer.to_owned() }.into())
136            },
137            None => Ok(Self {
138                root: root.to_owned(),
139                config: Config::default(),
140                config_text: String::new(),
141                lock: Lock::default(),
142                pending: None,
143                state: State::default(),
144                answers: BTreeMap::new(),
145                given: BTreeSet::new(),
146                state_digest: None,
147            }),
148        }
149    }
150
151    /// The directory holding `.devset/`.
152    #[must_use]
153    pub fn root(&self) -> &Utf8Path {
154        &self.root
155    }
156
157    /// `config.toml`.
158    #[must_use]
159    pub const fn config(&self) -> &Config {
160        &self.config
161    }
162
163    /// Appends `source` as the last layer; written by the next [`commit`](crate::commit()).
164    ///
165    /// # Errors
166    /// - [`TargetError::DuplicateLayer`], the layer is already present.
167    /// - [`Error::Parse`](crate::Error::Parse), `layers` in `config.toml` is not a list of tables.
168    /// - [`Error::Io`](crate::Error::Io), the layer cannot be written as TOML.
169    pub fn add_layer(&mut self, source: Source) -> Result<()> {
170        if self.config.layers.contains(&source) {
171            return Err(TargetError::DuplicateLayer { layer: source.to_string() }.into());
172        }
173        let table =
174            ser::to_document(&SourceSpec::from(source)).map_err(io::Error::other)?.into_table();
175        self.edit(|doc| {
176            match doc.entry("layers").or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())) {
177                Item::ArrayOfTables(layers) => layers.push(table),
178                Item::Value(Value::Array(layers)) => layers.push(table.into_inline_table()),
179                Item::None | Item::Value(_) | Item::Table(_) => return false,
180            }
181            true
182        })
183    }
184
185    /// The source of the layer whose profile has `name`, as the lock last recorded it.
186    #[must_use]
187    pub fn layer(&self, name: &str) -> Option<&Source> {
188        let lock = self.pending.as_ref().unwrap_or(&self.lock);
189        let locked = lock.layers.iter().find(|locked| locked.name.as_deref() == Some(name))?;
190        self.config.layers.iter().find(|source| **source == locked.source)
191    }
192
193    /// Removes the layer at `source`; written by the next [`commit`](crate::commit()).
194    ///
195    /// Every `[files]` override that picks it, `from = "<name>"`, goes with it.
196    ///
197    /// # Errors
198    /// [`Error::Parse`](crate::Error::Parse), `config.toml` changed shape so that the layer
199    /// cannot be found in it.
200    pub fn remove_layer(&mut self, source: &Source, name: &str) -> Result<()> {
201        let Some(index) = self.config.layers.iter().position(|layer| layer == source) else {
202            return Ok(());
203        };
204        self.edit(|doc| {
205            match doc.get_mut("layers") {
206                Some(Item::ArrayOfTables(layers)) if index < layers.len() => {
207                    layers.remove(index);
208                },
209                Some(Item::Value(Value::Array(layers))) if index < layers.len() => {
210                    layers.remove(index);
211                },
212                Some(_) | None => return false,
213            }
214            if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
215                let picks = |item: &Item| item.get("from").and_then(Item::as_str) == Some(name);
216                let picked: Vec<String> = files
217                    .iter()
218                    .filter(|(_, item)| picks(item))
219                    .map(|(key, _)| key.to_owned())
220                    .collect();
221                for key in picked {
222                    files.remove(&key);
223                }
224            }
225            true
226        })
227    }
228
229    /// Removes the `[files."path"]` override; written by the next [`commit`](crate::commit()).
230    ///
231    /// # Errors
232    /// [`Error::Parse`](crate::Error::Parse), as [`add_layer`](Self::add_layer).
233    pub fn remove_override(&mut self, path: &RelPath) -> Result<()> {
234        self.edit(|doc| {
235            if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
236                files.remove(path.as_str());
237            }
238            true
239        })
240    }
241
242    /// Applies `edit` to `config.toml`, keeping its formatting and comments, and reads it again.
243    ///
244    /// `edit` returns `false` when the file is not the shape it expects.
245    fn edit(&mut self, edit: impl FnOnce(&mut DocumentMut) -> bool) -> Result<()> {
246        let file = label(CONFIG);
247        let failed = |text: &str, message: String| ParseError {
248            file: file.clone(),
249            text: text.to_owned(),
250            span: None,
251            message,
252        };
253        let mut doc: DocumentMut = self
254            .config_text
255            .parse()
256            .map_err(|e: TomlError| failed(&self.config_text, e.to_string()))?;
257        if !edit(&mut doc) {
258            let message = "`layers` must be a list of tables".to_owned();
259            return Err(failed(&self.config_text, message).into());
260        }
261        let text = doc.to_string();
262        self.config = from_toml(text.as_bytes(), &file)?;
263        self.config_text = text;
264        Ok(())
265    }
266
267    /// Answers `name`; written by the next [`commit`](crate::commit()).
268    pub fn answer(&mut self, name: VarName, value: String) {
269        self.given.insert(name.clone());
270        self.answers.insert(name, value);
271    }
272
273    /// Every answer: recorded, or given since.
274    #[must_use]
275    pub const fn answers(&self) -> &BTreeMap<VarName, String> {
276        &self.answers
277    }
278
279    /// Variables answered since loading.
280    pub(crate) fn given(&self) -> impl Iterator<Item = &VarName> {
281        self.given.iter()
282    }
283
284    /// `.devset/`.
285    pub(crate) fn dir(&self) -> Utf8PathBuf {
286        self.root.join(DIR)
287    }
288
289    /// `config.toml` as it should be written.
290    pub(crate) fn config_text(&self) -> &str {
291        &self.config_text
292    }
293
294    /// The lock entry of the layer at `source`.
295    ///
296    /// Found by source, not position, so removing or reordering layers leaves the others pinned.
297    /// The pending lock stands in while an update is withheld.
298    pub(crate) fn locked(&self, source: &Source) -> Option<&Locked> {
299        let lock = self.pending.as_ref().unwrap_or(&self.lock);
300        lock.layers.iter().find(|locked| locked.source == *source)
301    }
302
303    /// Where the conflicted merge of `path` waits.
304    pub(crate) fn sidecar(&self, path: &RelPath) -> Utf8PathBuf {
305        path.under(&self.dir().join(CONFLICTS))
306    }
307
308    /// An unfinished update's own files, beside its sidecars.
309    ///
310    /// Named `.devset`, which no managed path may contain, so it never meets a sidecar; and inside
311    /// `conflicts/`, so deleting that directory discards the whole update.
312    pub(crate) fn unfinished(&self) -> Utf8PathBuf {
313        self.dir().join(CONFLICTS).join(DIR)
314    }
315
316    /// The recorded base with fingerprint `record`, or `None` when it is missing or damaged.
317    pub(crate) fn base(&self, record: &Fingerprint) -> Result<Option<Vec<u8>>> {
318        let blob = read_optional(&self.dir().join(BASE).join(record.exact.to_string()))?;
319        Ok(blob.filter(|bytes| Digest::of(bytes) == record.exact))
320    }
321
322    /// Every recorded file and part.
323    pub(crate) fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
324        self.state.records()
325    }
326
327    /// Digest of `state.toml` as read.
328    pub(crate) const fn state_digest(&self) -> Option<Digest> {
329        self.state_digest
330    }
331
332    /// Reads the target at `root`.
333    fn load(root: &Utf8Path) -> Result<Self> {
334        let dir = root.join(DIR);
335        let config_raw = fs_err::read(dir.join(CONFIG))?;
336        let config = from_toml(&config_raw, &label(CONFIG))?;
337        let config_text = String::from_utf8(config_raw).map_err(io::Error::other)?;
338        let state_raw = read_optional(&dir.join(STATE))?;
339        let state_digest = state_raw.as_deref().map(Digest::of);
340        let state = state_raw.map(|raw| from_toml(&raw, &label(STATE))).transpose()?;
341        Ok(Self {
342            root: root.to_owned(),
343            config,
344            config_text,
345            lock: read_toml(&dir, LOCK)?.unwrap_or_default(),
346            pending: read_toml(&dir, &format!("{CONFLICTS}/{DIR}/{PENDING}"))?,
347            state: state.unwrap_or_default(),
348            state_digest,
349            answers: read_toml(&dir, ANSWERS)?.unwrap_or_default(),
350            given: BTreeSet::new(),
351        })
352    }
353}
354
355/// `name` in `.devset/`, as users know it.
356pub(crate) fn label(name: &str) -> String {
357    format!("{DIR}/{name}")
358}
359
360/// The nearest ancestor of `dir`, itself included, holding `.devset/config.toml`.
361fn enclosing(dir: &Utf8Path) -> Option<&Utf8Path> {
362    dir.ancestors().find(|d| d.join(DIR).join(CONFIG).is_file())
363}
364
365/// `lock.toml`.
366#[derive(Debug, Default, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub(crate) struct Lock {
369    /// Format version.
370    version: V1,
371    /// One per layer, in `config.toml` order.
372    #[serde(default)]
373    layers: Vec<Locked>,
374}
375
376/// What one layer resolved to.
377#[derive(Debug, Serialize, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub(crate) struct Locked {
380    /// The profile's name, so a layer can be named without reading its source.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub name: Option<String>,
383    /// Commit, for a versioned source.
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub rev: Option<Oid>,
386    /// Digest of manifest and payload.
387    pub digest: Digest,
388    /// The layer as configured when locked.
389    pub source: Source,
390}
391
392impl Lock {
393    /// A lock of `layers`, in order.
394    pub(crate) const fn new(layers: Vec<Locked>) -> Self {
395        Self { version: V1, layers }
396    }
397}
398
399/// `state.toml`.
400#[derive(Debug, Default, Serialize, Deserialize)]
401#[serde(deny_unknown_fields)]
402pub(crate) struct State {
403    /// Format version.
404    version: V1,
405    /// Each managed file's recorded base.
406    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
407    files: BTreeMap<RelPath, FileRecord>,
408    /// Each part's recorded base, by file, then by the profile that owns it.
409    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
410    parts: BTreeMap<RelPath, BTreeMap<String, PartRecord>>,
411}
412
413/// What devset recorded for one file or part.
414#[derive(Clone, Copy, Debug)]
415pub(crate) struct Record {
416    /// How much of the file it is.
417    pub scope: Scope,
418    /// Its content, as recorded.
419    pub fingerprint: Fingerprint,
420    /// How it was managed when recorded.
421    pub policy: Policy,
422}
423
424/// What devset recorded for one whole file: the digests of its content, and its policy.
425#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
426#[serde(deny_unknown_fields)]
427struct FileRecord {
428    /// As [`Fingerprint::exact`].
429    exact: Digest,
430    /// As [`Fingerprint::canonical`].
431    canonical: Digest,
432    /// How it was managed; `owned`, the default, is not written.
433    #[serde(default, skip_serializing_if = "Policy::is_owned")]
434    policy: Policy,
435}
436
437/// What devset recorded for one part: its scope, the digests of its content, and its policy.
438#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
439#[serde(deny_unknown_fields)]
440struct PartRecord {
441    /// How much of the file the part is.
442    scope: Scope,
443    /// As [`Fingerprint::exact`].
444    exact: Digest,
445    /// As [`Fingerprint::canonical`].
446    canonical: Digest,
447    /// How it was managed; `owned`, the default, is not written.
448    #[serde(default, skip_serializing_if = "Policy::is_owned")]
449    policy: Policy,
450}
451
452impl State {
453    /// A state recording `records`.
454    pub(crate) fn new(records: BTreeMap<Slot, Record>) -> Self {
455        let mut state = Self::default();
456        for (Slot { path, part }, Record { scope, fingerprint, policy }) in records {
457            let Fingerprint { exact, canonical } = fingerprint;
458            match part {
459                None => {
460                    state.files.insert(path, FileRecord { exact, canonical, policy });
461                },
462                Some(owner) => {
463                    let record = PartRecord { scope, exact, canonical, policy };
464                    state.parts.entry(path).or_default().insert(owner, record);
465                },
466            }
467        }
468        state
469    }
470
471    /// Every record, whole files first.
472    fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
473        let files = self.files.iter().map(|(path, record)| {
474            let FileRecord { exact, canonical, policy } = *record;
475            let fingerprint = Fingerprint { exact, canonical };
476            (Slot::whole(path.clone()), Record { scope: Scope::File, fingerprint, policy })
477        });
478        let parts = self.parts.iter().flat_map(|(path, parts)| {
479            parts.iter().map(|(owner, record)| {
480                let slot = Slot { path: path.clone(), part: Some(owner.clone()) };
481                let PartRecord { scope, exact, canonical, policy } = *record;
482                (slot, Record { scope, fingerprint: Fingerprint { exact, canonical }, policy })
483            })
484        });
485        files.chain(parts)
486    }
487
488    /// The bases it references.
489    pub(crate) fn bases(&self) -> impl Iterator<Item = Digest> {
490        self.records().map(|(_, record)| record.fingerprint.exact)
491    }
492}
493
494/// The only on-disk format version, `1`.
495#[derive(Clone, Copy, Debug, Default)]
496pub(crate) struct V1;
497
498impl Serialize for V1 {
499    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
500        serializer.serialize_u8(1)
501    }
502}
503
504impl<'de> Deserialize<'de> for V1 {
505    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
506        match u8::deserialize(deserializer)? {
507            1 => Ok(Self),
508            other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
509        }
510    }
511}
512
513/// Parses TOML `bytes` read from `file`.
514pub(crate) fn from_toml<T: DeserializeOwned>(bytes: &[u8], file: &str) -> Result<T> {
515    let failed = |text: &str, span, message| ParseError {
516        file: file.into(),
517        text: text.into(),
518        span,
519        message,
520    };
521    let text = str::from_utf8(bytes).map_err(|e| failed("", None, e.to_string()))?;
522    Ok(toml::from_str(text).map_err(|e| failed(text, e.span(), e.message().to_owned()))?)
523}
524
525/// `name` in the `.devset/` at `dir`, parsed; `None` if it does not exist.
526pub(crate) fn read_toml<T: DeserializeOwned>(dir: &Utf8Path, name: &str) -> Result<Option<T>> {
527    read_optional(&dir.join(name))?.map(|raw| from_toml(&raw, &label(name))).transpose()
528}
529
530/// The contents of `path`, or `None` if it does not exist.
531pub(crate) fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>> {
532    match fs_err::read(path) {
533        Ok(bytes) => Ok(Some(bytes)),
534        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
535        Err(e) => Err(e.into()),
536    }
537}