specfold_core/authoring/
plan.rs1use anyhow::{Context, Result};
5use serde::Serialize;
6use std::path::Path;
7
8pub 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 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 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 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 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 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}