Skip to main content

sim_table_fs/
dir_edit.rs

1//! Atomic text edits for filesystem-backed table leaves.
2
3use std::{
4    fs::OpenOptions,
5    io::{ErrorKind, Write},
6    path::Path,
7    time::{SystemTime, UNIX_EPOCH},
8};
9
10use sim_kernel::{Cx, Error, Expr, Result, Symbol};
11
12use crate::{
13    FsDir,
14    capabilities::{require_table_fs_edit, require_table_fs_read},
15};
16
17impl FsDir {
18    /// Applies an exact text replacement to a string leaf and atomically writes it.
19    ///
20    /// Requires `fs/read` and `edit`. It does not require `fs/write`.
21    pub fn edit(
22        &self,
23        cx: &mut Cx,
24        key: Symbol,
25        old: &str,
26        new: &str,
27        replace_all: bool,
28    ) -> Result<()> {
29        self.edit_text_leaf(cx, key, |text| apply_edit(text, old, new, replace_all))
30    }
31
32    /// Applies a 1-based inclusive line-range replacement to a string leaf.
33    ///
34    /// Requires `fs/read` and `edit`. It does not require `fs/write`.
35    pub fn edit_lines(
36        &self,
37        cx: &mut Cx,
38        key: Symbol,
39        start: usize,
40        end: usize,
41        new: &str,
42    ) -> Result<()> {
43        self.edit_text_leaf(cx, key, |text| apply_edit_lines(text, start, end, new))
44    }
45
46    fn edit_text_leaf<F>(&self, cx: &mut Cx, key: Symbol, edit: F) -> Result<()>
47    where
48        F: FnOnce(&str) -> Result<String>,
49    {
50        require_table_fs_read(cx)?;
51        require_table_fs_edit(cx)?;
52
53        let (path, ext, expr) = self.read_leaf_expr(cx, &key)?;
54        let Expr::String(text) = expr else {
55            return Err(Error::Eval(format!(
56                "table/fs: dir/edit expects string leaf at {key}"
57            )));
58        };
59        let edited = edit(&text)?;
60        let bytes = FsDir::encode_leaf_expr(cx, ext, &Expr::String(edited))?;
61        self.atomic_write_leaf(&path, &bytes)
62    }
63
64    fn atomic_write_leaf(&self, path: &Path, bytes: &[u8]) -> Result<()> {
65        self.ensure_internal_path(path)?;
66        let parent = path
67            .parent()
68            .ok_or_else(|| Error::Eval("table/fs: edit target has no parent".to_owned()))?;
69        let file_name = path
70            .file_name()
71            .and_then(|name| name.to_str())
72            .ok_or_else(|| Error::Eval("table/fs: edit target has invalid name".to_owned()))?;
73        for attempt in 0..32 {
74            let temp_name = format!(
75                ".{file_name}.edit-{}-{}-{attempt}.tmp",
76                std::process::id(),
77                unique_nanos()
78            );
79            let temp_path = parent.join(temp_name);
80            self.ensure_internal_path(&temp_path)?;
81            match OpenOptions::new()
82                .write(true)
83                .create_new(true)
84                .open(&temp_path)
85            {
86                Ok(mut file) => {
87                    if let Err(err) = file.write_all(bytes) {
88                        let _ = std::fs::remove_file(&temp_path);
89                        return Err(Error::Eval(format!("table/fs: edit write {err}")));
90                    }
91                    if let Err(err) = file.sync_all() {
92                        let _ = std::fs::remove_file(&temp_path);
93                        return Err(Error::Eval(format!("table/fs: edit sync {err}")));
94                    }
95                    drop(file);
96                    if let Err(err) = std::fs::rename(&temp_path, path) {
97                        let _ = std::fs::remove_file(&temp_path);
98                        return Err(Error::Eval(format!("table/fs: edit rename {err}")));
99                    }
100                    return Ok(());
101                }
102                Err(err) if err.kind() == ErrorKind::AlreadyExists => continue,
103                Err(err) => return Err(Error::Eval(format!("table/fs: edit temp {err}"))),
104            }
105        }
106        Err(Error::Eval(
107            "table/fs: edit could not create a temporary file".to_owned(),
108        ))
109    }
110}
111
112fn unique_nanos() -> u128 {
113    SystemTime::now()
114        .duration_since(UNIX_EPOCH)
115        .unwrap_or_default()
116        .as_nanos()
117}
118
119fn apply_edit(text: &str, old: &str, new: &str, replace_all: bool) -> Result<String> {
120    if old.is_empty() {
121        return Err(Error::Eval("edit: old pattern is empty".to_owned()));
122    }
123    let matches = text.matches(old).count();
124    match matches {
125        0 => Err(Error::Eval(format!("edit: pattern not found: {old:?}"))),
126        n if n > 1 && !replace_all => Err(Error::Eval(format!(
127            "edit: pattern is not unique ({n} matches); pass replace_all"
128        ))),
129        _ if replace_all => Ok(text.replace(old, new)),
130        _ => Ok(text.replacen(old, new, 1)),
131    }
132}
133
134fn apply_edit_lines(text: &str, start: usize, end: usize, new: &str) -> Result<String> {
135    if start == 0 {
136        return Err(Error::Eval(
137            "edit-lines: start must be at least 1".to_owned(),
138        ));
139    }
140    if end < start {
141        return Err(Error::Eval(
142            "edit-lines: end must be greater than or equal to start".to_owned(),
143        ));
144    }
145    let lines = text.split_inclusive('\n').collect::<Vec<_>>();
146    if end > lines.len() {
147        return Err(Error::Eval(format!(
148            "edit-lines: range {start}..{end} exceeds {} line(s)",
149            lines.len()
150        )));
151    }
152    let mut edited = String::new();
153    for line in &lines[..start - 1] {
154        edited.push_str(line);
155    }
156    edited.push_str(new);
157    for line in &lines[end..] {
158        edited.push_str(line);
159    }
160    Ok(edited)
161}