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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21pub struct SaveReceipt {
22    #[serde(with = "crate::path_serde::option")]
23    origin: Option<PathBuf>,
24    #[serde(with = "crate::path_serde")]
25    target: PathBuf,
26    #[serde(with = "crate::path_serde")]
27    canonical: PathBuf,
28    revision: BufferRevision,
29    stamp: Option<SystemTime>,
30}
31
32impl Buffer {
33    /// Pure adoption of an owned reload's observed binding and on-disk baseline.
34    /// The caller validates resource/request identity and publishes text separately.
35    pub fn adopt_file_binding(&mut self, observed: &Self) {
36        self.path = observed.path.clone();
37        self.file_identity = observed.file_identity.clone();
38        self.disk_stamp = observed.disk_stamp;
39    }
40
41    /// Adopt a confirmed native relocation without changing text, revision or undo.
42    /// `canonical` and `stamp` come from the owned filesystem operation receipt.
43    /// Descendants of a moved directory retain their unchanged baseline when no
44    /// separate file observation was needed.
45    pub fn relocate_file_binding(&mut self, canonical: PathBuf, stamp: Option<SystemTime>) {
46        self.path = Some(canonical.clone());
47        self.file_identity = Some(canonical);
48        self.disk_stamp = stamp.or(self.disk_stamp);
49    }
50
51    /// Detach a removed resource. A later unnamed write must not recreate it.
52    pub fn detach_file_binding(&mut self) {
53        self.path = None;
54        self.file_identity = None;
55        self.disk_stamp = None;
56    }
57
58    pub fn prepare_save(&self, target: Option<PathBuf>, force: bool) -> io::Result<SaveRequest> {
59        // The verified save-authority decision (0057 VF18, crate::mutguard).
60        if !crate::mutguard::save_admitted(self.readonly, force) {
61            return Err(io::Error::new(
62                io::ErrorKind::PermissionDenied,
63                "readonly buffer — :w! to force",
64            ));
65        }
66        let new_name = target.is_some();
67        let target = target.or_else(|| self.path.clone()).ok_or_else(|| {
68            io::Error::new(
69                io::ErrorKind::NotFound,
70                "no file name — :w {path} to name it",
71            )
72        })?;
73        Ok(SaveRequest {
74            text: self.snapshot(),
75            origin: self.path.clone(),
76            target,
77            revision: self.revision(),
78            baseline: self.disk_stamp,
79            new_name,
80            force,
81        })
82    }
83
84    /// A write of an older snapshot may update the on-disk baseline, never clear
85    /// dirty text or rename an edited document. Caller also checks request identity.
86    pub fn accept_save(&mut self, receipt: SaveReceipt) -> bool {
87        if self.path != receipt.origin {
88            return false;
89        }
90        let current = self.acknowledge_saved_revision(receipt.revision);
91        if current {
92            self.path = Some(receipt.target);
93            self.disk_stamp = receipt.stamp;
94            self.file_identity = Some(receipt.canonical);
95        } else if self.path.as_ref() == Some(&receipt.target) {
96            self.disk_stamp = receipt.stamp;
97        }
98        current
99    }
100
101    /// Pure acknowledgment for an externally owned save. The caller validates
102    /// document/request identity; no local filesystem path is created or changed.
103    pub fn acknowledge_saved_revision(&mut self, revision: BufferRevision) -> bool {
104        // The verified retirement rule: only the live revision retires.
105        let current = crate::mutguard::save_ack_is_current(revision.get(), self.revision().get());
106        if current {
107            self.dirty = false;
108        }
109        current
110    }
111}
112
113impl SaveRequest {
114    /// Blocking filesystem work; no editor borrow crosses this boundary.
115    pub fn execute(self) -> io::Result<SaveReceipt> {
116        let target = if self.new_name {
117            self.target.clone()
118        } else {
119            fs::canonicalize(&self.target).unwrap_or_else(|_| self.target.clone())
120        };
121        let current = match fs::metadata(&target) {
122            Ok(metadata) => Some(metadata.modified()?),
123            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
124            Err(error) => return Err(error),
125        };
126        if !self.force && self.new_name && current.is_some() {
127            return Err(io::Error::new(
128                io::ErrorKind::PermissionDenied,
129                "file exists — :w! to overwrite",
130            ));
131        }
132        if !self.force && !self.new_name && current != self.baseline {
133            return Err(io::Error::new(
134                io::ErrorKind::PermissionDenied,
135                "file changed on disk — :w! to force",
136            ));
137        }
138        write_atomic(&target, &self.text, !self.new_name || self.force)?;
139        let stamp = Some(fs::metadata(&target)?.modified()?);
140        let canonical = fs::canonicalize(&target)?;
141        Ok(SaveReceipt {
142            origin: self.origin,
143            target: self.target,
144            canonical,
145            revision: self.revision,
146            stamp,
147        })
148    }
149}
150
151fn write_atomic(target: &Path, contents: &Rope, overwrite: bool) -> io::Result<()> {
152    let parent = target
153        .parent()
154        .filter(|path| !path.as_os_str().is_empty())
155        .unwrap_or_else(|| Path::new("."));
156    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
157    match fs::metadata(target) {
158        Ok(metadata) => temporary
159            .as_file()
160            .set_permissions(metadata.permissions())?,
161        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
162        Err(error) => return Err(error),
163    }
164    for chunk in contents.chunks() {
165        temporary.write_all(chunk.as_bytes())?;
166    }
167    temporary.as_file().sync_all()?;
168    let result = if overwrite {
169        temporary.persist(target)
170    } else {
171        temporary.persist_noclobber(target)
172    };
173    result.map_err(|error| error.error)?;
174    #[cfg(unix)]
175    fs::File::open(parent)?.sync_all()?;
176    Ok(())
177}