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::part::Scope;
12use crate::part::{Key, Shape, Slot, decode};
13use crate::path::RelPath;
14use crate::plan::Mode;
15use crate::profile::Policy;
16use crate::resolve::Resolved;
17use crate::target::{Record, Target, read_optional};
18
19/// One managed file, or one profile's part of it: what the profile wants, what devset recorded,
20/// what is on disk.
21#[derive(Clone, Debug)]
22pub struct Entry {
23    /// The file.
24    pub path: RelPath,
25    /// The part of the file this entry is; `None` for the whole file.
26    pub part: Option<Part>,
27    /// What the profile provides; `None` once no layer provides it.
28    pub want: Option<Want>,
29    /// What devset last recorded.
30    pub record: Option<Fingerprint>,
31    /// The policy it was recorded under; `None` without a record.
32    pub recorded: Option<Policy>,
33    /// What is on disk: the file, or what it holds of the part.
34    pub found: Option<Fingerprint>,
35    /// Whether a conflicted merge of the file waits in `.devset/conflicts/`.
36    pub conflict: bool,
37    /// For keys, every key the part covers: its base's and the profile's.
38    pub(crate) keys: BTreeSet<Key>,
39}
40
41/// One profile's part of a file.
42#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
43pub struct Part {
44    /// The profile whose part it is, which names it.
45    pub owner: String,
46    /// How much of the file it is: [`Scope::Keys`] or [`Scope::Block`].
47    pub scope: Scope,
48}
49
50/// What a layer provides at one path, or in one part.
51#[derive(Clone, Copy, Debug)]
52pub struct Want {
53    /// Index into the resolved layers.
54    pub layer: usize,
55    /// How the file, or the part, is managed.
56    pub policy: Policy,
57    /// How a merge of the file is checked.
58    pub validate: Format,
59    /// Whether the file is written executable.
60    pub executable: bool,
61    /// The profile's bytes, or the part's content.
62    pub fingerprint: Fingerprint,
63}
64
65/// How a file on disk compares with its record.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum Drift {
68    /// Byte-identical.
69    Unchanged,
70    /// Differs only in whitespace, line endings or a BOM.
71    Cosmetic,
72    /// Edited.
73    Edited,
74    /// Deleted.
75    Missing,
76}
77
78impl Entry {
79    /// How much of the file this entry is.
80    #[must_use]
81    pub fn scope(&self) -> Scope {
82        self.part.as_ref().map_or(Scope::File, |part| part.scope)
83    }
84
85    /// Where it lives.
86    pub(crate) fn slot(&self) -> Slot {
87        Slot { path: self.path.clone(), part: self.part.as_ref().map(|part| part.owner.clone()) }
88    }
89
90    /// How the file compares with its record; `None` when there is no record.
91    #[must_use]
92    pub fn drift(&self) -> Option<Drift> {
93        let record = self.record?;
94        Some(match self.found {
95            None => Drift::Missing,
96            Some(found) if found.exact == record.exact => Drift::Unchanged,
97            Some(found) if found.same_content(&record) => Drift::Cosmetic,
98            Some(_) => Drift::Edited,
99        })
100    }
101}
102
103/// The path, and for a part its scope and owner: `Cargo.toml [keys: lints]`.
104impl fmt::Display for Entry {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match &self.part {
107            Some(Part { owner, scope }) => write!(f, "{} [{scope}: {owner}]", self.path),
108            None => write!(f, "{}", self.path),
109        }
110    }
111}
112
113/// A target's managed paths against its resolved profile, in path order.
114#[derive(Debug)]
115pub struct Survey {
116    /// The profile surveyed against.
117    pub(crate) resolved: Resolved,
118    /// Every provided or recorded path.
119    pub(crate) entries: Vec<Entry>,
120}
121
122impl Survey {
123    /// The profile surveyed against.
124    #[must_use]
125    pub const fn resolved(&self) -> &Resolved {
126        &self.resolved
127    }
128
129    /// Every provided or recorded path, in order.
130    #[must_use]
131    pub fn entries(&self) -> &[Entry] {
132        &self.entries
133    }
134
135    /// Whether an update is unfinished: conflicts wait in `.devset/conflicts/`.
136    #[must_use]
137    pub fn unfinished(&self) -> bool {
138        self.entries.iter().any(|entry| entry.conflict)
139    }
140
141    /// `entry`'s file as the profile would have it: the profile's file, or for a part, the file
142    /// in `target` with the part as the profile has it; `None` when no layer provides it.
143    ///
144    /// # Errors
145    /// - [`Error::Parse`](crate::Error::Parse), the part cannot be written into its file.
146    /// - [`Error::Io`](crate::Error::Io), the file cannot be read.
147    pub fn wanted(&self, entry: &Entry, target: &Target) -> Result<Option<Vec<u8>>> {
148        if entry.want.is_none() {
149            return Ok(None);
150        }
151        let payload = self.resolved.payload(entry)?;
152        let Some(portion) = self.resolved.portion(entry) else {
153            return Ok(Some(payload.to_vec()));
154        };
155        let file = read(target, &entry.path)?.unwrap_or_default();
156        Ok(Some(portion.splice(&file, payload, &entry.keys, entry.path.as_str())?))
157    }
158
159    /// Whether `apply --force` would write a file: the drift gate.
160    #[must_use]
161    pub fn drifted(&self) -> bool {
162        self.entries.iter().any(|entry| entry.action(Mode::Force).writes())
163    }
164}
165
166/// Compares `target`'s files with `resolved`.
167///
168/// A part is read out of its file: the keys its base and the profile define, or its block.
169///
170/// # Errors
171/// - [`TargetError::NotAFile`], a managed path on disk is a symlink or a directory.
172/// - [`Error::Parse`](crate::Error::Parse), a file with parts does not parse, or a block's markers
173///   do not pair.
174/// - [`Error::Io`](crate::Error::Io), a managed file cannot be read.
175pub fn survey(resolved: Resolved, target: &Target) -> Result<Survey> {
176    let mut wants: BTreeMap<&Slot, (Scope, Want, &[u8])> = resolved
177        .provided()
178        .map(|(slot, provided, bytes)| {
179            let want = Want {
180                layer: provided.layer,
181                policy: provided.policy,
182                validate: provided.validate,
183                executable: provided.executable,
184                fingerprint: Fingerprint::of(bytes),
185            };
186            let scope = provided.portion.as_ref().map_or(Scope::File, |portion| portion.scope);
187            (slot, (scope, want, bytes))
188        })
189        .collect();
190    let mut entries = Vec::new();
191    for (slot, record) in target.records() {
192        let wanted = wants.remove(&slot);
193        let now = wanted.map_or(record.scope, |(now, ..)| now);
194        // A part the profile now owns in another scope has other content: its record is no base.
195        let record = (now == record.scope).then_some(record);
196        entries.push(entry(slot, now, wanted.map(|(_, want, bytes)| (want, bytes)), record));
197    }
198    for (slot, (scope, want, bytes)) in wants {
199        entries.push(entry(slot.clone(), scope, Some((want, bytes)), None));
200    }
201    entries.sort_unstable_by(|a, b| (&a.path, &a.part).cmp(&(&b.path, &b.part)));
202    let mut file: Option<(RelPath, Option<Vec<u8>>)> = None;
203    for entry in &mut entries {
204        if file.as_ref().is_none_or(|(path, _)| *path != entry.path) {
205            file = Some((entry.path.clone(), read(target, &entry.path)?));
206        }
207        let Some((_, bytes)) = &file else { continue };
208        entry.conflict = target.sidecar(&entry.path).is_file();
209        let Some(bytes) = bytes else { continue };
210        let Some(part) = &entry.part else {
211            entry.found = Some(Fingerprint::of(bytes));
212            continue;
213        };
214        let shape = match resolved.portion(entry) {
215            Some(portion) => Cow::Borrowed(&portion.shape),
216            None => match released(target, entry) {
217                Some(shape) => Cow::Owned(shape),
218                None => continue,
219            },
220        };
221        if part.scope == Scope::Keys
222            && let Some(record) = &entry.record
223        {
224            let base = target.base(record)?.unwrap_or_default();
225            entry.keys.extend(shape.keys(&base));
226        }
227        let view = shape.view(bytes, &entry.keys, entry.path.as_str())?;
228        entry.found = view.as_deref().map(Fingerprint::of);
229    }
230    Ok(Survey { resolved, entries })
231}
232
233/// The shape of a part no layer provides any more, as its path tells: its format by name or by
234/// the target's override, its markers in the file type's comment syntax. `None` when the path does
235/// not tell, as for a block in a comment syntax the profile chose.
236pub(crate) fn released(target: &Target, entry: &Entry) -> Option<Shape> {
237    let part = entry.part.as_ref()?;
238    let format = target.config().files.get(&entry.path).and_then(|over| over.validate);
239    let format = format.unwrap_or_else(|| Format::of(&entry.path));
240    Shape::of(part.scope, &entry.path, format, None, &part.owner).ok().flatten()
241}
242
243/// The entry at `slot`, in `scope`, before the disk is read.
244fn entry(slot: Slot, scope: Scope, want: Option<(Want, &[u8])>, record: Option<Record>) -> Entry {
245    let Slot { path, part } = slot;
246    let part = part.map(|owner| Part { owner, scope });
247    let keys = match (&part, want) {
248        (Some(Part { scope: Scope::Keys, .. }), Some((_, content))) => {
249            decode(content).map(|leaves| leaves.into_keys().collect())
250        },
251        _ => None,
252    };
253    Entry {
254        path,
255        part,
256        want: want.map(|(want, _)| want),
257        record: record.map(|record| record.fingerprint),
258        recorded: record.map(|record| record.policy),
259        found: None,
260        conflict: false,
261        keys: keys.unwrap_or_default(),
262    }
263}
264
265/// The regular file at `path` in `target`; `None` if absent.
266fn read(target: &Target, path: &RelPath) -> Result<Option<Vec<u8>>> {
267    let file = path.under(target.root());
268    match fs_err::symlink_metadata(&file) {
269        Ok(meta) if meta.is_file() => read_optional(&file),
270        Ok(meta) => {
271            let kind = if meta.is_symlink() {
272                "a symlink"
273            } else if meta.is_dir() {
274                "a directory"
275            } else {
276                "a special file"
277            };
278            Err(TargetError::NotAFile { path: path.clone(), kind }.into())
279        },
280        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
281        Err(e) => Err(e.into()),
282    }
283}