Skip to main content

devset_core/
plan.rs

1//! Deciding what committing does to each path, merges included.
2
3use alloc::collections::{BTreeMap, BTreeSet};
4
5use serde_json::Value;
6
7use crate::digest::{Fingerprint, is_binary};
8use crate::errors::{MergeError, Result, list};
9use crate::format::Format;
10use crate::merge::{Driver, Merged, has_markers};
11use crate::name::ScaffoldId;
12use crate::part::{Key, Leaves, Shape, decode, display, encode, merge, overlap};
13use crate::path::RelPath;
14use crate::profile::{OnConflict, Policy};
15use crate::resolve::{Portion, Resolved};
16use crate::survey::{Entry, Survey, released};
17use crate::target::{Scaffold, Target, read_optional};
18use crate::tree::Tree;
19
20/// How far a plan may go.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum Mode {
23    /// Never destroy bytes.
24    Apply,
25    /// Also restore `owned` files that were edited, deleted, or never recorded.
26    Force,
27    /// Install resolved conflicts from `.devset/conflicts/`; otherwise as [`Mode::Apply`].
28    Continue,
29}
30
31/// What committing does to one path.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Action {
34    /// Nothing.
35    Keep,
36    /// Write the profile's bytes and record them.
37    Write,
38    /// Record the profile's bytes as the base; leave the file.
39    Record,
40    /// Stop tracking it, and leave the file: no layer provides it any more, and it is not as
41    /// devset recorded it, or it became the target's when written.
42    Untrack,
43    /// Delete the file, or take the part out of it: no layer provides it any more, and it is as
44    /// devset recorded it.
45    Remove,
46    /// Write the merge of local edits and the profile's new bytes, and record the latter.
47    Merge,
48    /// Leave the file; the merge, with markers, waits in `.devset/conflicts/`.
49    Conflict,
50}
51
52impl Action {
53    /// Whether the file on disk would change.
54    #[must_use]
55    pub const fn writes(self) -> bool {
56        matches!(self, Self::Write | Self::Merge | Self::Remove)
57    }
58}
59
60impl Entry {
61    /// What committing does to this path under `mode`.
62    ///
63    /// A needed merge is [`Action::Merge`] here; [`plan`] runs it, and may find a conflict.
64    #[must_use]
65    pub fn action(&self, mode: Mode) -> Action {
66        let Some(want) = self.want else {
67            return self.released();
68        };
69        let incoming = want.fingerprint;
70        let restore = mode == Mode::Force && want.policy == Policy::Owned;
71        match (self.found, self.record) {
72            (None, None) => Action::Write,
73            (None, Some(_)) => {
74                if restore {
75                    Action::Write
76                } else {
77                    Action::Keep
78                }
79            },
80            // Already the profile's content, perhaps reformatted: never rewritten.
81            (Some(found), record) if found.same_content(&incoming) => {
82                if record == Some(incoming) {
83                    Action::Keep
84                } else {
85                    Action::Record
86                }
87            },
88            // Present but never recorded: adopted, so the profile becomes its base.
89            (Some(_), None) => {
90                if restore {
91                    Action::Write
92                } else {
93                    Action::Record
94                }
95            },
96            // Untouched since recorded, and the profile has moved on.
97            (Some(found), Some(record)) if found.same_content(&record) => {
98                if want.policy == Policy::Once { Action::Keep } else { Action::Write }
99            },
100            (Some(_), Some(record)) => {
101                if restore {
102                    Action::Write
103                } else if want.policy == Policy::Merge && !record.same_content(&incoming) {
104                    Action::Merge
105                } else {
106                    Action::Keep
107                }
108            },
109        }
110    }
111}
112
113impl Entry {
114    /// What becomes of a path no layer provides: removed when it is as devset recorded it, unless
115    /// it became the target's when written (`once`); otherwise untracked, and left as it is.
116    fn released(&self) -> Action {
117        let unchanged = matches!(
118            (self.found, self.record),
119            (Some(found), Some(record)) if found.same_content(&record)
120        );
121        if unchanged && self.recorded != Some(Policy::Once) {
122            Action::Remove
123        } else {
124            Action::Untrack
125        }
126    }
127}
128
129/// One path and what committing does to it.
130#[derive(Clone, Debug)]
131pub struct Step {
132    /// The path, in all three forms.
133    pub entry: Entry,
134    /// What happens to it.
135    pub action: Action,
136    /// Why a merge conflicted.
137    pub note: Option<String>,
138}
139
140/// Everything committing will do; nothing is written until [`commit`](crate::commit()).
141#[derive(Debug)]
142pub struct Plan {
143    /// Where the profile's bytes come from.
144    pub(crate) resolved: Resolved,
145    /// What was decided of every active scaffold, for `state.toml`.
146    pub(crate) scaffolds: BTreeMap<ScaffoldId, Scaffold>,
147    /// Every path, in order.
148    pub(crate) steps: Vec<Step>,
149    /// Merge results, clean or conflicted, by path.
150    pub(crate) merged: Tree,
151    /// Files to delete: removed whole, or left empty by the parts removed from them.
152    pub(crate) gone: BTreeSet<RelPath>,
153    /// Whether `on-conflict = "apply-none"` withholds everything but the conflicts.
154    pub(crate) held: bool,
155}
156
157impl Plan {
158    /// The profile planned against.
159    #[must_use]
160    pub const fn resolved(&self) -> &Resolved {
161        &self.resolved
162    }
163
164    /// Every path and its action, in path order.
165    #[must_use]
166    pub fn steps(&self) -> &[Step] {
167        &self.steps
168    }
169
170    /// Whether conflicts withhold the rest of the update.
171    #[must_use]
172    pub const fn held(&self) -> bool {
173        self.held
174    }
175}
176
177/// Decides every path of `survey` under `mode`, running the merges it needs.
178///
179/// A file with parts is composed: each part spliced into it, merged where it needs merging, and
180/// the whole checked. A conflict in one part holds the whole file back. A file that is absent
181/// starts from its starter: a whole `once` file another layer provides, or a part's own.
182///
183/// # Errors
184/// - [`MergeError::Unresolved`], conflicts wait under [`Mode::Apply`] or [`Mode::Force`].
185/// - [`MergeError::NothingToContinue`], none waits under [`Mode::Continue`].
186/// - [`MergeError::Unmerged`] or [`MergeError::Invalid`], a resolution still has markers, or does
187///   not pass its file's check.
188/// - [`MergeError::Run`], a merge driver does not run to completion.
189/// - [`Error::Parse`](crate::Error::Parse), a part cannot be written into its file: a key it sets
190///   lies under one the file holds as a value.
191pub fn plan(survey: Survey, mode: Mode, target: &Target) -> Result<Plan> {
192    let Survey { resolved, entries, scaffolds, .. } = survey;
193    let mut unresolved: Vec<RelPath> =
194        entries.iter().filter(|e| e.conflict).map(|e| e.path.clone()).collect();
195    unresolved.dedup();
196    match (mode, unresolved.is_empty()) {
197        (Mode::Continue, true) => return Err(MergeError::NothingToContinue.into()),
198        (Mode::Apply | Mode::Force, false) => {
199            return Err(MergeError::Unresolved { paths: unresolved }.into());
200        },
201        _ => {},
202    }
203    let mut planner = Planner {
204        resolved: &resolved,
205        target,
206        mode,
207        merged: Tree::default(),
208        gone: BTreeSet::new(),
209    };
210    let mut steps = Vec::with_capacity(entries.len());
211    let mut entries = entries.into_iter().peekable();
212    while let Some(first) = entries.next() {
213        let path = first.path.clone();
214        let mut file = vec![first];
215        while let Some(next) = entries.next_if(|next| next.path == path) {
216            file.push(next);
217        }
218        steps.extend(planner.file(file)?);
219    }
220    let Planner { merged, gone, .. } = planner;
221    let conflicted = steps.iter().any(|step| step.action == Action::Conflict);
222    let held = conflicted && resolved.settings().on_conflict == OnConflict::ApplyNone;
223    Ok(Plan { resolved, scaffolds, steps, merged, gone, held })
224}
225
226/// Plans a file at a time, gathering the bytes merges and parts write.
227struct Planner<'a> {
228    /// The profile.
229    resolved: &'a Resolved,
230    /// Where the files are.
231    target: &'a Target,
232    /// How far the plan may go.
233    mode: Mode,
234    /// Merged and composed files, conflicted or not, by path.
235    merged: Tree,
236    /// Files to delete.
237    gone: BTreeSet<RelPath>,
238}
239
240/// What one provided part of a file becomes.
241struct Decision<'a> {
242    /// What committing does to it.
243    action: Action,
244    /// The content it puts in the file, when it changes.
245    content: Option<Vec<u8>>,
246    /// Why it conflicts.
247    note: Option<String>,
248    /// The keys both sides changed.
249    clash: Option<Clash<'a>>,
250}
251
252impl Decision<'_> {
253    /// A decision with no clash.
254    const fn of(action: Action, content: Option<Vec<u8>>, note: Option<String>) -> Self {
255        Self { action, content, note, clash: None }
256    }
257}
258
259/// Keys both sides changed in one part, and their values on each side but ours.
260struct Clash<'a> {
261    /// The part.
262    portion: &'a Portion,
263    /// The keys.
264    keys: BTreeSet<Key>,
265    /// Their values in the base.
266    base: Leaves,
267    /// Their values in the profile.
268    theirs: Leaves,
269}
270
271impl<'a> Planner<'a> {
272    /// Steps for every entry of one file: the whole file, its parts, or its resolution.
273    fn file(&mut self, entries: Vec<Entry>) -> Result<Vec<Step>> {
274        if entries.first().is_some_and(|entry| entry.conflict) {
275            return self.resolution(entries);
276        }
277        let provided = |entry: &Entry| entry.want.is_some();
278        let parted = entries.iter().any(|entry| entry.part.is_some() && provided(entry));
279        // A layer that owns the whole file decides all of it, a dropped part there left; a whole
280        // `once` file is a starter, which the parts compose with.
281        let owner = entries.iter().any(|entry| {
282            entry.part.is_none() && entry.want.is_some_and(|want| want.policy != Policy::Once)
283        });
284        // A whole entry still provided keeps the file, whatever parts leave it.
285        let kept = entries.iter().any(|entry| entry.part.is_none() && provided(entry));
286        let mut steps = Vec::with_capacity(entries.len());
287        let mut parts = Vec::new();
288        let mut start = None;
289        for entry in entries {
290            match (&entry.part, entry.want) {
291                (None, Some(_)) if parted => {
292                    let action = entry.action(self.mode);
293                    if action == Action::Write {
294                        start = Some(self.resolved.payload(&entry)?.to_vec());
295                    }
296                    steps.push(Step { entry, action, note: None });
297                },
298                (None, None) if parted => {
299                    // The parts take the file over: it stays, whatever its last owner left.
300                    let action = entry.action(self.mode);
301                    let action = if action == Action::Remove { Action::Untrack } else { action };
302                    steps.push(Step { entry, action, note: None });
303                },
304                (None, _) => steps.push(self.whole(entry)?),
305                (Some(_), None) if owner => {
306                    steps.push(Step { entry, action: Action::Untrack, note: None });
307                },
308                (Some(_), _) => parts.push(entry),
309            }
310        }
311        if !parts.is_empty() {
312            steps.extend(self.parts(parts, start, kept)?);
313            steps.sort_by(|a, b| a.entry.part.cmp(&b.entry.part));
314        }
315        // A starter is written composed with its parts, or not at all.
316        if steps.iter().any(|step| step.action == Action::Conflict) {
317            for step in
318                steps.iter_mut().filter(|s| s.entry.part.is_none() && s.action == Action::Write)
319            {
320                step.action = Action::Conflict;
321                step.note = Some("waits for the conflict in another part of the file".to_owned());
322            }
323        }
324        Ok(steps)
325    }
326
327    /// The step for a whole file, merged if it needs merging.
328    fn whole(&mut self, entry: Entry) -> Result<Step> {
329        let (action, note) = match (entry.action(self.mode), entry.want, entry.record) {
330            (Action::Merge, Some(want), Some(record)) => {
331                let base = self.target.base(&record)?;
332                let ours = fs_err::read(entry.path.under(self.target.root()))?;
333                let theirs = self.resolved.payload(&entry)?;
334                let Merged { bytes, conflict } =
335                    self.merge(&entry.path, base.as_deref(), &ours, theirs)?;
336                let conflict = conflict.or_else(|| {
337                    want.validate
338                        .check(&bytes)
339                        .err()
340                        .map(|why| format!("merged, but invalid: {why}"))
341                });
342                self.merged.put(entry.path.clone(), &bytes);
343                (if conflict.is_some() { Action::Conflict } else { Action::Merge }, conflict)
344            },
345            (Action::Remove, ..) => {
346                self.gone.insert(entry.path.clone());
347                (Action::Remove, None)
348            },
349            (action, ..) => (action, None),
350        };
351        Ok(Step { entry, action, note })
352    }
353
354    /// Merges `ours` and `theirs` from `base` for `path` with the configured driver.
355    fn merge(
356        &self, path: &RelPath, base: Option<&[u8]>, ours: &[u8], theirs: &[u8],
357    ) -> Result<Merged> {
358        let driver = &self.resolved.settings().driver;
359        let root = self.target.root();
360        let Merged { bytes, conflict } =
361            driver.merge(root, path, base.unwrap_or_default(), ours, theirs)?;
362        // Without its base a merge cannot tell a local edit from a profile change, so every
363        // difference waits for the user; a binary file keeps its own note.
364        let lost = base.is_none() && !is_binary(ours) && !is_binary(theirs);
365        let conflict = if lost {
366            Some("its recorded base is missing, so it merged without one".to_owned())
367        } else {
368            conflict
369        };
370        Ok(Merged { bytes, conflict })
371    }
372
373    /// Steps for the parts of one file: each spliced in, merged where it needs merging, then
374    /// the whole file checked, and each part read back.
375    ///
376    /// An absent file starts from `start`, a whole starter being written, or else from the part's
377    /// own starter. A starter written anew takes every part again, one deleted with the file
378    /// included; and a file a whole entry `kept` is never deleted for the parts it loses.
379    fn parts(
380        &mut self, mut entries: Vec<Entry>, start: Option<Vec<u8>>, kept: bool,
381    ) -> Result<Vec<Step>> {
382        let resolved = self.resolved;
383        // A key a part's base holds but its profile dropped, which another part's profile now
384        // provides, is that part's: the part that dropped it leaves it be.
385        let provided = entries
386            .iter()
387            .map(|entry| match entry.want.and_then(|_| resolved.portion(entry)) {
388                Some(portion) => Ok(portion.shape.keys(resolved.payload(entry)?)),
389                None => Ok(BTreeSet::new()),
390            })
391            .collect::<Result<Vec<_>>>()?;
392        for (index, (entry, own)) in entries.iter_mut().zip(&provided).enumerate() {
393            let theirs: Vec<&Key> = provided
394                .iter()
395                .enumerate()
396                .filter(|(other, _)| *other != index)
397                .flat_map(|(_, keys)| keys)
398                .collect();
399            entry.keys.retain(|key| own.contains(key) || !theirs.iter().any(|k| overlap(key, k)));
400        }
401        let Some(path) = entries.first().map(|entry| entry.path.clone()) else {
402            return Ok(Vec::new());
403        };
404        let label = path.as_str();
405        let found = read_optional(&path.under(self.target.root()))?;
406        let starter = || {
407            let mut own = entries.iter().filter(|entry| entry.want.is_some());
408            own.find_map(|entry| resolved.portion(entry)?.starter.clone())
409        };
410        let recreated = found.is_none() && start.is_some();
411        let disk = found.unwrap_or_else(|| start.or_else(starter).unwrap_or_default());
412        let mut file = disk.clone();
413        let mut steps = Vec::with_capacity(entries.len());
414        let mut written: Vec<(usize, &Shape, Vec<u8>)> = Vec::new();
415        let mut removed: Vec<(usize, Shape, BTreeSet<Key>)> = Vec::new();
416        let mut clashes = Vec::new();
417        // Keys a part still provided covers, which no dropped part takes with it.
418        let claimed: Vec<Key> = entries
419            .iter()
420            .filter(|entry| entry.want.is_some())
421            .flat_map(|entry| entry.keys.iter().cloned())
422            .collect();
423        for entry in entries {
424            let Some(portion) = entry.want.and_then(|_| resolved.portion(&entry)) else {
425                let taken = self.take_out(&mut file, &entry, &claimed, label)?;
426                let action = if taken.is_some() { Action::Remove } else { Action::Untrack };
427                removed.extend(taken.map(|(shape, keys)| (steps.len(), shape, keys)));
428                steps.push(Step { entry, action, note: None });
429                continue;
430            };
431            let decision = if recreated && entry.action(self.mode) == Action::Keep {
432                Decision::of(Action::Write, Some(resolved.payload(&entry)?.to_vec()), None)
433            } else {
434                self.decide(&entry, portion, &disk)?
435            };
436            let Decision { action, content, note, clash } = decision;
437            clashes.extend(clash);
438            if let Some(content) = content {
439                file = portion.splice(&file, &content, &entry.keys, label)?;
440                written.push((steps.len(), &portion.shape, content));
441            }
442            steps.push(Step { entry, action, note });
443        }
444        if written.is_empty() && removed.is_empty() {
445            return Ok(steps);
446        }
447        // Markers never parse: a conflicted file is checked once resolved, by `--continue`.
448        let marked = steps.iter().any(|step| step.action == Action::Conflict);
449        let trouble = if marked { None } else { check(&file, &steps, &written, &removed, label)? };
450        let conflicted = marked || trouble.is_some();
451        if !conflicted {
452            // A file that held nothing but the parts removed from it goes with them.
453            if !removed.is_empty() && !kept && file.trim_ascii().is_empty() {
454                self.gone.insert(path);
455            } else {
456                self.merged.put(path, &file);
457            }
458            return Ok(steps);
459        }
460        for step in &mut steps {
461            if step.action.writes() {
462                step.action = Action::Conflict;
463                step.note = trouble.clone().or_else(|| {
464                    Some("waits for the conflict in another part of the file".to_owned())
465                });
466            }
467        }
468        let sidecar = self.mark(&path, &file, &clashes)?;
469        self.merged.put(path, &sidecar);
470        Ok(steps)
471    }
472
473    /// What `entry`, a part `portion` provides, becomes against its file as it is on `disk`.
474    fn decide(&self, entry: &Entry, portion: &'a Portion, disk: &[u8]) -> Result<Decision<'a>> {
475        let (shape, theirs) = (&portion.shape, self.resolved.payload(entry)?);
476        let label = entry.path.as_str();
477        Ok(match (entry.action(self.mode), entry.record, shape) {
478            (Action::Write, ..) => Decision::of(Action::Write, Some(theirs.to_vec()), None),
479            (Action::Merge, Some(record), Shape::Keys(_)) => {
480                let base = self.target.base(&record)?;
481                let base = base.as_deref().and_then(decode).unwrap_or_default();
482                let ours = shape.view(disk, &entry.keys, label)?;
483                let ours = ours.as_deref().and_then(decode).unwrap_or_default();
484                let theirs = decode(theirs).unwrap_or_default();
485                let merged = merge(&base, &ours, &theirs, &entry.keys);
486                let content = Some(encode(&merged.leaves));
487                if merged.conflicts.is_empty() {
488                    return Ok(Decision::of(Action::Merge, content, None));
489                }
490                let note = format!("both changed {}", keys(&merged.conflicts));
491                let at = |side: &Leaves| {
492                    let conflicts = &merged.conflicts;
493                    side.iter().filter(|(k, _)| conflicts.contains(*k)).map(clone).collect()
494                };
495                let (base, theirs) = (at(&base), at(&theirs));
496                let clash = Clash { portion, keys: merged.conflicts, base, theirs };
497                Decision { action: Action::Conflict, content, note: Some(note), clash: Some(clash) }
498            },
499            (Action::Merge, Some(record), Shape::Block(_)) => {
500                let base = self.target.base(&record)?;
501                let ours = shape.view(disk, &entry.keys, label)?.unwrap_or_default();
502                let Merged { bytes, conflict } =
503                    self.merge(&entry.path, base.as_deref(), &ours, theirs)?;
504                let action = if conflict.is_some() { Action::Conflict } else { Action::Merge };
505                Decision::of(action, Some(bytes), conflict)
506            },
507            // Adopted leaf by leaf: a key the file holds keeps its value, one it lacks is
508            // written, as a whole file already there is kept and one missing is written.
509            (Action::Record, None, Shape::Keys(_)) => {
510                let ours = shape.view(disk, &entry.keys, label)?;
511                let ours = ours.as_deref().and_then(decode).unwrap_or_default();
512                let mut filled = decode(theirs).unwrap_or_default();
513                filled.extend(ours.iter().map(clone));
514                if filled == ours {
515                    Decision::of(Action::Record, None, None)
516                } else {
517                    Decision::of(Action::Merge, Some(encode(&filled)), None)
518                }
519            },
520            (action, ..) => Decision::of(action, None, None),
521        })
522    }
523
524    /// Takes a part no layer provides out of `file`, as its path reads it, when it is as devset
525    /// recorded it; a key a part still provided covers stays. The part's shape and the keys it
526    /// took, or `None` when it stays, untracked.
527    fn take_out(
528        &self, file: &mut Vec<u8>, entry: &Entry, claimed: &[Key], label: &str,
529    ) -> Result<Option<(Shape, BTreeSet<Key>)>> {
530        if entry.action(self.mode) != Action::Remove {
531            return Ok(None);
532        }
533        // A part its gates turned off keeps the shape its profile gives it.
534        let shape = self.resolved.portion(entry).map(|portion| portion.shape.clone());
535        let Some(shape) = shape.or_else(|| released(self.resolved, self.target, entry)) else {
536            return Ok(None);
537        };
538        let mut keys = entry.keys.clone();
539        keys.retain(|key| !claimed.iter().any(|kept| overlap(key, kept)));
540        *file = shape.remove(file, &keys, label)?;
541        Ok(Some((shape, keys)))
542    }
543
544    /// `file` with conflict markers around the keys in `clashes`: ours, as `file` holds them,
545    /// against the profile's.
546    fn mark(&self, path: &RelPath, file: &[u8], clashes: &[Clash<'_>]) -> Result<Vec<u8>> {
547        if clashes.is_empty() {
548            return Ok(file.to_vec());
549        }
550        let label = path.as_str();
551        let (mut base, mut theirs) = (file.to_vec(), file.to_vec());
552        for clash in clashes {
553            base = clash.portion.splice(&base, &encode(&clash.base), &clash.keys, label)?;
554            theirs = clash.portion.splice(&theirs, &encode(&clash.theirs), &clash.keys, label)?;
555        }
556        let root = self.target.root();
557        Ok(Driver::Builtin.merge(root, path, &base, file, &theirs)?.bytes)
558    }
559
560    /// Installs the resolution of a file that waits in `.devset/conflicts/`.
561    ///
562    /// The whole file is the resolution, so a part the update changed is recorded from the
563    /// profile; one it left alone keeps its record.
564    fn resolution(&mut self, entries: Vec<Entry>) -> Result<Vec<Step>> {
565        let path = entries.iter().find(|entry| entry.want.is_some()).map(|e| e.path.clone());
566        if let Some(path) = path {
567            let bytes = fs_err::read(self.target.sidecar(&path))?;
568            if has_markers(&bytes) {
569                return Err(MergeError::Unmerged { path }.into());
570            }
571            for want in entries.iter().filter_map(|entry| entry.want) {
572                want.validate
573                    .check(&bytes)
574                    .map_err(|reason| MergeError::Invalid { path: path.clone(), reason })?;
575            }
576            self.merged.put(path, &bytes);
577        }
578        let mode = self.mode;
579        let step = |entry: Entry| {
580            let action = match (entry.want, &entry.part) {
581                (None, _) => Action::Untrack,
582                (Some(_), None) => Action::Merge,
583                (Some(_), Some(_)) => match entry.action(mode) {
584                    action if action.writes() => Action::Merge,
585                    action => action,
586                },
587            };
588            Step { entry, action, note: None }
589        };
590        Ok(entries.into_iter().map(step).collect())
591    }
592}
593
594/// Why the composed `file` cannot be written: it does not pass a part's check, a part reads back
595/// other than as `written`, or one `removed` is still there.
596fn check(
597    file: &[u8], steps: &[Step], written: &[(usize, &Shape, Vec<u8>)],
598    removed: &[(usize, Shape, BTreeSet<Key>)], label: &str,
599) -> Result<Option<String>> {
600    let mut formats: Vec<Format> =
601        steps.iter().filter_map(|s| s.entry.want).map(|w| w.validate).collect();
602    formats.dedup();
603    for format in formats {
604        if let Err(why) = format.check(file) {
605            return Ok(Some(format!("with it, the file is invalid: {why}")));
606        }
607    }
608    for (index, shape, content) in written {
609        let Some(step) = steps.get(*index) else {
610            continue;
611        };
612        let back = shape.view(file, &step.entry.keys, label)?.unwrap_or_default();
613        if !Fingerprint::of(&back).same_content(&Fingerprint::of(content)) {
614            return Ok(Some("it reads back other than devset wrote it".to_owned()));
615        }
616    }
617    for (_, shape, keys) in removed {
618        let left = shape.view(file, keys, label)?;
619        if left.is_some_and(|left| decode(&left).is_none_or(|leaves| !leaves.is_empty())) {
620            return Ok(Some("a part devset removed is still in it".to_owned()));
621        }
622    }
623    Ok(None)
624}
625
626/// A leaf, owned.
627fn clone((key, value): (&Key, &Value)) -> (Key, Value) {
628    (key.clone(), value.clone())
629}
630
631/// `keys` as people write them, the first few by name.
632fn keys(keys: &BTreeSet<Key>) -> String {
633    const NAMED: usize = 3;
634    let named = keys.iter().take(NAMED).map(|key| format!("`{}`", display(key)));
635    let rest = keys.len().saturating_sub(NAMED);
636    list(named.chain((rest > 0).then(|| format!("{rest} more"))))
637}
638
639#[cfg(test)]
640mod tests {
641    use alloc::collections::BTreeSet;
642
643    use super::{Action, Mode};
644    use crate::digest::Fingerprint;
645    use crate::format::Format;
646    use crate::path::RelPath;
647    use crate::profile::Policy;
648    use crate::survey::{Entry, Want};
649
650    /// On-disk and recorded contents, by name.
651    ///
652    /// `"p"` is the profile's bytes, `"p~"` the same reformatted, `"o"` older profile bytes,
653    /// `"x"` a local edit.
654    fn fp(name: &str) -> Fingerprint {
655        Fingerprint::of(match name {
656            "p" => b"profile\n",
657            "p~" => b"profile  \r\n\n",
658            "o" => b"old\n",
659            _ => b"edited\n",
660        })
661    }
662
663    fn entry(policy: Policy, found: Option<&str>, record: Option<&str>) -> Entry {
664        Entry {
665            path: RelPath::new("f").unwrap(),
666            part: None,
667            want: Some(Want {
668                layer: 0,
669                policy,
670                validate: Format::None,
671                executable: false,
672                fingerprint: fp("p"),
673            }),
674            record: record.map(fp),
675            recorded: record.map(|_| policy),
676            found: found.map(fp),
677            conflict: false,
678            gate: None,
679            present: found.is_some(),
680            keys: BTreeSet::new(),
681        }
682    }
683
684    #[test]
685    fn decision_table() {
686        use Action::{Keep, Merge, Record, Write};
687        use Policy::{Merge as M, Once, Owned};
688        #[rustfmt::skip]
689        let table = [
690            // policy found       record      apply   force
691            (Owned, None,       None,       Write,  Write),  // new
692            (Owned, None,       Some("p"),  Keep,   Write),  // deleted
693            (Owned, Some("p"),  Some("p"),  Keep,   Keep),   // in sync
694            (Owned, Some("p~"), Some("p"),  Keep,   Keep),   // cosmetic
695            (Owned, Some("p~"), Some("o"),  Record, Record), // already the new content
696            (Owned, Some("x"),  None,       Record, Write),  // untracked: adopt
697            (Owned, Some("o"),  Some("o"),  Write,  Write),  // profile moved on
698            (Owned, Some("x"),  Some("p"),  Keep,   Write),  // edited
699            (Once,  None,       None,       Write,  Write),
700            (Once,  None,       Some("p"),  Keep,   Keep),
701            (Once,  Some("x"),  None,       Record, Record),
702            (Once,  Some("o"),  Some("o"),  Keep,   Keep),
703            (Once,  Some("x"),  Some("p"),  Keep,   Keep),
704            (M,     Some("x"),  Some("o"),  Merge,  Merge),
705            (M,     Some("x"),  Some("p"),  Keep,   Keep),
706            (M,     Some("o"),  Some("o"),  Write,  Write),
707        ];
708        for (policy, found, record, apply, force) in table {
709            let entry = entry(policy, found, record);
710            let case = format!("{policy:?} found={found:?} record={record:?}");
711            assert_eq!(entry.action(Mode::Apply), apply, "apply: {case}");
712            assert_eq!(entry.action(Mode::Force), force, "force: {case}");
713        }
714    }
715
716    #[test]
717    fn a_dropped_file_goes_unless_edited_or_the_targets() {
718        use Action::{Remove, Untrack};
719        use Policy::{Merge as M, Once, Owned};
720        #[rustfmt::skip]
721        let table = [
722            // policy found       record      both modes
723            (Owned, Some("p"),  Some("p"),  Remove),  // as recorded
724            (Owned, Some("p~"), Some("p"),  Remove),  // reformatted only
725            (M,     Some("p"),  Some("p"),  Remove),
726            (Owned, Some("x"),  Some("p"),  Untrack), // edited: the edit stays
727            (Owned, None,       Some("p"),  Untrack), // already deleted
728            (Once,  Some("p"),  Some("p"),  Untrack), // the target's since written
729        ];
730        for (policy, found, record, action) in table {
731            let entry = Entry { want: None, ..entry(policy, found, record) };
732            let case = format!("{policy:?} found={found:?} record={record:?}");
733            assert_eq!(entry.action(Mode::Apply), action, "apply: {case}");
734            assert_eq!(entry.action(Mode::Force), action, "force: {case}");
735        }
736    }
737}