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, entries: Vec<Entry>, start: Option<Vec<u8>>, kept: bool,
381    ) -> Result<Vec<Step>> {
382        let resolved = self.resolved;
383        let Some(path) = entries.first().map(|entry| entry.path.clone()) else {
384            return Ok(Vec::new());
385        };
386        let label = path.as_str();
387        let found = read_optional(&path.under(self.target.root()))?;
388        let starter = || {
389            let mut own = entries.iter().filter(|entry| entry.want.is_some());
390            own.find_map(|entry| resolved.portion(entry)?.starter.clone())
391        };
392        let recreated = found.is_none() && start.is_some();
393        let disk = found.unwrap_or_else(|| start.or_else(starter).unwrap_or_default());
394        let mut file = disk.clone();
395        let mut steps = Vec::with_capacity(entries.len());
396        let mut written: Vec<(usize, &Shape, Vec<u8>)> = Vec::new();
397        let mut removed: Vec<(usize, Shape, BTreeSet<Key>)> = Vec::new();
398        let mut clashes = Vec::new();
399        // Keys a part still provided covers, which no dropped part takes with it.
400        let claimed: Vec<Key> = entries
401            .iter()
402            .filter(|entry| entry.want.is_some())
403            .flat_map(|entry| entry.keys.iter().cloned())
404            .collect();
405        for entry in entries {
406            let Some(portion) = entry.want.and_then(|_| resolved.portion(&entry)) else {
407                let taken = self.take_out(&mut file, &entry, &claimed, label)?;
408                let action = if taken.is_some() { Action::Remove } else { Action::Untrack };
409                removed.extend(taken.map(|(shape, keys)| (steps.len(), shape, keys)));
410                steps.push(Step { entry, action, note: None });
411                continue;
412            };
413            let decision = if recreated && entry.action(self.mode) == Action::Keep {
414                Decision::of(Action::Write, Some(resolved.payload(&entry)?.to_vec()), None)
415            } else {
416                self.decide(&entry, portion, &disk)?
417            };
418            let Decision { action, content, note, clash } = decision;
419            clashes.extend(clash);
420            if let Some(content) = content {
421                file = portion.splice(&file, &content, &entry.keys, label)?;
422                written.push((steps.len(), &portion.shape, content));
423            }
424            steps.push(Step { entry, action, note });
425        }
426        if written.is_empty() && removed.is_empty() {
427            return Ok(steps);
428        }
429        // Markers never parse: a conflicted file is checked once resolved, by `--continue`.
430        let marked = steps.iter().any(|step| step.action == Action::Conflict);
431        let trouble = if marked { None } else { check(&file, &steps, &written, &removed, label)? };
432        let conflicted = marked || trouble.is_some();
433        if !conflicted {
434            // A file that held nothing but the parts removed from it goes with them.
435            if !removed.is_empty() && !kept && file.trim_ascii().is_empty() {
436                self.gone.insert(path);
437            } else {
438                self.merged.put(path, &file);
439            }
440            return Ok(steps);
441        }
442        for step in &mut steps {
443            if step.action.writes() {
444                step.action = Action::Conflict;
445                step.note = trouble.clone().or_else(|| {
446                    Some("waits for the conflict in another part of the file".to_owned())
447                });
448            }
449        }
450        let sidecar = self.mark(&path, &file, &clashes)?;
451        self.merged.put(path, &sidecar);
452        Ok(steps)
453    }
454
455    /// What `entry`, a part `portion` provides, becomes against its file as it is on `disk`.
456    fn decide(&self, entry: &Entry, portion: &'a Portion, disk: &[u8]) -> Result<Decision<'a>> {
457        let (shape, theirs) = (&portion.shape, self.resolved.payload(entry)?);
458        let label = entry.path.as_str();
459        Ok(match (entry.action(self.mode), entry.record, shape) {
460            (Action::Write, ..) => Decision::of(Action::Write, Some(theirs.to_vec()), None),
461            (Action::Merge, Some(record), Shape::Keys(_)) => {
462                let base = self.target.base(&record)?;
463                let base = base.as_deref().and_then(decode).unwrap_or_default();
464                let ours = shape.view(disk, &entry.keys, label)?;
465                let ours = ours.as_deref().and_then(decode).unwrap_or_default();
466                let theirs = decode(theirs).unwrap_or_default();
467                let merged = merge(&base, &ours, &theirs, &entry.keys);
468                let content = Some(encode(&merged.leaves));
469                if merged.conflicts.is_empty() {
470                    return Ok(Decision::of(Action::Merge, content, None));
471                }
472                let note = format!("both changed {}", keys(&merged.conflicts));
473                let at = |side: &Leaves| {
474                    let conflicts = &merged.conflicts;
475                    side.iter().filter(|(k, _)| conflicts.contains(*k)).map(clone).collect()
476                };
477                let (base, theirs) = (at(&base), at(&theirs));
478                let clash = Clash { portion, keys: merged.conflicts, base, theirs };
479                Decision { action: Action::Conflict, content, note: Some(note), clash: Some(clash) }
480            },
481            (Action::Merge, Some(record), Shape::Block(_)) => {
482                let base = self.target.base(&record)?;
483                let ours = shape.view(disk, &entry.keys, label)?.unwrap_or_default();
484                let Merged { bytes, conflict } =
485                    self.merge(&entry.path, base.as_deref(), &ours, theirs)?;
486                let action = if conflict.is_some() { Action::Conflict } else { Action::Merge };
487                Decision::of(action, Some(bytes), conflict)
488            },
489            // Adopted leaf by leaf: a key the file holds keeps its value, one it lacks is
490            // written, as a whole file already there is kept and one missing is written.
491            (Action::Record, None, Shape::Keys(_)) => {
492                let ours = shape.view(disk, &entry.keys, label)?;
493                let ours = ours.as_deref().and_then(decode).unwrap_or_default();
494                let mut filled = decode(theirs).unwrap_or_default();
495                filled.extend(ours.iter().map(clone));
496                if filled == ours {
497                    Decision::of(Action::Record, None, None)
498                } else {
499                    Decision::of(Action::Merge, Some(encode(&filled)), None)
500                }
501            },
502            (action, ..) => Decision::of(action, None, None),
503        })
504    }
505
506    /// Takes a part no layer provides out of `file`, as its path reads it, when it is as devset
507    /// recorded it; a key a part still provided covers stays. The part's shape and the keys it
508    /// took, or `None` when it stays, untracked.
509    fn take_out(
510        &self, file: &mut Vec<u8>, entry: &Entry, claimed: &[Key], label: &str,
511    ) -> Result<Option<(Shape, BTreeSet<Key>)>> {
512        if entry.action(self.mode) != Action::Remove {
513            return Ok(None);
514        }
515        // A part its gates turned off keeps the shape its profile gives it.
516        let shape = self.resolved.portion(entry).map(|portion| portion.shape.clone());
517        let Some(shape) = shape.or_else(|| released(self.resolved, self.target, entry)) else {
518            return Ok(None);
519        };
520        let mut keys = entry.keys.clone();
521        keys.retain(|key| !claimed.iter().any(|kept| overlap(key, kept)));
522        *file = shape.remove(file, &keys, label)?;
523        Ok(Some((shape, keys)))
524    }
525
526    /// `file` with conflict markers around the keys in `clashes`: ours, as `file` holds them,
527    /// against the profile's.
528    fn mark(&self, path: &RelPath, file: &[u8], clashes: &[Clash<'_>]) -> Result<Vec<u8>> {
529        if clashes.is_empty() {
530            return Ok(file.to_vec());
531        }
532        let label = path.as_str();
533        let (mut base, mut theirs) = (file.to_vec(), file.to_vec());
534        for clash in clashes {
535            base = clash.portion.splice(&base, &encode(&clash.base), &clash.keys, label)?;
536            theirs = clash.portion.splice(&theirs, &encode(&clash.theirs), &clash.keys, label)?;
537        }
538        let root = self.target.root();
539        Ok(Driver::Builtin.merge(root, path, &base, file, &theirs)?.bytes)
540    }
541
542    /// Installs the resolution of a file that waits in `.devset/conflicts/`.
543    ///
544    /// The whole file is the resolution, so a part the update changed is recorded from the
545    /// profile; one it left alone keeps its record.
546    fn resolution(&mut self, entries: Vec<Entry>) -> Result<Vec<Step>> {
547        let path = entries.iter().find(|entry| entry.want.is_some()).map(|e| e.path.clone());
548        if let Some(path) = path {
549            let bytes = fs_err::read(self.target.sidecar(&path))?;
550            if has_markers(&bytes) {
551                return Err(MergeError::Unmerged { path }.into());
552            }
553            for want in entries.iter().filter_map(|entry| entry.want) {
554                want.validate
555                    .check(&bytes)
556                    .map_err(|reason| MergeError::Invalid { path: path.clone(), reason })?;
557            }
558            self.merged.put(path, &bytes);
559        }
560        let mode = self.mode;
561        let step = |entry: Entry| {
562            let action = match (entry.want, &entry.part) {
563                (None, _) => Action::Untrack,
564                (Some(_), None) => Action::Merge,
565                (Some(_), Some(_)) => match entry.action(mode) {
566                    action if action.writes() => Action::Merge,
567                    action => action,
568                },
569            };
570            Step { entry, action, note: None }
571        };
572        Ok(entries.into_iter().map(step).collect())
573    }
574}
575
576/// Why the composed `file` cannot be written: it does not pass a part's check, a part reads back
577/// other than as `written`, or one `removed` is still there.
578fn check(
579    file: &[u8], steps: &[Step], written: &[(usize, &Shape, Vec<u8>)],
580    removed: &[(usize, Shape, BTreeSet<Key>)], label: &str,
581) -> Result<Option<String>> {
582    let mut formats: Vec<Format> =
583        steps.iter().filter_map(|s| s.entry.want).map(|w| w.validate).collect();
584    formats.dedup();
585    for format in formats {
586        if let Err(why) = format.check(file) {
587            return Ok(Some(format!("with it, the file is invalid: {why}")));
588        }
589    }
590    for (index, shape, content) in written {
591        let Some(step) = steps.get(*index) else {
592            continue;
593        };
594        let back = shape.view(file, &step.entry.keys, label)?.unwrap_or_default();
595        if !Fingerprint::of(&back).same_content(&Fingerprint::of(content)) {
596            return Ok(Some("it reads back other than devset wrote it".to_owned()));
597        }
598    }
599    for (_, shape, keys) in removed {
600        let left = shape.view(file, keys, label)?;
601        if left.is_some_and(|left| decode(&left).is_none_or(|leaves| !leaves.is_empty())) {
602            return Ok(Some("a part devset removed is still in it".to_owned()));
603        }
604    }
605    Ok(None)
606}
607
608/// A leaf, owned.
609fn clone((key, value): (&Key, &Value)) -> (Key, Value) {
610    (key.clone(), value.clone())
611}
612
613/// `keys` as people write them, the first few by name.
614fn keys(keys: &BTreeSet<Key>) -> String {
615    const NAMED: usize = 3;
616    let named = keys.iter().take(NAMED).map(|key| format!("`{}`", display(key)));
617    let rest = keys.len().saturating_sub(NAMED);
618    list(named.chain((rest > 0).then(|| format!("{rest} more"))))
619}
620
621#[cfg(test)]
622mod tests {
623    use alloc::collections::BTreeSet;
624
625    use super::{Action, Mode};
626    use crate::digest::Fingerprint;
627    use crate::format::Format;
628    use crate::path::RelPath;
629    use crate::profile::Policy;
630    use crate::survey::{Entry, Want};
631
632    /// On-disk and recorded contents, by name.
633    ///
634    /// `"p"` is the profile's bytes, `"p~"` the same reformatted, `"o"` older profile bytes,
635    /// `"x"` a local edit.
636    fn fp(name: &str) -> Fingerprint {
637        Fingerprint::of(match name {
638            "p" => b"profile\n",
639            "p~" => b"profile  \r\n\n",
640            "o" => b"old\n",
641            _ => b"edited\n",
642        })
643    }
644
645    fn entry(policy: Policy, found: Option<&str>, record: Option<&str>) -> Entry {
646        Entry {
647            path: RelPath::new("f").unwrap(),
648            part: None,
649            want: Some(Want {
650                layer: 0,
651                policy,
652                validate: Format::None,
653                executable: false,
654                fingerprint: fp("p"),
655            }),
656            record: record.map(fp),
657            recorded: record.map(|_| policy),
658            found: found.map(fp),
659            conflict: false,
660            gate: None,
661            present: found.is_some(),
662            keys: BTreeSet::new(),
663        }
664    }
665
666    #[test]
667    fn decision_table() {
668        use Action::{Keep, Merge, Record, Write};
669        use Policy::{Merge as M, Once, Owned};
670        #[rustfmt::skip]
671        let table = [
672            // policy found       record      apply   force
673            (Owned, None,       None,       Write,  Write),  // new
674            (Owned, None,       Some("p"),  Keep,   Write),  // deleted
675            (Owned, Some("p"),  Some("p"),  Keep,   Keep),   // in sync
676            (Owned, Some("p~"), Some("p"),  Keep,   Keep),   // cosmetic
677            (Owned, Some("p~"), Some("o"),  Record, Record), // already the new content
678            (Owned, Some("x"),  None,       Record, Write),  // untracked: adopt
679            (Owned, Some("o"),  Some("o"),  Write,  Write),  // profile moved on
680            (Owned, Some("x"),  Some("p"),  Keep,   Write),  // edited
681            (Once,  None,       None,       Write,  Write),
682            (Once,  None,       Some("p"),  Keep,   Keep),
683            (Once,  Some("x"),  None,       Record, Record),
684            (Once,  Some("o"),  Some("o"),  Keep,   Keep),
685            (Once,  Some("x"),  Some("p"),  Keep,   Keep),
686            (M,     Some("x"),  Some("o"),  Merge,  Merge),
687            (M,     Some("x"),  Some("p"),  Keep,   Keep),
688            (M,     Some("o"),  Some("o"),  Write,  Write),
689        ];
690        for (policy, found, record, apply, force) in table {
691            let entry = entry(policy, found, record);
692            let case = format!("{policy:?} found={found:?} record={record:?}");
693            assert_eq!(entry.action(Mode::Apply), apply, "apply: {case}");
694            assert_eq!(entry.action(Mode::Force), force, "force: {case}");
695        }
696    }
697
698    #[test]
699    fn a_dropped_file_goes_unless_edited_or_the_targets() {
700        use Action::{Remove, Untrack};
701        use Policy::{Merge as M, Once, Owned};
702        #[rustfmt::skip]
703        let table = [
704            // policy found       record      both modes
705            (Owned, Some("p"),  Some("p"),  Remove),  // as recorded
706            (Owned, Some("p~"), Some("p"),  Remove),  // reformatted only
707            (M,     Some("p"),  Some("p"),  Remove),
708            (Owned, Some("x"),  Some("p"),  Untrack), // edited: the edit stays
709            (Owned, None,       Some("p"),  Untrack), // already deleted
710            (Once,  Some("p"),  Some("p"),  Untrack), // the target's since written
711        ];
712        for (policy, found, record, action) in table {
713            let entry = Entry { want: None, ..entry(policy, found, record) };
714            let case = format!("{policy:?} found={found:?} record={record:?}");
715            assert_eq!(entry.action(Mode::Apply), action, "apply: {case}");
716            assert_eq!(entry.action(Mode::Force), action, "force: {case}");
717        }
718    }
719}