Skip to main content

ephemeris_files/
planner.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::iter;
3use std::path::PathBuf;
4
5use super::entry::{FileEntry, FileMode};
6use super::error::IoResultExt as _;
7use super::path::{RepositoryPath, RepositoryRoot};
8use super::path_policy::PathPolicy;
9use super::repository::Repository;
10use super::walk::files_below;
11use crate::state::{FileSpec, FormatterSpec};
12use crate::{FileState, FilesError, PathError};
13
14/// A desired file with its reconciled content and what the file holds now.
15struct Candidate<'a> {
16    spec: &'a FileSpec,
17    content: Vec<u8>,
18    current: Option<FileEntry>,
19}
20
21type CandidateMap<'a> = BTreeMap<RepositoryPath, Candidate<'a>>;
22/// One caller-supplied `(path, expected content, replacement content)` entry
23/// for [`Plan::for_expected_updates`].
24type ExpectedUpdate = (RepositoryPath, Option<Vec<u8>>, Option<Vec<u8>>);
25
26/// Tracked paths that no declared file explains.
27#[derive(Default)]
28struct Leftovers {
29    generated: BTreeSet<RepositoryPath>,
30    foreign: BTreeSet<RepositoryPath>,
31}
32
33/// The preconditions and dirty paths collected while planning.
34#[derive(Default)]
35struct Guards {
36    preconditions: Vec<Precondition>,
37    dirty: Vec<RepositoryPath>,
38}
39
40impl Guards {
41    /// Record that `path` must still hold `current`, and whether the
42    /// repository can recover it.
43    fn existing(
44        &mut self,
45        repo: &Repository,
46        path: &RepositoryPath,
47        current: FileEntry,
48    ) -> Result<(), FilesError> {
49        if !repo.is_recoverable(path, &current) {
50            if current.mode == FileMode::Symlink {
51                return Err(PathError::Symlink(path.as_path().to_path_buf()).into());
52            }
53            self.dirty.push(path.clone());
54        }
55        self.preconditions.push(Precondition::Existing {
56            path: path.clone(),
57            entry: current,
58        });
59        Ok(())
60    }
61}
62
63/// One filesystem change a [`Plan`] will make when applied.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum Operation {
66    /// Create a file that does not exist yet.
67    Create {
68        /// The repository-relative path to create.
69        path: RepositoryPath,
70        /// The file's full content and Git mode.
71        entry: FileEntry,
72    },
73    /// Replace an existing file's content and mode.
74    Replace {
75        /// The repository-relative path to replace.
76        path: RepositoryPath,
77        /// The file's full replacement content and new Git mode.
78        entry: FileEntry,
79    },
80    /// Delete a file that is no longer desired.
81    Delete {
82        /// The repository-relative path to delete.
83        path: RepositoryPath,
84    },
85}
86
87/// The filesystem changes needed to reach a desired file state.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Plan {
90    operations: Vec<Operation>,
91    pub(crate) preconditions: Vec<Precondition>,
92    dirty: Vec<RepositoryPath>,
93    foreign: Vec<RepositoryPath>,
94    pub(super) root: PathBuf,
95    pub(super) origin: PlanOrigin,
96}
97
98/// How [`Plan::apply`] treats paths whose current state Git cannot recover.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum DirtyFiles {
101    /// Fail before any write when the plan touches a dirty path.
102    Refuse,
103    /// Replace or delete dirty paths.
104    Overwrite,
105}
106
107/// A condition [`Plan::apply`] checks before making any change.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub(crate) enum Precondition {
110    /// The path must not exist yet.
111    Missing(RepositoryPath),
112    /// The path must currently hold exactly this entry.
113    Existing {
114        path: RepositoryPath,
115        entry: FileEntry,
116    },
117}
118
119/// What a [`Plan`] was built against.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub(crate) enum PlanOrigin {
122    /// A live Git worktree, which [`Plan::apply`] may mutate.
123    Repository,
124    /// A read-only snapshot, which [`Plan::apply`] always rejects.
125    Snapshot,
126}
127
128struct Planner<'a> {
129    root: &'a RepositoryRoot,
130    state: &'a FileState,
131}
132impl<'a> Planner<'a> {
133    const fn new(root: &'a RepositoryRoot, state: &'a FileState) -> Self {
134        Self { root, state }
135    }
136    fn desired(
137        &self,
138        policy: &PathPolicy<'_>,
139    ) -> Result<BTreeMap<RepositoryPath, &FileSpec>, FilesError> {
140        let mut desired = BTreeMap::new();
141        for (path, spec) in &self.state.files {
142            spec.validate(path)?;
143            let policy_managed = policy.managed(path)?;
144            if spec.is_partial() || policy_managed {
145                desired.insert(path.clone(), spec);
146            }
147        }
148        Ok(desired)
149    }
150    fn real(&self) -> Result<Plan, FilesError> {
151        let policy = PathPolicy::new(&self.state.path_rules)?;
152        let desired = self.desired(&policy)?;
153        let repo = Repository::open(self.root.as_path())?;
154        let tracked = repo.tracked()?;
155        self.build(
156            PlanOrigin::Repository,
157            &policy,
158            &desired,
159            &tracked,
160            Some(&repo),
161        )
162    }
163    fn snapshot(&self) -> Result<Plan, FilesError> {
164        let policy = PathPolicy::new(&self.state.path_rules)?;
165        let desired = self.desired(&policy)?;
166        let present = files_below(self.root)?;
167        self.build(PlanOrigin::Snapshot, &policy, &desired, &present, None)
168    }
169    fn build(
170        &self,
171        origin: PlanOrigin,
172        policy: &PathPolicy<'_>,
173        desired: &BTreeMap<RepositoryPath, &FileSpec>,
174        tracked: &BTreeSet<RepositoryPath>,
175        repo: Option<&Repository>,
176    ) -> Result<Plan, FilesError> {
177        let leftovers = self.leftovers(policy, desired, tracked)?;
178        RepositoryPath::reject_case_collisions(
179            desired
180                .keys()
181                .chain(leftovers.generated.iter())
182                .chain(leftovers.foreign.iter()),
183        )?;
184        let mut operations = Vec::new();
185        let mut guards = Guards::default();
186        let candidates = self.candidates(desired)?;
187        let profile_inputs = self.formatter_profiles(&candidates)?;
188
189        for (
190            path,
191            Candidate {
192                spec,
193                mut content,
194                current: current_entry,
195            },
196        ) in candidates
197        {
198            if !spec.is_partial()
199                && let Some(formatter) = spec.formatter(self.state.formatter.as_ref())
200                && let Some(inputs) = profile_inputs.get(formatter)
201            {
202                content = super::formatting::format(&path, &content, formatter, inputs)?;
203            }
204            let wanted = FileEntry {
205                content,
206                mode: spec.mode(),
207            };
208            let Some(current) = current_entry else {
209                guards
210                    .preconditions
211                    .push(Precondition::Missing(path.clone()));
212                operations.push(Operation::Create {
213                    path,
214                    entry: wanted,
215                });
216                continue;
217            };
218            if current == wanted {
219                continue;
220            }
221            if let Some(repo) = repo {
222                guards.existing(repo, &path, current)?;
223            }
224            operations.push(Operation::Replace {
225                path,
226                entry: wanted,
227            });
228        }
229        for path in &leftovers.generated {
230            let Some(current) = Repository::current_entry(self.root.as_path(), path)? else {
231                continue;
232            };
233            if let Some(repo) = repo {
234                guards.existing(repo, path, current)?;
235            }
236            operations.push(Operation::Delete { path: path.clone() });
237        }
238        Ok(Plan {
239            operations,
240            preconditions: guards.preconditions,
241            dirty: guards.dirty,
242            foreign: leftovers.foreign.into_iter().collect(),
243            root: self.root.as_path().to_path_buf(),
244            origin,
245        })
246    }
247
248    /// Sort the tracked paths that are not desired. Files that carry the
249    /// generated header are stale. Other files in a managed path are foreign.
250    /// Paths the state declares are never leftovers.
251    fn leftovers(
252        &self,
253        policy: &PathPolicy<'_>,
254        desired: &BTreeMap<RepositoryPath, &FileSpec>,
255        tracked: &BTreeSet<RepositoryPath>,
256    ) -> Result<Leftovers, FilesError> {
257        let mut leftovers = Leftovers::default();
258        for path in tracked {
259            if desired.contains_key(path) || self.state.files.contains_key(path) {
260                continue;
261            }
262            let Some(leading) = Repository::leading_bytes(self.root.as_path(), path)? else {
263                continue;
264            };
265            if ephemeris_documents::is_generated(&leading) {
266                leftovers.generated.insert(path.clone());
267            } else if policy.managed(path)? {
268                leftovers.foreign.insert(path.clone());
269            }
270        }
271        Ok(leftovers)
272    }
273
274    fn candidates(
275        &self,
276        desired: &BTreeMap<RepositoryPath, &'a FileSpec>,
277    ) -> Result<CandidateMap<'a>, FilesError> {
278        let mut candidates = BTreeMap::new();
279        for (path, &spec) in desired {
280            let current = Repository::current_entry(self.root.as_path(), path)?;
281            if current
282                .as_ref()
283                .is_some_and(|entry| entry.mode == FileMode::Symlink)
284                && spec.is_partial()
285            {
286                return Err(PathError::Symlink(path.as_path().to_path_buf()).into());
287            }
288            let existing = current
289                .as_ref()
290                .filter(|entry| entry.mode != FileMode::Symlink)
291                .map(|entry| entry.content.as_slice());
292            let content = spec.reconciled_content(path, existing)?;
293            candidates.insert(
294                path.clone(),
295                Candidate {
296                    spec,
297                    content,
298                    current,
299                },
300            );
301        }
302        Ok(candidates)
303    }
304
305    fn formatter_profiles(
306        &self,
307        candidates: &CandidateMap<'_>,
308    ) -> Result<BTreeMap<FormatterSpec, BTreeMap<RepositoryPath, Vec<u8>>>, FilesError> {
309        let mut profiles = BTreeMap::new();
310        for spec in candidates.values().map(|candidate| candidate.spec) {
311            if spec.is_partial() {
312                continue;
313            }
314            let Some(formatter) = spec.formatter(self.state.formatter.as_ref()) else {
315                continue;
316            };
317            if profiles.contains_key(formatter) {
318                continue;
319            }
320            let mut inputs = BTreeMap::new();
321            for relative in formatter
322                .inputs
323                .iter()
324                .chain(iter::once(&formatter.project_root_file))
325            {
326                if let Some(candidate) = candidates.get(relative) {
327                    inputs.insert(relative.clone(), candidate.content.clone());
328                    continue;
329                }
330                relative.resolve(self.root)?;
331                if let Some(entry) = Repository::current_entry(self.root.as_path(), relative)? {
332                    inputs.insert(relative.clone(), entry.content);
333                }
334            }
335            profiles.insert(formatter.clone(), inputs);
336        }
337        Ok(profiles)
338    }
339}
340
341impl Plan {
342    /// Return the operations this plan will execute.
343    #[must_use]
344    pub fn operations(&self) -> &[Operation] {
345        &self.operations
346    }
347
348    /// Return the planned paths whose current state Git cannot recover.
349    ///
350    /// Only plans from [`Plan::for_repository`] can have dirty paths.
351    #[must_use]
352    pub fn dirty_paths(&self) -> &[RepositoryPath] {
353        &self.dirty
354    }
355
356    /// Whether `operation` touches a path in [`Plan::dirty_paths`].
357    #[must_use]
358    pub fn is_dirty(&self, operation: &Operation) -> bool {
359        self.dirty.iter().any(|path| path == operation.path())
360    }
361
362    /// Return the tracked paths in managed paths that Ephemeris neither
363    /// generates nor marked as generated. [`Plan::apply`] refuses a plan that
364    /// has any.
365    #[must_use]
366    pub fn foreign_paths(&self) -> &[RepositoryPath] {
367        &self.foreign
368    }
369
370    /// Return the content a planned path holds in the tree the plan was
371    /// built against, or `None` when the path does not exist there.
372    ///
373    /// # Errors
374    ///
375    /// Returns an error when the path cannot be read.
376    pub fn current_content(&self, path: &RepositoryPath) -> Result<Option<Vec<u8>>, FilesError> {
377        Ok(Repository::current_entry(&self.root, path)?.map(|entry| entry.content))
378    }
379
380    /// Plan changes against a live Git worktree.
381    ///
382    /// Paths that would overwrite unrecoverable worktree data are recorded
383    /// in [`Plan::dirty_paths`]. [`Plan::apply`] decides what to do with them.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error when the desired state is invalid, Git cannot be
388    /// inspected, or a planned path is a symlink.
389    pub fn for_repository(root: &RepositoryRoot, state: &FileState) -> Result<Self, FilesError> {
390        Planner::new(root, state).real()
391    }
392
393    /// Plan a fixed set of whole-file replacements using caller-supplied
394    /// expected content as preconditions, instead of reading the repository
395    /// to discover what each path currently holds.
396    ///
397    /// Each entry is `(path, expected, replacement)`. `expected = None` means
398    /// the path must not exist yet; `expected = Some(bytes)` means the path
399    /// must currently hold exactly those bytes. `replacement = None` records
400    /// a read-only precondition: [`Plan::apply`] checks it but writes
401    /// nothing for that path. This lets a caller capture expected content
402    /// once, during planning, and have [`Plan::apply`] reject the whole plan
403    /// if any of it changed before the plan was applied.
404    ///
405    /// # Errors
406    ///
407    /// Returns an error for invalid or duplicate paths, or a path that is
408    /// unexpectedly a symlink.
409    pub fn for_expected_updates(
410        root: &RepositoryRoot,
411        entries: &[ExpectedUpdate],
412    ) -> Result<Self, FilesError> {
413        let repository = Repository::open(root.as_path())?;
414        let mut operations = Vec::new();
415        let mut preconditions = Vec::with_capacity(entries.len());
416        let mut seen = BTreeSet::new();
417        for (relative, expected, replacement) in entries {
418            let full = relative.resolve(root)?;
419            if !seen.insert(relative.clone()) {
420                return Err(FilesError::DuplicatePath(relative.clone()));
421            }
422            if let Some(content) = expected {
423                let mode = FileMode::from_metadata(&full.symlink_metadata().at_path(&full)?);
424                let existing = FileEntry {
425                    content: content.clone(),
426                    mode,
427                };
428                if replacement.is_some() {
429                    repository.ensure_recoverable(relative, &existing)?;
430                }
431                preconditions.push(Precondition::Existing {
432                    path: relative.clone(),
433                    entry: existing,
434                });
435                if let Some(replacement) = replacement {
436                    operations.push(Operation::Replace {
437                        path: relative.clone(),
438                        entry: FileEntry {
439                            content: replacement.clone(),
440                            mode,
441                        },
442                    });
443                }
444            } else {
445                preconditions.push(Precondition::Missing(relative.clone()));
446                if let Some(replacement) = replacement {
447                    operations.push(Operation::Create {
448                        path: relative.clone(),
449                        entry: FileEntry {
450                            content: replacement.clone(),
451                            mode: FileMode::Regular,
452                        },
453                    });
454                }
455            }
456        }
457        Ok(Self {
458            operations,
459            preconditions,
460            dirty: Vec::new(),
461            foreign: Vec::new(),
462            root: root.as_path().to_path_buf(),
463            origin: PlanOrigin::Repository,
464        })
465    }
466
467    /// Plan changes against a read-only source snapshot.
468    ///
469    /// # Errors
470    ///
471    /// Returns an error when the desired state or source snapshot is invalid.
472    pub fn for_snapshot(root: &RepositoryRoot, state: &FileState) -> Result<Self, FilesError> {
473        Planner::new(root, state).snapshot()
474    }
475}
476
477#[cfg(test)]
478mod tests;