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        if self.readonly && !force {
60            return Err(io::Error::new(
61                io::ErrorKind::PermissionDenied,
62                "readonly buffer — :w! to force",
63            ));
64        }
65        let new_name = target.is_some();
66        let target = target.or_else(|| self.path.clone()).ok_or_else(|| {
67            io::Error::new(
68                io::ErrorKind::NotFound,
69                "no file name — :w {path} to name it",
70            )
71        })?;
72        Ok(SaveRequest {
73            text: self.snapshot(),
74            origin: self.path.clone(),
75            target,
76            revision: self.revision(),
77            baseline: self.disk_stamp,
78            new_name,
79            force,
80        })
81    }
82
83    /// A write of an older snapshot may update the on-disk baseline, never clear
84    /// dirty text or rename an edited document. Caller also checks request identity.
85    pub fn accept_save(&mut self, receipt: SaveReceipt) -> bool {
86        if self.path != receipt.origin {
87            return false;
88        }
89        let current = self.acknowledge_saved_revision(receipt.revision);
90        if current {
91            self.path = Some(receipt.target);
92            self.disk_stamp = receipt.stamp;
93            self.file_identity = Some(receipt.canonical);
94        } else if self.path.as_ref() == Some(&receipt.target) {
95            self.disk_stamp = receipt.stamp;
96        }
97        current
98    }
99
100    /// Pure acknowledgment for an externally owned save. The caller validates
101    /// document/request identity; no local filesystem path is created or changed.
102    pub fn acknowledge_saved_revision(&mut self, revision: BufferRevision) -> bool {
103        let current = self.revision() == revision;
104        if current {
105            self.dirty = false;
106        }
107        current
108    }
109}
110
111impl SaveRequest {
112    /// Blocking filesystem work; no editor borrow crosses this boundary.
113    pub fn execute(self) -> io::Result<SaveReceipt> {
114        let target = if self.new_name {
115            self.target.clone()
116        } else {
117            fs::canonicalize(&self.target).unwrap_or_else(|_| self.target.clone())
118        };
119        let current = match fs::metadata(&target) {
120            Ok(metadata) => Some(metadata.modified()?),
121            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
122            Err(error) => return Err(error),
123        };
124        if !self.force && self.new_name && current.is_some() {
125            return Err(io::Error::new(
126                io::ErrorKind::PermissionDenied,
127                "file exists — :w! to overwrite",
128            ));
129        }
130        if !self.force && !self.new_name && current != self.baseline {
131            return Err(io::Error::new(
132                io::ErrorKind::PermissionDenied,
133                "file changed on disk — :w! to force",
134            ));
135        }
136        write_atomic(&target, &self.text, !self.new_name || self.force)?;
137        let stamp = Some(fs::metadata(&target)?.modified()?);
138        let canonical = fs::canonicalize(&target)?;
139        Ok(SaveReceipt {
140            origin: self.origin,
141            target: self.target,
142            canonical,
143            revision: self.revision,
144            stamp,
145        })
146    }
147}
148
149fn write_atomic(target: &Path, contents: &Rope, overwrite: bool) -> io::Result<()> {
150    let parent = target
151        .parent()
152        .filter(|path| !path.as_os_str().is_empty())
153        .unwrap_or_else(|| Path::new("."));
154    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
155    match fs::metadata(target) {
156        Ok(metadata) => temporary
157            .as_file()
158            .set_permissions(metadata.permissions())?,
159        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
160        Err(error) => return Err(error),
161    }
162    for chunk in contents.chunks() {
163        temporary.write_all(chunk.as_bytes())?;
164    }
165    temporary.as_file().sync_all()?;
166    let result = if overwrite {
167        temporary.persist(target)
168    } else {
169        temporary.persist_noclobber(target)
170    };
171    result.map_err(|error| error.error)?;
172    #[cfg(unix)]
173    fs::File::open(parent)?.sync_all()?;
174    Ok(())
175}