Skip to main content

es_fluent_runner/
transaction.rs

1use crate::RunnerIoError;
2use fs_err as fs;
3use std::path::{Path, PathBuf};
4
5/// A planned filesystem mutation with its expected before and after contents.
6#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)]
7pub struct FileMutation {
8    path: PathBuf,
9    original: Option<Vec<u8>>,
10    replacement: Option<Vec<u8>>,
11}
12
13impl FileMutation {
14    pub fn path(&self) -> &Path {
15        &self.path
16    }
17
18    pub fn original(&self) -> Option<&[u8]> {
19        self.original.as_deref()
20    }
21
22    pub fn replacement(&self) -> Option<&[u8]> {
23        self.replacement.as_deref()
24    }
25}
26
27/// An all-or-nothing set of file and directory mutations.
28#[derive(Clone, Debug, Default, serde::Deserialize, Eq, PartialEq, serde::Serialize)]
29pub struct FileTransaction {
30    mutations: Vec<FileMutation>,
31    #[serde(default)]
32    create_directories: Vec<PathBuf>,
33    #[serde(default)]
34    remove_empty_directories: Vec<PathBuf>,
35}
36
37impl FileTransaction {
38    pub fn is_empty(&self) -> bool {
39        self.mutations.is_empty()
40            && self.create_directories.is_empty()
41            && self.remove_empty_directories.is_empty()
42    }
43
44    pub fn len(&self) -> usize {
45        self.mutations.len() + self.create_directories.len() + self.remove_empty_directories.len()
46    }
47
48    pub fn mutations(&self) -> &[FileMutation] {
49        &self.mutations
50    }
51
52    pub fn plan_write(
53        &mut self,
54        path: impl Into<PathBuf>,
55        replacement: impl Into<Vec<u8>>,
56    ) -> Result<bool, RunnerIoError> {
57        let path = path.into();
58        let original = read_optional_file(&path)?;
59        self.plan_write_from(path, original, replacement)
60    }
61
62    #[doc(hidden)]
63    pub fn plan_write_from(
64        &mut self,
65        path: impl Into<PathBuf>,
66        original: Option<Vec<u8>>,
67        replacement: impl Into<Vec<u8>>,
68    ) -> Result<bool, RunnerIoError> {
69        let path = path.into();
70        let replacement = Some(replacement.into());
71        if original == replacement {
72            return Ok(false);
73        }
74
75        self.add_mutation(FileMutation {
76            path,
77            original,
78            replacement,
79        })?;
80        Ok(true)
81    }
82
83    pub fn plan_remove(&mut self, path: impl Into<PathBuf>) -> Result<bool, RunnerIoError> {
84        let path = path.into();
85        let Some(original) = read_optional_file(&path)? else {
86            return Ok(false);
87        };
88
89        self.add_mutation(FileMutation {
90            path,
91            original: Some(original),
92            replacement: None,
93        })?;
94        Ok(true)
95    }
96
97    pub fn plan_create_directory(
98        &mut self,
99        path: impl Into<PathBuf>,
100    ) -> Result<bool, RunnerIoError> {
101        let path = path.into();
102        match fs::symlink_metadata(&path) {
103            Ok(metadata) if metadata.is_dir() => return Ok(false),
104            Ok(_) => {
105                return Err(std::io::Error::new(
106                    std::io::ErrorKind::AlreadyExists,
107                    format!(
108                        "cannot create directory because the path already exists: {}",
109                        path.display()
110                    ),
111                )
112                .into());
113            },
114            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
115            Err(error) => return Err(error.into()),
116        }
117
118        Ok(self.add_created_directory(path))
119    }
120
121    pub fn plan_remove_empty_directory(&mut self, path: impl Into<PathBuf>) {
122        let path = path.into();
123        if !self
124            .remove_empty_directories
125            .iter()
126            .any(|planned| planned == &path)
127        {
128            self.remove_empty_directories.push(path);
129        }
130    }
131
132    pub fn extend(&mut self, other: Self) -> Result<(), RunnerIoError> {
133        for mutation in other.mutations {
134            self.add_mutation(mutation)?;
135        }
136        for directory in other.create_directories {
137            self.add_created_directory(directory);
138        }
139        for directory in other.remove_empty_directories {
140            self.plan_remove_empty_directory(directory);
141        }
142        Ok(())
143    }
144
145    /// Verifies every before-state and applies the complete mutation set.
146    ///
147    /// If a directory creation, write, or removal fails, already-applied
148    /// mutations are rolled back.
149    pub fn commit(&self) -> Result<bool, RunnerIoError> {
150        self.validate()?;
151        if self.is_empty() {
152            return Ok(false);
153        }
154
155        let mut created_directories = Vec::new();
156        for directory in &self.create_directories {
157            if let Err(error) = create_parent_directories(directory, &mut created_directories) {
158                return Err(rollback_error(error, &[], &[], &created_directories));
159            }
160        }
161
162        for (index, mutation) in self.mutations.iter().enumerate() {
163            if let Err(error) = apply_mutation(mutation, &mut created_directories) {
164                return Err(rollback_error(
165                    error,
166                    &self.mutations[..=index],
167                    &[],
168                    &created_directories,
169                ));
170            }
171        }
172
173        let mut directories = self.remove_empty_directories.clone();
174        directories.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
175        let mut removed_directories = Vec::new();
176        for directory in directories {
177            match remove_directory_if_empty(&directory) {
178                Ok(true) => removed_directories.push(directory),
179                Ok(false) => {},
180                Err(error) => {
181                    return Err(rollback_error(
182                        error,
183                        &self.mutations,
184                        &removed_directories,
185                        &created_directories,
186                    ));
187                },
188            }
189        }
190
191        Ok(true)
192    }
193
194    fn add_mutation(&mut self, mutation: FileMutation) -> Result<(), RunnerIoError> {
195        let Some(existing_index) = self
196            .mutations
197            .iter()
198            .position(|existing| existing.path == mutation.path)
199        else {
200            self.mutations.push(mutation);
201            return Ok(());
202        };
203
204        let existing = &mut self.mutations[existing_index];
205        if existing == &mutation {
206            return Ok(());
207        }
208        if existing.replacement == mutation.original {
209            existing.replacement = mutation.replacement;
210            if existing.original == existing.replacement {
211                self.mutations.remove(existing_index);
212            }
213            return Ok(());
214        }
215
216        Err(RunnerIoError::TransactionConflict {
217            path: mutation.path,
218        })
219    }
220
221    fn add_created_directory(&mut self, path: PathBuf) -> bool {
222        if self
223            .create_directories
224            .iter()
225            .any(|planned| planned == &path)
226        {
227            return false;
228        }
229        self.create_directories.push(path);
230        true
231    }
232
233    fn validate(&self) -> Result<(), RunnerIoError> {
234        for directory in &self.create_directories {
235            if self
236                .mutations
237                .iter()
238                .any(|mutation| mutation.path.as_path() == directory)
239                || self
240                    .remove_empty_directories
241                    .iter()
242                    .any(|removed| removed == directory)
243            {
244                return Err(RunnerIoError::TransactionConflict {
245                    path: directory.clone(),
246                });
247            }
248
249            match fs::symlink_metadata(directory) {
250                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
251                Ok(_) => {
252                    return Err(RunnerIoError::TransactionChanged {
253                        path: directory.clone(),
254                    });
255                },
256                Err(error) => return Err(error.into()),
257            }
258        }
259
260        for mutation in &self.mutations {
261            if mutation.original == mutation.replacement {
262                return Err(RunnerIoError::InvalidRunnerRequest(format!(
263                    "transaction contains an unchanged mutation for {}",
264                    mutation.path.display()
265                )));
266            }
267
268            let current = read_optional_file(&mutation.path)?;
269            if current != mutation.original {
270                return Err(RunnerIoError::TransactionChanged {
271                    path: mutation.path.clone(),
272                });
273            }
274        }
275        Ok(())
276    }
277}
278
279fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>, RunnerIoError> {
280    match fs::read(path) {
281        Ok(contents) => Ok(Some(contents)),
282        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
283        Err(error) => Err(error.into()),
284    }
285}
286
287fn apply_mutation(
288    mutation: &FileMutation,
289    created_directories: &mut Vec<PathBuf>,
290) -> std::io::Result<()> {
291    match &mutation.replacement {
292        Some(replacement) => {
293            if let Some(parent) = mutation.path.parent() {
294                create_parent_directories(parent, created_directories)?;
295            }
296            fs::write(&mutation.path, replacement)
297        },
298        None => fs::remove_file(&mutation.path),
299    }
300}
301
302fn create_parent_directories(
303    parent: &Path,
304    created_directories: &mut Vec<PathBuf>,
305) -> std::io::Result<()> {
306    if parent.as_os_str().is_empty() {
307        return Ok(());
308    }
309
310    let mut missing = Vec::new();
311    for ancestor in parent.ancestors() {
312        if ancestor.exists() {
313            break;
314        }
315        missing.push(ancestor.to_path_buf());
316    }
317
318    missing.reverse();
319    for directory in missing {
320        fs::create_dir(&directory)?;
321        created_directories.push(directory);
322    }
323    Ok(())
324}
325
326fn remove_directory_if_empty(path: &Path) -> std::io::Result<bool> {
327    match fs::read_dir(path) {
328        Ok(mut entries) => {
329            if entries.next().is_some() {
330                return Ok(false);
331            }
332            fs::remove_dir(path)?;
333            Ok(true)
334        },
335        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
336        Err(error) => Err(error),
337    }
338}
339
340fn rollback_error(
341    commit_error: std::io::Error,
342    applied_mutations: &[FileMutation],
343    removed_directories: &[PathBuf],
344    created_directories: &[PathBuf],
345) -> RunnerIoError {
346    let rollback = rollback(applied_mutations, removed_directories, created_directories);
347    match rollback {
348        Ok(()) => RunnerIoError::TransactionCommit(commit_error.to_string()),
349        Err(rollback_error) => RunnerIoError::TransactionRollback {
350            commit_error: commit_error.to_string(),
351            rollback_error: rollback_error.to_string(),
352        },
353    }
354}
355
356fn rollback(
357    applied_mutations: &[FileMutation],
358    removed_directories: &[PathBuf],
359    created_directories: &[PathBuf],
360) -> std::io::Result<()> {
361    for directory in removed_directories.iter().rev() {
362        fs::create_dir_all(directory)?;
363    }
364
365    for mutation in applied_mutations.iter().rev() {
366        match &mutation.original {
367            Some(original) => {
368                if let Some(parent) = mutation.path.parent() {
369                    fs::create_dir_all(parent)?;
370                }
371                fs::write(&mutation.path, original)?;
372            },
373            None => match fs::remove_file(&mutation.path) {
374                Ok(()) => {},
375                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
376                Err(error) => return Err(error),
377            },
378        }
379    }
380
381    let mut created_directories = created_directories.to_vec();
382    created_directories.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
383    for directory in created_directories {
384        match fs::remove_dir(&directory) {
385            Ok(()) => {},
386            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
387            Err(error) => return Err(error),
388        }
389    }
390
391    Ok(())
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn transaction_round_trips_through_json() {
400        let temp = tempfile::tempdir().expect("tempdir");
401        let path = temp.path().join("demo.ftl");
402        fs::write(&path, "old = Old\n").expect("write original");
403        let mut transaction = FileTransaction::default();
404        transaction
405            .plan_write(&path, b"new = New\n".to_vec())
406            .expect("plan write");
407        transaction
408            .plan_create_directory(temp.path().join("empty-locale"))
409            .expect("plan directory");
410
411        let encoded = serde_json::to_string(&transaction).expect("encode");
412        let decoded: FileTransaction = serde_json::from_str(&encoded).expect("decode");
413
414        assert_eq!(decoded, transaction);
415    }
416
417    #[test]
418    fn transaction_creates_an_empty_directory() {
419        let temp = tempfile::tempdir().expect("tempdir");
420        let directory = temp.path().join("empty-locale");
421        let mut transaction = FileTransaction::default();
422        transaction
423            .plan_create_directory(&directory)
424            .expect("plan directory");
425
426        assert!(transaction.commit().expect("commit"));
427        assert!(directory.is_dir());
428    }
429
430    #[test]
431    fn transaction_rejects_a_directory_created_after_planning() {
432        let temp = tempfile::tempdir().expect("tempdir");
433        let directory = temp.path().join("empty-locale");
434        let mut transaction = FileTransaction::default();
435        transaction
436            .plan_create_directory(&directory)
437            .expect("plan directory");
438        fs::create_dir(&directory).expect("external directory creation");
439
440        let error = transaction.commit().expect_err("commit should fail");
441
442        assert!(matches!(
443            error,
444            RunnerIoError::TransactionChanged { path: changed } if changed == directory
445        ));
446        assert!(directory.is_dir());
447    }
448
449    #[test]
450    fn transaction_rejects_a_file_and_directory_at_the_same_path() {
451        let temp = tempfile::tempdir().expect("tempdir");
452        let path = temp.path().join("conflict");
453        let mut transaction = FileTransaction::default();
454        transaction
455            .plan_create_directory(&path)
456            .expect("plan directory");
457        transaction
458            .plan_write(&path, b"message = Message\n".to_vec())
459            .expect("plan file");
460
461        let error = transaction.commit().expect_err("commit should fail");
462
463        assert!(matches!(
464            error,
465            RunnerIoError::TransactionConflict { path: conflict } if conflict == path
466        ));
467        assert!(!path.exists());
468    }
469
470    #[test]
471    fn transaction_extend_preserves_a_directory_before_state() {
472        let temp = tempfile::tempdir().expect("tempdir");
473        let directory = temp.path().join("empty-locale");
474        let mut planned = FileTransaction::default();
475        planned
476            .plan_create_directory(&directory)
477            .expect("plan directory");
478        let mut transaction = FileTransaction::default();
479        transaction.extend(planned).expect("extend transaction");
480        fs::create_dir(&directory).expect("external directory creation");
481
482        let error = transaction.commit().expect_err("commit should fail");
483
484        assert!(matches!(
485            error,
486            RunnerIoError::TransactionChanged { path: changed } if changed == directory
487        ));
488        assert!(directory.is_dir());
489    }
490
491    #[test]
492    fn transaction_rejects_files_changed_after_planning() {
493        let temp = tempfile::tempdir().expect("tempdir");
494        let path = temp.path().join("demo.ftl");
495        fs::write(&path, "old = Old\n").expect("write original");
496        let mut transaction = FileTransaction::default();
497        transaction
498            .plan_write(&path, b"new = New\n".to_vec())
499            .expect("plan write");
500        fs::write(&path, "external = Edit\n").expect("write external edit");
501
502        let error = transaction.commit().expect_err("commit should fail");
503
504        assert!(matches!(
505            error,
506            RunnerIoError::TransactionChanged { path: changed } if changed == path
507        ));
508        assert_eq!(
509            fs::read_to_string(&path).expect("read external edit"),
510            "external = Edit\n"
511        );
512    }
513
514    #[test]
515    #[cfg(unix)]
516    fn transaction_rolls_back_when_a_later_write_fails() {
517        use std::os::unix::fs::PermissionsExt as _;
518
519        let temp = tempfile::tempdir().expect("tempdir");
520        let first = temp.path().join("first.ftl");
521        let blocked_parent = temp.path().join("blocked");
522        fs::create_dir(&blocked_parent).expect("create blocked directory");
523        let second = blocked_parent.join("second.ftl");
524
525        let mut transaction = FileTransaction::default();
526        transaction
527            .plan_write(&first, b"first = First\n".to_vec())
528            .expect("plan first");
529        transaction
530            .plan_write(&second, b"second = Second\n".to_vec())
531            .expect("plan second");
532        fs::set_permissions(&blocked_parent, std::fs::Permissions::from_mode(0o555))
533            .expect("make blocked directory read-only");
534
535        let error = transaction.commit().expect_err("commit should fail");
536
537        fs::set_permissions(&blocked_parent, std::fs::Permissions::from_mode(0o755))
538            .expect("restore blocked directory permissions");
539        assert!(matches!(error, RunnerIoError::TransactionCommit(_)));
540        assert!(!first.exists(), "the first write must be rolled back");
541        assert!(!second.exists());
542    }
543
544    #[test]
545    #[cfg(unix)]
546    fn transaction_rolls_back_an_empty_directory_when_a_later_write_fails() {
547        use std::os::unix::fs::PermissionsExt as _;
548
549        let temp = tempfile::tempdir().expect("tempdir");
550        let empty_locale = temp.path().join("empty-locale");
551        let blocked_parent = temp.path().join("blocked");
552        fs::create_dir(&blocked_parent).expect("create blocked directory");
553        let blocked_file = blocked_parent.join("message.ftl");
554
555        let mut transaction = FileTransaction::default();
556        transaction
557            .plan_create_directory(&empty_locale)
558            .expect("plan directory");
559        transaction
560            .plan_write(&blocked_file, b"message = Message\n".to_vec())
561            .expect("plan file");
562        fs::set_permissions(&blocked_parent, std::fs::Permissions::from_mode(0o555))
563            .expect("make blocked directory read-only");
564
565        let error = transaction.commit().expect_err("commit should fail");
566
567        fs::set_permissions(&blocked_parent, std::fs::Permissions::from_mode(0o755))
568            .expect("restore blocked directory permissions");
569        assert!(matches!(error, RunnerIoError::TransactionCommit(_)));
570        assert!(
571            !empty_locale.exists(),
572            "the empty locale must be rolled back"
573        );
574        assert!(!blocked_file.exists());
575    }
576
577    #[test]
578    fn transaction_merges_sequential_mutations_for_one_path() {
579        let temp = tempfile::tempdir().expect("tempdir");
580        let path = temp.path().join("demo.ftl");
581        fs::write(&path, "one").expect("write original");
582
583        let mut transaction = FileTransaction::default();
584        transaction
585            .add_mutation(FileMutation {
586                path: path.clone(),
587                original: Some(b"one".to_vec()),
588                replacement: Some(b"two".to_vec()),
589            })
590            .expect("add first");
591        transaction
592            .add_mutation(FileMutation {
593                path: path.clone(),
594                original: Some(b"two".to_vec()),
595                replacement: Some(b"three".to_vec()),
596            })
597            .expect("add second");
598
599        transaction.commit().expect("commit");
600        assert_eq!(fs::read_to_string(path).expect("read final"), "three");
601    }
602}