Skip to main content

strop_core/buffer/
io.rs

1//! Owned file writes: preparing and accepting are pure; execute belongs on a worker.
2use super::Buffer;
3use crate::id::BufferRevision;
4use ropey::Rope;
5use std::fs;
6use std::io::{self, Write};
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10pub struct SaveRequest {
11    text: Rope,
12    origin: Option<PathBuf>,
13    target: PathBuf,
14    revision: BufferRevision,
15    baseline: Option<SystemTime>,
16    new_name: bool,
17    force: bool,
18}
19
20/// The frozen save plan (0058 WK09): admission evidence decomposed so the
21/// engine can route the write through the worker's Store intent. Carries
22/// the same fields [`SaveRequest::execute`] consumes in-process.
23pub struct SavePlan {
24    pub text: Rope,
25    pub origin: Option<PathBuf>,
26    pub target: PathBuf,
27    pub revision: BufferRevision,
28    pub baseline: Option<SystemTime>,
29    pub new_name: bool,
30    pub force: bool,
31}
32
33#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
34pub struct SaveReceipt {
35    #[serde(with = "crate::path_serde::option")]
36    origin: Option<PathBuf>,
37    #[serde(with = "crate::path_serde")]
38    target: PathBuf,
39    #[serde(with = "crate::path_serde")]
40    canonical: PathBuf,
41    revision: BufferRevision,
42    stamp: Option<SystemTime>,
43}
44
45impl SaveReceipt {
46    /// A worker-served store's receipt (0058 WK09): identical retirement
47    /// evidence to the in-process writer's — [`Buffer::accept_save`]
48    /// cannot tell the difference.
49    pub fn from_store(
50        origin: Option<PathBuf>,
51        target: PathBuf,
52        canonical: PathBuf,
53        revision: BufferRevision,
54        stamp: Option<SystemTime>,
55    ) -> Self {
56        Self {
57            origin,
58            target,
59            canonical,
60            revision,
61            stamp,
62        }
63    }
64}
65
66impl Buffer {
67    /// Pure adoption of an owned reload's observed binding and on-disk baseline.
68    /// The caller validates resource/request identity and publishes text separately.
69    pub fn adopt_file_binding(&mut self, observed: &Self) {
70        self.path = observed.path.clone();
71        self.file_identity = observed.file_identity.clone();
72        self.disk_stamp = observed.disk_stamp;
73    }
74
75    /// Adopt a confirmed native relocation without changing text, revision or undo.
76    /// `canonical` and `stamp` come from the owned filesystem operation receipt.
77    /// Descendants of a moved directory retain their unchanged baseline when no
78    /// separate file observation was needed.
79    pub fn relocate_file_binding(&mut self, canonical: PathBuf, stamp: Option<SystemTime>) {
80        self.path = Some(canonical.clone());
81        self.file_identity = Some(canonical);
82        self.disk_stamp = stamp.or(self.disk_stamp);
83    }
84
85    /// Detach a removed resource. A later unnamed write must not recreate it.
86    pub fn detach_file_binding(&mut self) {
87        self.path = None;
88        self.file_identity = None;
89        self.disk_stamp = None;
90    }
91
92    pub fn prepare_save(&self, target: Option<PathBuf>, force: bool) -> io::Result<SaveRequest> {
93        // The verified save-authority decision (0057 VF18, crate::mutguard).
94        if !crate::mutguard::save_admitted(self.readonly, force) {
95            return Err(io::Error::new(
96                io::ErrorKind::PermissionDenied,
97                "readonly buffer — :w! to force",
98            ));
99        }
100        let new_name = target.is_some();
101        let target = target.or_else(|| self.path.clone()).ok_or_else(|| {
102            io::Error::new(
103                io::ErrorKind::NotFound,
104                "no file name — :w {path} to name it",
105            )
106        })?;
107        Ok(SaveRequest {
108            text: self.snapshot(),
109            origin: self.path.clone(),
110            target,
111            revision: self.revision(),
112            baseline: self.disk_stamp,
113            new_name,
114            force,
115        })
116    }
117
118    /// A write of an older snapshot may update the on-disk baseline, never clear
119    /// dirty text or rename an edited document. Caller also checks request identity.
120    pub fn accept_save(&mut self, receipt: SaveReceipt) -> bool {
121        if self.path != receipt.origin {
122            return false;
123        }
124        let current = self.acknowledge_saved_revision(receipt.revision);
125        if current {
126            self.path = Some(receipt.target);
127            self.disk_stamp = receipt.stamp;
128            self.file_identity = Some(receipt.canonical);
129        } else if self.path.as_ref() == Some(&receipt.target) {
130            self.disk_stamp = receipt.stamp;
131        }
132        current
133    }
134
135    /// Pure acknowledgment for an externally owned save. The caller validates
136    /// document/request identity; no local filesystem path is created or changed.
137    pub fn acknowledge_saved_revision(&mut self, revision: BufferRevision) -> bool {
138        // The verified retirement rule: only the live revision retires.
139        let current = crate::mutguard::save_ack_is_current(revision.get(), self.revision().get());
140        if current {
141            self.dirty = false;
142        }
143        current
144    }
145}
146
147impl SaveRequest {
148    /// Decompose into the frozen plan for a worker-routed store (0058
149    /// WK09). `execute` stays for the in-process consumers.
150    pub fn into_plan(self) -> SavePlan {
151        SavePlan {
152            text: self.text,
153            origin: self.origin,
154            target: self.target,
155            revision: self.revision,
156            baseline: self.baseline,
157            new_name: self.new_name,
158            force: self.force,
159        }
160    }
161    /// Blocking filesystem work; no editor borrow crosses this boundary.
162    pub fn execute(self) -> io::Result<SaveReceipt> {
163        let target = if self.new_name {
164            self.target.clone()
165        } else {
166            fs::canonicalize(&self.target).unwrap_or_else(|_| self.target.clone())
167        };
168        let current = match fs::metadata(&target) {
169            Ok(metadata) => Some(metadata.modified()?),
170            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
171            Err(error) => return Err(error),
172        };
173        if !self.force && self.new_name && current.is_some() {
174            return Err(io::Error::new(
175                io::ErrorKind::PermissionDenied,
176                "file exists — :w! to overwrite",
177            ));
178        }
179        if !self.force && !self.new_name && current != self.baseline {
180            return Err(io::Error::new(
181                io::ErrorKind::PermissionDenied,
182                "file changed on disk — :w! to force",
183            ));
184        }
185        write_atomic(&target, &self.text, !self.new_name || self.force)?;
186        let stamp = Some(fs::metadata(&target)?.modified()?);
187        let canonical = fs::canonicalize(&target)?;
188        Ok(SaveReceipt {
189            origin: self.origin,
190            target: self.target,
191            canonical,
192            revision: self.revision,
193            stamp,
194        })
195    }
196}
197
198fn write_atomic(target: &Path, contents: &Rope, overwrite: bool) -> io::Result<()> {
199    let parent = target
200        .parent()
201        .filter(|path| !path.as_os_str().is_empty())
202        .unwrap_or_else(|| Path::new("."));
203    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
204    match fs::metadata(target) {
205        Ok(metadata) => temporary
206            .as_file()
207            .set_permissions(metadata.permissions())?,
208        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
209        Err(error) => return Err(error),
210    }
211    for chunk in contents.chunks() {
212        temporary.write_all(chunk.as_bytes())?;
213    }
214    temporary.as_file().sync_all()?;
215    let result = if overwrite {
216        temporary.persist(target)
217    } else {
218        temporary.persist_noclobber(target)
219    };
220    result.map_err(|error| error.error)?;
221    #[cfg(unix)]
222    fs::File::open(parent)?.sync_all()?;
223    Ok(())
224}