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/// The UTF-8 byte order mark some editors put at the start of a file.
9pub const BOM: &str = "\u{FEFF}";
10
11#[derive(Debug, Clone, Serialize)]
12#[serde(tag = "op", rename_all = "lowercase")]
13pub enum Change {
14    Write {
15        path: String,
16        content: String,
17        created: bool,
18    },
19    Move {
20        from: String,
21        to: String,
22    },
23    /// Copies bytes from a source file (attachments during an import).
24    Copy {
25        from: String,
26        to: String,
27    },
28    Remove {
29        path: String,
30    },
31}
32
33#[derive(Debug, Clone, Default, Serialize)]
34pub struct Plan {
35    pub changes: Vec<Change>,
36}
37
38impl Plan {
39    pub fn write(&mut self, path: impl Into<String>, content: String, created: bool) {
40        let path = path.into();
41        self.changes.retain(|c| !matches!(c, Change::Write { path: p, .. } if *p == path));
42        self.changes.push(Change::Write { path, content, created });
43    }
44
45    pub fn mv(&mut self, from: impl Into<String>, to: impl Into<String>) {
46        self.changes.push(Change::Move { from: from.into(), to: to.into() });
47    }
48
49    pub fn copy(&mut self, from: impl Into<String>, to: impl Into<String>) {
50        self.changes.push(Change::Copy { from: from.into(), to: to.into() });
51    }
52
53    pub fn remove(&mut self, path: impl Into<String>) {
54        self.changes.push(Change::Remove { path: path.into() });
55    }
56
57    pub fn is_empty(&self) -> bool {
58        self.changes.is_empty()
59    }
60
61    /// Paths touched, for reports.
62    pub fn paths(&self) -> Vec<String> {
63        self.changes
64            .iter()
65            .map(|c| match c {
66                Change::Write { path, .. } | Change::Remove { path } => path.clone(),
67                Change::Move { from, to } | Change::Copy { from, to } => format!("{from} -> {to}"),
68            })
69            .collect()
70    }
71
72    /// Moves and copies first (so writes land at the new paths), then writes, then removals.
73    pub fn apply(&self, root: &Path) -> Result<()> {
74        let ensure_parent = |p: &Path| -> Result<()> {
75            if let Some(d) = p.parent() {
76                std::fs::create_dir_all(d).with_context(|| format!("cannot create folder {}", d.display()))?;
77            }
78            Ok(())
79        };
80        for c in &self.changes {
81            match c {
82                Change::Move { from, to } => {
83                    let dst = root.join(to);
84                    ensure_parent(&dst)?;
85                    std::fs::rename(root.join(from), &dst).with_context(|| format!("cannot move {from} to {to}"))?;
86                }
87                Change::Copy { from, to } => {
88                    let dst = root.join(to);
89                    ensure_parent(&dst)?;
90                    std::fs::copy(root.join(from), &dst).with_context(|| format!("cannot copy {from} to {to}"))?;
91                }
92                _ => {}
93            }
94        }
95        for c in &self.changes {
96            if let Change::Write { path, content, .. } = c {
97                let dst = root.join(path);
98                ensure_parent(&dst)?;
99                // C154: a file kept with CRLF line endings stays CRLF throughout.
100                let old = std::fs::read(&dst).unwrap_or_default();
101                let crlf = old.windows(2).any(|w| w == b"\r\n");
102                let mut text = if crlf {
103                    std::borrow::Cow::Owned(content.replace("\r\n", "\n").replace('\n', "\r\n"))
104                } else {
105                    std::borrow::Cow::Borrowed(content.as_str())
106                };
107                // C199: a document that starts with a UTF-8 BOM keeps it.
108                if old.starts_with(BOM.as_bytes()) && !text.starts_with(BOM) {
109                    text = std::borrow::Cow::Owned(format!("{BOM}{text}"));
110                }
111                std::fs::write(&dst, text.as_bytes()).with_context(|| format!("cannot write {path}"))?;
112            }
113        }
114        for c in &self.changes {
115            if let Change::Remove { path } = c {
116                let p = root.join(path);
117                if p.is_dir() {
118                    std::fs::remove_dir_all(&p).with_context(|| format!("cannot remove {path}"))?;
119                } else if p.exists() {
120                    std::fs::remove_file(&p).with_context(|| format!("cannot remove {path}"))?;
121                }
122            }
123        }
124        Ok(())
125    }
126}