Skip to main content

specfold_core/authoring/
plan.rs

1//! Every write is planned first and applied only on request, so a preview and the real run do
2//! exactly the same thing.
3
4use anyhow::{Context, Result};
5use serde::Serialize;
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize)]
9#[serde(tag = "op", rename_all = "lowercase")]
10pub enum Change {
11    Write {
12        path: String,
13        content: String,
14        created: bool,
15    },
16    Move {
17        from: String,
18        to: String,
19    },
20    /// Copies bytes from a source file (attachments during an import).
21    Copy {
22        from: String,
23        to: String,
24    },
25    Remove {
26        path: String,
27    },
28}
29
30#[derive(Debug, Clone, Default, Serialize)]
31pub struct Plan {
32    pub changes: Vec<Change>,
33}
34
35impl Plan {
36    pub fn write(&mut self, path: impl Into<String>, content: String, created: bool) {
37        let path = path.into();
38        self.changes.retain(|c| !matches!(c, Change::Write { path: p, .. } if *p == path));
39        self.changes.push(Change::Write { path, content, created });
40    }
41
42    pub fn mv(&mut self, from: impl Into<String>, to: impl Into<String>) {
43        self.changes.push(Change::Move { from: from.into(), to: to.into() });
44    }
45
46    pub fn copy(&mut self, from: impl Into<String>, to: impl Into<String>) {
47        self.changes.push(Change::Copy { from: from.into(), to: to.into() });
48    }
49
50    pub fn remove(&mut self, path: impl Into<String>) {
51        self.changes.push(Change::Remove { path: path.into() });
52    }
53
54    pub fn is_empty(&self) -> bool {
55        self.changes.is_empty()
56    }
57
58    /// Paths touched, for reports.
59    pub fn paths(&self) -> Vec<String> {
60        self.changes
61            .iter()
62            .map(|c| match c {
63                Change::Write { path, .. } | Change::Remove { path } => path.clone(),
64                Change::Move { from, to } | Change::Copy { from, to } => format!("{from} -> {to}"),
65            })
66            .collect()
67    }
68
69    /// Moves and copies first (so writes land at the new paths), then writes, then removals.
70    pub fn apply(&self, root: &Path) -> Result<()> {
71        let ensure_parent = |p: &Path| -> Result<()> {
72            if let Some(d) = p.parent() {
73                std::fs::create_dir_all(d)?;
74            }
75            Ok(())
76        };
77        for c in &self.changes {
78            match c {
79                Change::Move { from, to } => {
80                    let dst = root.join(to);
81                    ensure_parent(&dst)?;
82                    std::fs::rename(root.join(from), &dst).with_context(|| format!("cannot move {from} to {to}"))?;
83                }
84                Change::Copy { from, to } => {
85                    let dst = root.join(to);
86                    ensure_parent(&dst)?;
87                    std::fs::copy(root.join(from), &dst).with_context(|| format!("cannot copy {from} to {to}"))?;
88                }
89                _ => {}
90            }
91        }
92        for c in &self.changes {
93            if let Change::Write { path, content, .. } = c {
94                let dst = root.join(path);
95                ensure_parent(&dst)?;
96                std::fs::write(&dst, content).with_context(|| format!("cannot write {path}"))?;
97            }
98        }
99        for c in &self.changes {
100            if let Change::Remove { path } = c {
101                let p = root.join(path);
102                if p.is_dir() {
103                    std::fs::remove_dir_all(&p).with_context(|| format!("cannot remove {path}"))?;
104                } else if p.exists() {
105                    std::fs::remove_file(&p).with_context(|| format!("cannot remove {path}"))?;
106                }
107            }
108        }
109        Ok(())
110    }
111}