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
14struct Candidate<'a> {
16 spec: &'a FileSpec,
17 content: Vec<u8>,
18 current: Option<FileEntry>,
19}
20
21type CandidateMap<'a> = BTreeMap<RepositoryPath, Candidate<'a>>;
22type ExpectedUpdate = (RepositoryPath, Option<Vec<u8>>, Option<Vec<u8>>);
25
26#[derive(Default)]
28struct Leftovers {
29 generated: BTreeSet<RepositoryPath>,
30 foreign: BTreeSet<RepositoryPath>,
31}
32
33#[derive(Default)]
35struct Guards {
36 preconditions: Vec<Precondition>,
37 dirty: Vec<RepositoryPath>,
38}
39
40impl Guards {
41 fn existing(
44 &mut self,
45 repo: &Repository,
46 path: &RepositoryPath,
47 current: FileEntry,
48 ) -> Result<(), FilesError> {
49 if !repo.is_recoverable(path, ¤t) {
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#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum Operation {
66 Create {
68 path: RepositoryPath,
70 entry: FileEntry,
72 },
73 Replace {
75 path: RepositoryPath,
77 entry: FileEntry,
79 },
80 Delete {
82 path: RepositoryPath,
84 },
85}
86
87#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum DirtyFiles {
101 Refuse,
103 Overwrite,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub(crate) enum Precondition {
110 Missing(RepositoryPath),
112 Existing {
114 path: RepositoryPath,
115 entry: FileEntry,
116 },
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub(crate) enum PlanOrigin {
122 Repository,
124 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 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 #[must_use]
344 pub fn operations(&self) -> &[Operation] {
345 &self.operations
346 }
347
348 #[must_use]
352 pub fn dirty_paths(&self) -> &[RepositoryPath] {
353 &self.dirty
354 }
355
356 #[must_use]
358 pub fn is_dirty(&self, operation: &Operation) -> bool {
359 self.dirty.iter().any(|path| path == operation.path())
360 }
361
362 #[must_use]
366 pub fn foreign_paths(&self) -> &[RepositoryPath] {
367 &self.foreign
368 }
369
370 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 pub fn for_repository(root: &RepositoryRoot, state: &FileState) -> Result<Self, FilesError> {
390 Planner::new(root, state).real()
391 }
392
393 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 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;