Skip to main content

devset_core/
survey.rs

1//! Comparing a target with its resolved profile, path by path.
2
3use alloc::borrow::Cow;
4use alloc::collections::{BTreeMap, BTreeSet};
5use core::fmt;
6use std::io;
7
8pub use crate::digest::{Digest, Fingerprint};
9use crate::errors::{Result, TargetError};
10use crate::format::Format;
11pub use crate::gate::Reason;
12use crate::gate::{self, Settled};
13use crate::name::ScaffoldId;
14pub use crate::part::Scope;
15use crate::part::{Key, Shape, Slot, decode};
16use crate::path::RelPath;
17use crate::plan::Mode;
18use crate::profile::Policy;
19use crate::resolve::{Layer, Resolved};
20use crate::target::{Record, Target, read_optional};
21pub use crate::target::{Scaffold, Scaffolded};
22
23/// One managed file, or one profile's part of it: what the profile wants, what devset recorded,
24/// what is on disk.
25#[derive(Clone, Debug)]
26pub struct Entry {
27    /// The file.
28    pub path: RelPath,
29    /// The part of the file this entry is; `None` for the whole file.
30    pub part: Option<Part>,
31    /// What the profile provides; `None` once no layer provides it.
32    pub want: Option<Want>,
33    /// What devset last recorded.
34    pub record: Option<Fingerprint>,
35    /// The policy it was recorded under; `None` without a record.
36    pub recorded: Option<Policy>,
37    /// What is on disk: the file, or what it holds of the part.
38    pub found: Option<Fingerprint>,
39    /// Whether a conflicted merge of the file waits in `.devset/conflicts/`.
40    pub conflict: bool,
41    /// Why its gates turned it off, when they released it.
42    pub gate: Option<Reason>,
43    /// Whether the file is on disk, whatever it holds of the part.
44    pub(crate) present: bool,
45    /// For keys, every key the part covers: its base's and the profile's.
46    pub(crate) keys: BTreeSet<Key>,
47}
48
49/// One profile's part of a file.
50#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
51pub struct Part {
52    /// The profile whose part it is, which names it.
53    pub owner: String,
54    /// How much of the file it is: [`Scope::Keys`] or [`Scope::Block`].
55    pub scope: Scope,
56}
57
58/// What a layer provides at one path, or in one part.
59#[derive(Clone, Copy, Debug)]
60pub struct Want {
61    /// Index into the resolved layers.
62    pub layer: usize,
63    /// How the file, or the part, is managed.
64    pub policy: Policy,
65    /// How a merge of the file is checked.
66    pub validate: Format,
67    /// Whether the file is written executable.
68    pub executable: bool,
69    /// The profile's bytes, or the part's content.
70    pub fingerprint: Fingerprint,
71}
72
73/// How a file on disk compares with its record.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum Drift {
76    /// Byte-identical.
77    Unchanged,
78    /// Differs only in whitespace, line endings or a BOM.
79    Cosmetic,
80    /// Edited.
81    Edited,
82    /// Deleted.
83    Missing,
84}
85
86impl Entry {
87    /// How much of the file this entry is.
88    #[must_use]
89    pub fn scope(&self) -> Scope {
90        self.part.as_ref().map_or(Scope::File, |part| part.scope)
91    }
92
93    /// Where it lives.
94    pub(crate) fn slot(&self) -> Slot {
95        Slot { path: self.path.clone(), part: self.part.as_ref().map(|part| part.owner.clone()) }
96    }
97
98    /// How the file compares with its record; `None` when there is no record.
99    #[must_use]
100    pub fn drift(&self) -> Option<Drift> {
101        let record = self.record?;
102        Some(match self.found {
103            None => Drift::Missing,
104            Some(found) if found.exact == record.exact => Drift::Unchanged,
105            Some(found) if found.same_content(&record) => Drift::Cosmetic,
106            Some(_) => Drift::Edited,
107        })
108    }
109}
110
111/// The path, and for a part its scope and owner: `Cargo.toml [keys: lints]`.
112impl fmt::Display for Entry {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        match &self.part {
115            Some(Part { owner, scope }) => write!(f, "{} [{scope}: {owner}]", self.path),
116            None => write!(f, "{}", self.path),
117        }
118    }
119}
120
121/// A target's managed paths against its resolved profile, in path order.
122#[derive(Debug)]
123pub struct Survey {
124    /// The profile surveyed against.
125    pub(crate) resolved: Resolved,
126    /// Every provided or recorded path.
127    pub(crate) entries: Vec<Entry>,
128    /// What was decided of every active scaffold.
129    pub(crate) scaffolds: BTreeMap<ScaffoldId, Scaffold>,
130    /// Every slot its gates turned off, whether or not it was recorded.
131    pub(crate) gated: BTreeMap<Slot, Reason>,
132    /// How each path and glob of each slot's `when.exists` came out.
133    pub(crate) exists: BTreeMap<Slot, Vec<(String, bool)>>,
134}
135
136impl Survey {
137    /// The profile surveyed against.
138    #[must_use]
139    pub const fn resolved(&self) -> &Resolved {
140        &self.resolved
141    }
142
143    /// Every provided or recorded path, in order.
144    #[must_use]
145    pub fn entries(&self) -> &[Entry] {
146        &self.entries
147    }
148
149    /// What was decided of every active scaffold.
150    #[must_use]
151    pub const fn scaffolds(&self) -> &BTreeMap<ScaffoldId, Scaffold> {
152        &self.scaffolds
153    }
154
155    /// How each path and glob of the `when.exists` of the file at `path`, or of `part`'s part of
156    /// it, came out: whether it will exist. `None` when its other gates left it unchecked.
157    #[must_use]
158    pub fn exists(&self, path: &RelPath, part: Option<&str>) -> Option<&[(String, bool)]> {
159        let slot = Slot { path: path.clone(), part: part.map(str::to_owned) };
160        self.exists.get(&slot).map(Vec::as_slice)
161    }
162
163    /// Why the gates turned off the file at `path`, or `part`'s part of it.
164    #[must_use]
165    pub fn gated(&self, path: &RelPath, part: Option<&str>) -> Option<&Reason> {
166        self.gated.get(&Slot { path: path.clone(), part: part.map(str::to_owned) })
167    }
168
169    /// Whether a run is unfinished: conflicts wait in `.devset/conflicts/`.
170    #[must_use]
171    pub fn unfinished(&self) -> bool {
172        self.entries.iter().any(|entry| entry.conflict)
173    }
174
175    /// `entry`'s file as the profile would have it: the profile's file, or for a part, the file
176    /// in `target` with the part as the profile has it; `None` when no layer provides it.
177    ///
178    /// # Errors
179    /// - [`Error::Parse`](crate::Error::Parse), the part cannot be written into its file.
180    /// - [`Error::Io`](crate::Error::Io), the file cannot be read.
181    pub fn wanted(&self, entry: &Entry, target: &Target) -> Result<Option<Vec<u8>>> {
182        if entry.want.is_none() {
183            return Ok(None);
184        }
185        let payload = self.resolved.payload(entry)?;
186        let Some(portion) = self.resolved.portion(entry) else {
187            return Ok(Some(payload.to_vec()));
188        };
189        let file = read(target, &entry.path)?.unwrap_or_default();
190        Ok(Some(portion.splice(&file, payload, &entry.keys, entry.path.as_str())?))
191    }
192
193    /// Whether `apply --force` would write a file: the drift gate.
194    #[must_use]
195    pub fn drifted(&self) -> bool {
196        self.entries.iter().any(|entry| entry.action(Mode::Force).writes())
197    }
198}
199
200/// Compares `target`'s files with `resolved`, and settles the gates that depend on them: each
201/// scaffold, and each `exists`.
202///
203/// A part is read out of its file: the keys its base and the profile define, or its block.
204///
205/// # Errors
206/// - [`TargetError::NotAFile`], a managed path on disk is a symlink or a directory.
207/// - [`TargetError::NoSuchScaffold`], a scaffold to write again is no active profile's.
208/// - [`Error::Parse`](crate::Error::Parse), a file with parts does not parse, or a block's markers
209///   do not pair.
210/// - [`Error::Io`](crate::Error::Io), a managed file cannot be read.
211pub fn survey(resolved: Resolved, target: &Target) -> Result<Survey> {
212    let mut wants: BTreeMap<&Slot, (Scope, Want, &[u8])> = resolved
213        .provided()
214        .map(|(slot, provided, bytes)| {
215            let want = Want {
216                layer: provided.layer,
217                policy: provided.policy,
218                validate: provided.validate,
219                executable: provided.executable,
220                fingerprint: Fingerprint::of(bytes),
221            };
222            let scope = provided.portion.as_ref().map_or(Scope::File, |portion| portion.scope);
223            (slot, (scope, want, bytes))
224        })
225        .collect();
226    let mut entries = Vec::new();
227    for (slot, record) in target.records() {
228        let wanted = wants.remove(&slot);
229        let now = wanted.map_or(record.scope, |(now, ..)| now);
230        // A part the profile now owns in another scope has other content: its record is no base.
231        let record = (now == record.scope).then_some(record);
232        entries.push(entry(slot, now, wanted.map(|(_, want, bytes)| (want, bytes)), record));
233    }
234    for (slot, (scope, want, bytes)) in wants {
235        entries.push(entry(slot.clone(), scope, Some((want, bytes)), None));
236    }
237    entries.sort_unstable_by(|a, b| (&a.path, &a.part).cmp(&(&b.path, &b.part)));
238    let mut file: Option<(RelPath, Option<Vec<u8>>)> = None;
239    for entry in &mut entries {
240        if file.as_ref().is_none_or(|(path, _)| *path != entry.path) {
241            file = Some((entry.path.clone(), read(target, &entry.path)?));
242        }
243        let Some((_, bytes)) = &file else { continue };
244        entry.conflict = target.sidecar(&entry.path).is_file();
245        let Some(bytes) = bytes else { continue };
246        entry.present = true;
247        let Some(part) = &entry.part else {
248            entry.found = Some(Fingerprint::of(bytes));
249            continue;
250        };
251        let shape = match resolved.portion(entry) {
252            Some(portion) => Cow::Borrowed(&portion.shape),
253            None => match released(&resolved, target, entry) {
254                Some(shape) => Cow::Owned(shape),
255                None => continue,
256            },
257        };
258        if part.scope == Scope::Keys
259            && let Some(record) = &entry.record
260        {
261            let base = target.base(record)?.unwrap_or_default();
262            entry.keys.extend(shape.keys(&base));
263        }
264        let view = shape.view(bytes, &entry.keys, entry.path.as_str())?;
265        // Markers the target wrote with nothing between them place a block devset has yet to
266        // write: it is written there, not adopted as the target's edit.
267        let placed = part.scope == Scope::Block
268            && entry.record.is_none()
269            && view.as_deref().is_some_and(|body| body.trim_ascii().is_empty());
270        entry.found = if placed { None } else { view.as_deref().map(Fingerprint::of) };
271    }
272    starters(&mut entries);
273    let Settled { scaffolds, gated, exists } = gate::settle(&mut entries, &resolved, target)?;
274    for entry in entries.iter_mut().filter(|entry| entry.want.is_none() && entry.gate.is_none()) {
275        entry.gate = gated_by(&resolved, entry);
276    }
277    Ok(Survey { resolved, entries, scaffolds, gated, exists })
278}
279
280/// Treats each starter whose parts are provided as its record: a `once` file is the target's as
281/// soon as it is written, so what the parts change in it is no edit.
282fn starters(entries: &mut [Entry]) {
283    let parted: BTreeSet<RelPath> = entries
284        .iter()
285        .filter(|entry| entry.part.is_some() && entry.want.is_some())
286        .map(|entry| entry.path.clone())
287        .collect();
288    for entry in entries.iter_mut() {
289        let starter = entry.part.is_none()
290            && entry.want.is_some_and(|want| want.policy == Policy::Once)
291            && parted.contains(&entry.path);
292        if starter && entry.found.is_some() && entry.record.is_some() {
293            entry.found = entry.record;
294        }
295    }
296}
297
298/// Why no layer provides `entry` while one still lists it: the gate of its entry there.
299fn gated_by(resolved: &Resolved, entry: &Entry) -> Option<Reason> {
300    let listed = |layer: &Layer| layer.files().get(&entry.path)?.gated.clone();
301    match &entry.part {
302        Some(part) => listed(resolved.layer(&part.owner)?),
303        None => resolved.layers().iter().find_map(listed),
304    }
305}
306
307/// The shape of a part no layer provides: as its owner still lists it, when its gates turned it
308/// off; else as its path tells, its format by name or by the target's override, its markers in
309/// the file type's comment syntax. `None` when neither tells, as for a block in a comment syntax
310/// a profile that dropped it chose.
311pub(crate) fn released(resolved: &Resolved, target: &Target, entry: &Entry) -> Option<Shape> {
312    let part = entry.part.as_ref()?;
313    let listed = resolved.layer(&part.owner).and_then(|layer| layer.files().get(&entry.path));
314    let spec = listed.map(|file| &file.spec);
315    let over = target.config().files.get(&entry.path).and_then(|over| over.validate);
316    let format = over
317        .or_else(|| spec.and_then(|spec| spec.validate))
318        .unwrap_or_else(|| Format::of(&entry.path));
319    let comment = spec.and_then(|spec| spec.comment);
320    Shape::of(part.scope, &entry.path, format, comment, &part.owner).ok().flatten()
321}
322
323/// The entry at `slot`, in `scope`, before the disk is read.
324fn entry(slot: Slot, scope: Scope, want: Option<(Want, &[u8])>, record: Option<Record>) -> Entry {
325    let Slot { path, part } = slot;
326    let part = part.map(|owner| Part { owner, scope });
327    let keys = match (&part, want) {
328        (Some(Part { scope: Scope::Keys, .. }), Some((_, content))) => {
329            decode(content).map(|leaves| leaves.into_keys().collect())
330        },
331        _ => None,
332    };
333    Entry {
334        path,
335        part,
336        want: want.map(|(want, _)| want),
337        record: record.map(|record| record.fingerprint),
338        recorded: record.map(|record| record.policy),
339        found: None,
340        conflict: false,
341        gate: None,
342        present: false,
343        keys: keys.unwrap_or_default(),
344    }
345}
346
347/// The regular file at `path` in `target`; `None` if absent.
348fn read(target: &Target, path: &RelPath) -> Result<Option<Vec<u8>>> {
349    let file = path.under(target.root());
350    match fs_err::symlink_metadata(&file) {
351        Ok(meta) if meta.is_file() => read_optional(&file),
352        Ok(meta) => {
353            let kind = if meta.is_symlink() {
354                "a symlink"
355            } else if meta.is_dir() {
356                "a directory"
357            } else {
358                "a special file"
359            };
360            Err(TargetError::NotAFile { path: path.clone(), kind }.into())
361        },
362        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
363        Err(e) => Err(e.into()),
364    }
365}