Skip to main content

devset_core/
gate.rs

1//! Gates: whether an entry applies.
2//!
3//! An entry's `when` holds when every condition does. `features`, `profiles` and `vars` are
4//! settled by [`resolve`](crate::resolve()), which knows the graph and the answers. Scaffolds and
5//! `exists` are settled by [`survey`](crate::survey()), which knows the disk:
6//!
7//! - A scaffold is decided once: written when none of its sentinels was there, found otherwise, and
8//!   recorded. A found group's files are off.
9//! - `exists` holds when a path will exist once the run is done: on disk and not removed by it, or
10//!   written by it, whole or as a part. It only ever turns entries on, so it settles as a least
11//!   fixpoint: every entry it gates starts off, and each round turns on those whose paths will
12//!   exist, until none turns on. An entry is never its own evidence, and the order of the layers
13//!   never matters.
14//!
15//! An entry left off with a record is released as its profile dropping it would release it; one
16//! without is not there at all.
17
18use alloc::collections::{BTreeMap, BTreeSet};
19use core::cell::OnceCell;
20use core::fmt;
21
22use camino::Utf8Path;
23use globset::{GlobBuilder, GlobMatcher};
24use ignore::WalkBuilder;
25
26use crate::errors::{Result, TargetError};
27use crate::graph::Enabler;
28use crate::name::{FeatureName, ProfileName, ScaffoldId};
29use crate::part::Slot;
30use crate::path::RelPath;
31use crate::plan::{Action, Mode};
32use crate::profile::When;
33use crate::resolve::{Layer, Resolved};
34use crate::survey::Entry;
35use crate::target::{DIR, Scaffold, Scaffolded, Target};
36use crate::vars::VarName;
37
38/// A path in the target, or a glob over its paths.
39#[derive(Clone, Debug)]
40pub(crate) enum Pattern {
41    /// One path.
42    Path(RelPath),
43    /// A glob: `*` within a component, `**` across them.
44    Glob {
45        /// As written, variables answered.
46        written: String,
47        /// Its matcher.
48        glob: GlobMatcher,
49    },
50}
51
52impl Pattern {
53    /// `written` as a path, or as a glob when it has any of `*?[{`.
54    ///
55    /// # Errors
56    /// Why it is neither.
57    pub(crate) fn parse(written: &str) -> Result<Self, String> {
58        if !written.contains(['*', '?', '[', '{']) {
59            return RelPath::new(written).map(Self::Path).map_err(|e| e.to_string());
60        }
61        let glob = GlobBuilder::new(written)
62            .literal_separator(true)
63            .build()
64            .map_err(|e| e.kind().to_string())?;
65        Ok(Self::Glob { written: written.to_owned(), glob: glob.compile_matcher() })
66    }
67
68    /// As written.
69    pub(crate) fn as_str(&self) -> &str {
70        match self {
71            Self::Path(path) => path.as_str(),
72            Self::Glob { written, .. } => written,
73        }
74    }
75}
76
77impl fmt::Display for Pattern {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.write_str(self.as_str())
80    }
81}
82
83/// Why an entry does not apply.
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub enum Reason {
86    /// A feature of its profile is off.
87    Feature(FeatureName),
88    /// A profile is not active in the target.
89    Profile(ProfileName),
90    /// A variable's answer is none of the values `when` names.
91    Var {
92        /// The variable.
93        name: VarName,
94        /// Its answer.
95        answer: String,
96    },
97    /// Its scaffold found the target's own files.
98    Found(ScaffoldId),
99    /// Its scaffold wrote it elsewhere, before an answer moved its path; it stays there.
100    Moved {
101        /// The scaffold.
102        scaffold: ScaffoldId,
103        /// Where it wrote it.
104        path: RelPath,
105    },
106    /// A path, or a glob, will not exist when the run is done.
107    Absent(String),
108}
109
110impl fmt::Display for Reason {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::Feature(feature) => write!(f, "feature {feature} is off"),
114            Self::Profile(profile) => write!(f, "profile {profile} is not active"),
115            Self::Var { name, answer } => write!(f, "{name} is {answer:?}"),
116            Self::Found(scaffold) => write!(f, "scaffold {scaffold} found the target's own"),
117            Self::Moved { scaffold, path } => write!(f, "scaffold {scaffold} wrote it at {path}"),
118            Self::Absent(pattern) => write!(f, "{pattern} will not exist"),
119        }
120    }
121}
122
123/// Why `when` does not hold for a layer with `features` on, among `active` profiles and
124/// `answers`, as far as those tell; `None` when it does.
125pub(crate) fn check(
126    when: &When, features: &BTreeMap<FeatureName, BTreeSet<Enabler>>,
127    active: &BTreeSet<&ProfileName>, answers: &BTreeMap<VarName, String>,
128) -> Option<Reason> {
129    if let Some(feature) = when.features.iter().find(|f| !features.contains_key(*f)) {
130        return Some(Reason::Feature(feature.clone()));
131    }
132    if let Some(profile) = when.profiles.iter().find(|p| !active.contains(p)) {
133        return Some(Reason::Profile(profile.clone()));
134    }
135    when.vars.iter().find_map(|(name, values)| {
136        let answer = answers.get(name).map_or("", String::as_str);
137        (!values.iter().any(|value| value == answer))
138            .then(|| Reason::Var { name: name.clone(), answer: answer.to_owned() })
139    })
140}
141
142/// Turns off every file of `layers` whose `features`, `profiles` or `vars` do not hold.
143pub(crate) fn statics(layers: &mut [Layer], answers: &BTreeMap<VarName, String>) {
144    let active: BTreeSet<ProfileName> = layers.iter().map(|layer| layer.name().clone()).collect();
145    let active: BTreeSet<&ProfileName> = active.iter().collect();
146    for layer in layers {
147        let features = layer.features().clone();
148        for file in layer.files_mut().values_mut() {
149            file.gated = check(&file.spec.when, &features, &active, answers);
150        }
151    }
152}
153
154/// What [`settle`] decided: each scaffold, each slot its gates turned off with why, and how each
155/// `exists` came out.
156#[derive(Debug, Default)]
157pub(crate) struct Settled {
158    /// Every decided scaffold, and where it wrote its files.
159    pub scaffolds: BTreeMap<ScaffoldId, Scaffold>,
160    /// Every slot its gates turned off, whether or not it was recorded.
161    pub gated: BTreeMap<Slot, Reason>,
162    /// Each path and glob of each gated slot's `when.exists`, and whether it will exist.
163    pub exists: BTreeMap<Slot, Vec<(String, bool)>>,
164}
165
166/// Settles scaffolds and `exists` for `entries`, as surveyed in `target` against `resolved`:
167/// an entry left off with a record is released, one without is dropped.
168///
169/// # Errors
170/// [`TargetError::NoSuchScaffold`], a scaffold to write again is no active profile's.
171pub(crate) fn settle(
172    entries: &mut Vec<Entry>, resolved: &Resolved, target: &Target,
173) -> Result<Settled> {
174    let disk = Disk::new(target.root());
175    let mut scaffolds = decide(resolved, target, &disk)?;
176    let mut off: Vec<Option<Reason>> = vec![None; entries.len()];
177    let mut gated: Vec<(usize, &[Pattern])> = Vec::new();
178    // Each scaffold file that applies, by entry: its scaffold, and its path as written.
179    let mut scaffolded: Vec<(usize, ScaffoldId, &RelPath)> = Vec::new();
180    for (at, entry) in entries.iter_mut().enumerate() {
181        let (Some(file), Some(profile)) = (resolved.file(entry), resolved.provider(entry)) else {
182            continue;
183        };
184        if let Some(group) = &file.spec.scaffold {
185            let id = ScaffoldId { profile: profile.clone(), group: group.clone() };
186            let reason = match scaffolds.get(&id) {
187                Some(Scaffold { decision: Scaffolded::Found, .. }) => {
188                    Some(Reason::Found(id.clone()))
189                },
190                Some(Scaffold { files, .. }) => files
191                    .get(&file.written)
192                    .filter(|path| **path != entry.path)
193                    .map(|path| Reason::Moved { scaffold: id.clone(), path: path.clone() }),
194                None => None,
195            };
196            if let Some(reason) = reason {
197                if let Some(slot) = off.get_mut(at) {
198                    *slot = Some(reason);
199                }
200                continue;
201            }
202            if target.rescaffolds().contains(&id) {
203                entry.record = None;
204                entry.recorded = None;
205            }
206            scaffolded.push((at, id, &file.written));
207        }
208        if !file.exists.is_empty() {
209            gated.push((at, &file.exists));
210        }
211    }
212    let evidence = Evidence::new(entries, &disk);
213    let mut on: Vec<bool> = off.iter().map(Option::is_none).collect();
214    for &(at, _) in &gated {
215        if let Some(slot) = on.get_mut(at) {
216            *slot = false;
217        }
218    }
219    loop {
220        let turned: Vec<usize> = gated
221            .iter()
222            .filter(|&&(at, _)| !on.get(at).copied().unwrap_or(true))
223            .filter(|&&(at, patterns)| {
224                patterns.iter().all(|pattern| evidence.holds(pattern, &on, at))
225            })
226            .map(|&(at, _)| at)
227            .collect();
228        if turned.is_empty() {
229            break;
230        }
231        for at in turned {
232            if let Some(slot) = on.get_mut(at) {
233                *slot = true;
234            }
235        }
236    }
237    let mut exists = BTreeMap::new();
238    for &(at, patterns) in &gated {
239        let outcomes: Vec<(String, bool)> = patterns
240            .iter()
241            .map(|pattern| (pattern.to_string(), evidence.holds(pattern, &on, at)))
242            .collect();
243        let absent = outcomes.iter().find(|(_, holds)| !holds).map(|(pattern, _)| pattern.clone());
244        if let (Some(slot), Some(absent), false) =
245            (off.get_mut(at), absent, on.get(at).copied().unwrap_or(true))
246        {
247            *slot = Some(Reason::Absent(absent));
248        }
249        if let Some(entry) = entries.get(at) {
250            exists.insert(entry.slot(), outcomes);
251        }
252    }
253    for (at, id, written) in scaffolded {
254        let (Some(true), Some(entry), Some(scaffold)) =
255            (on.get(at).copied(), entries.get(at), scaffolds.get_mut(&id))
256        else {
257            continue;
258        };
259        scaffold.files.insert(written.clone(), entry.path.clone());
260    }
261    let mut settled = Settled { scaffolds, gated: BTreeMap::new(), exists };
262    let mut kept = Vec::with_capacity(entries.len());
263    for (entry, reason) in entries.drain(..).zip(off) {
264        let Some(reason) = reason else {
265            kept.push(entry);
266            continue;
267        };
268        settled.gated.insert(entry.slot(), reason.clone());
269        if entry.record.is_some() {
270            kept.push(Entry { want: None, gate: Some(reason), ..entry });
271        }
272    }
273    *entries = kept;
274    Ok(settled)
275}
276
277/// Every scaffold's decision: recorded, asked to be written again, or decided now against the
278/// disk. A group none of whose files applies yet is left undecided, so a sentinel that appears
279/// before one does still counts.
280fn decide(
281    resolved: &Resolved, target: &Target, disk: &Disk<'_>,
282) -> Result<BTreeMap<ScaffoldId, Scaffold>> {
283    let mut decided = BTreeMap::new();
284    let mut known = Vec::new();
285    for layer in resolved.layers() {
286        for (group, unless) in layer.sentinels() {
287            let id = ScaffoldId { profile: layer.name().clone(), group: group.clone() };
288            known.push(id.to_string());
289            let applies = layer
290                .files()
291                .values()
292                .any(|file| file.gated.is_none() && file.spec.scaffold.as_ref() == Some(group));
293            let rescaffold = target.rescaffolds().contains(&id);
294            let decision = if rescaffold {
295                Scaffold { decision: Scaffolded::Written, files: BTreeMap::new() }
296            } else if let Some(recorded) = target.scaffolds().get(&id) {
297                recorded.clone()
298            } else if !applies {
299                continue;
300            } else {
301                let found = unless.iter().any(|pattern| disk.has(pattern));
302                let decision = if found { Scaffolded::Found } else { Scaffolded::Written };
303                Scaffold { decision, files: BTreeMap::new() }
304            };
305            decided.insert(id, decision);
306        }
307    }
308    if let Some(unknown) = target.rescaffolds().iter().find(|id| !decided.contains_key(*id)) {
309        return Err(
310            TargetError::NoSuchScaffold { name: unknown.to_string(), scaffolds: known }.into()
311        );
312    }
313    Ok(decided)
314}
315
316/// The target's files as found, walked once when a glob needs them.
317struct Disk<'a> {
318    /// The target root.
319    root: &'a Utf8Path,
320    /// Every file no `.gitignore` excludes, `.git` and `.devset` left out.
321    files: OnceCell<Vec<RelPath>>,
322}
323
324impl<'a> Disk<'a> {
325    /// The target at `root`.
326    const fn new(root: &'a Utf8Path) -> Self {
327        Self { root, files: OnceCell::new() }
328    }
329
330    /// Whether `path` is there: a file, or a directory.
331    fn exists(&self, path: &RelPath) -> bool {
332        fs_err::symlink_metadata(path.under(self.root)).is_ok()
333    }
334
335    /// Whether anything is there that `pattern` names.
336    fn has(&self, pattern: &Pattern) -> bool {
337        match pattern {
338            Pattern::Path(path) => self.exists(path),
339            Pattern::Glob { glob, .. } => {
340                self.files().iter().any(|file| glob.is_match(file.as_str()))
341            },
342        }
343    }
344
345    /// Every file no `.gitignore` excludes, in a git repository or not.
346    fn files(&self) -> &[RelPath] {
347        self.files.get_or_init(|| {
348            WalkBuilder::new(self.root)
349                .hidden(false)
350                .require_git(false)
351                .git_global(false)
352                .filter_entry(|entry| !matches!(entry.file_name().to_str(), Some(".git" | DIR)))
353                .build()
354                .flatten()
355                .filter(|entry| entry.file_type().is_some_and(|kind| !kind.is_dir()))
356                .filter_map(|entry| {
357                    let inside = entry.path().strip_prefix(self.root).ok()?;
358                    let inside = Utf8Path::from_path(inside)?;
359                    let joined: Vec<&str> = inside.components().map(|part| part.as_str()).collect();
360                    RelPath::new(&joined.join("/")).ok()
361                })
362                .collect()
363        })
364    }
365}
366
367/// What will exist once the run is done, for an assignment of the gated entries.
368struct Evidence<'a> {
369    /// Every entry, as surveyed.
370    entries: &'a [Entry],
371    /// Each managed path's entries.
372    by_path: BTreeMap<&'a RelPath, Vec<usize>>,
373    /// The disk, for paths no entry manages.
374    disk: &'a Disk<'a>,
375}
376
377impl<'a> Evidence<'a> {
378    /// The evidence of `entries` and `disk`.
379    fn new(entries: &'a [Entry], disk: &'a Disk<'a>) -> Self {
380        let mut by_path: BTreeMap<&RelPath, Vec<usize>> = BTreeMap::new();
381        for (at, entry) in entries.iter().enumerate() {
382            by_path.entry(&entry.path).or_default().push(at);
383        }
384        Self { entries, by_path, disk }
385    }
386
387    /// Whether `pattern` names a path that will exist, each entry applying as `on` says; the
388    /// entry at `own` asks, so its own path is judged without it.
389    fn holds(&self, pattern: &Pattern, on: &[bool], own: usize) -> bool {
390        match pattern {
391            Pattern::Path(path) => self.stays(path, on, own),
392            Pattern::Glob { glob, .. } => {
393                let managed = self.by_path.keys().copied();
394                let found =
395                    self.disk.files().iter().filter(|file| !self.by_path.contains_key(file));
396                managed
397                    .chain(found)
398                    .any(|path| glob.is_match(path.as_str()) && self.stays(path, on, own))
399            },
400        }
401    }
402
403    /// Whether `path` will exist, each entry but `own` applying as `on` says: a file an entry
404    /// keeps or writes, a directory one writes under, or else what is on disk.
405    fn stays(&self, path: &RelPath, on: &[bool], own: usize) -> bool {
406        let at: Vec<usize> = self
407            .by_path
408            .get(path)
409            .map_or_default(|at| at.iter().copied().filter(|&at| at != own).collect());
410        if at.is_empty() {
411            let under = format!("{path}/");
412            let written =
413                self.by_path.keys().filter(|managed| managed.as_str().starts_with(&under));
414            return self.disk.exists(path) || written.into_iter().any(|p| self.stays(p, on, own));
415        }
416        at.iter().any(|&at| {
417            let Some(entry) = self.entries.get(at) else { return false };
418            let applies = on.get(at).copied().unwrap_or(false);
419            let action = if applies {
420                entry.action(Mode::Apply)
421            } else {
422                Entry { want: None, ..entry.clone() }.action(Mode::Apply)
423            };
424            match action {
425                Action::Write | Action::Merge | Action::Record => true,
426                // A part taken out of a file leaves the rest of it.
427                Action::Remove => entry.part.is_some() && entry.present,
428                Action::Keep | Action::Conflict | Action::Untrack => entry.present,
429            }
430        })
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::Pattern;
437
438    #[test]
439    fn patterns() {
440        assert!(matches!(Pattern::parse("docs/book.toml"), Ok(Pattern::Path(_))), "a path");
441        assert!(matches!(Pattern::parse("**/*.proto"), Ok(Pattern::Glob { .. })), "a glob");
442        let Ok(Pattern::Glob { glob, .. }) = Pattern::parse("src/*.rs") else { panic!("a glob") };
443        assert!(
444            glob.is_match("src/lib.rs") && !glob.is_match("src/a/lib.rs"),
445            "`*` stays in a component"
446        );
447        for bad in ["", "../x", "/abs", "a/[b"] {
448            assert!(Pattern::parse(bad).is_err(), "{bad:?} is neither");
449        }
450    }
451}