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, require_table_fs_write},
15};
16
17impl FsDir {
18    /// Applies an exact text replacement to a string leaf and atomically writes it.
19    ///
20    /// Requires `fs/read`, `fs/write`, and `edit`.
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`, `fs/write`, and `edit`.
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_write(cx)?;
52        require_table_fs_edit(cx)?;
53
54        let (path, ext, expr) = self.read_leaf_expr(cx, &key)?;
55        let Expr::String(text) = expr else {
56            return Err(Error::Eval(format!(
57                "table/fs: dir/edit expects string leaf at {key}"
58            )));
59        };
60        let edited = edit(&text)?;
61        let bytes = FsDir::encode_leaf_expr(cx, ext, &Expr::String(edited))?;
62        self.atomic_write_leaf(&path, &bytes)
63    }
64
65    fn atomic_write_leaf(&self, path: &Path, bytes: &[u8]) -> Result<()> {
66        self.ensure_internal_path(path)?;
67        let parent = path
68            .parent()
69            .ok_or_else(|| Error::Eval("table/fs: edit target has no parent".to_owned()))?;
70        let file_name = path
71            .file_name()
72            .and_then(|name| name.to_str())
73            .ok_or_else(|| Error::Eval("table/fs: edit target has invalid name".to_owned()))?;
74        for attempt in 0..32 {
75            let temp_name = format!(
76                ".{file_name}.edit-{}-{}-{attempt}.tmp",
77                std::process::id(),
78                unique_nanos()
79            );
80            let temp_path = parent.join(temp_name);
81            self.ensure_internal_path(&temp_path)?;
82            match OpenOptions::new()
83                .write(true)
84                .create_new(true)
85                .open(&temp_path)
86            {
87                Ok(mut file) => {
88                    if let Err(err) = file.write_all(bytes) {
89                        let _ = std::fs::remove_file(&temp_path);
90                        return Err(Error::Eval(format!("table/fs: edit write {err}")));
91                    }
92                    if let Err(err) = file.sync_all() {
93                        let _ = std::fs::remove_file(&temp_path);
94                        return Err(Error::Eval(format!("table/fs: edit sync {err}")));
95                    }
96                    drop(file);
97                    if let Err(err) = std::fs::rename(&temp_path, path) {
98                        let _ = std::fs::remove_file(&temp_path);
99                        return Err(Error::Eval(format!("table/fs: edit rename {err}")));
100                    }
101                    return Ok(());
102                }
103                Err(err) if err.kind() == ErrorKind::AlreadyExists => continue,
104                Err(err) => return Err(Error::Eval(format!("table/fs: edit temp {err}"))),
105            }
106        }
107        Err(Error::Eval(
108            "table/fs: edit could not create a temporary file".to_owned(),
109        ))
110    }
111}
112
113fn unique_nanos() -> u128 {
114    SystemTime::now()
115        .duration_since(UNIX_EPOCH)
116        .unwrap_or_default()
117        .as_nanos()
118}
119
120fn apply_edit(text: &str, old: &str, new: &str, replace_all: bool) -> Result<String> {
121    if old.is_empty() {
122        return Err(Error::Eval("edit: old pattern is empty".to_owned()));
123    }
124    let matches = text.matches(old).count();
125    match matches {
126        0 => Err(Error::Eval(format!("edit: pattern not found: {old:?}"))),
127        n if n > 1 && !replace_all => Err(Error::Eval(format!(
128            "edit: pattern is not unique ({n} matches); pass replace_all"
129        ))),
130        _ if replace_all => Ok(text.replace(old, new)),
131        _ => Ok(text.replacen(old, new, 1)),
132    }
133}
134
135fn apply_edit_lines(text: &str, start: usize, end: usize, new: &str) -> Result<String> {
136    if start == 0 {
137        return Err(Error::Eval(
138            "edit-lines: start must be at least 1".to_owned(),
139        ));
140    }
141    if end < start {
142        return Err(Error::Eval(
143            "edit-lines: end must be greater than or equal to start".to_owned(),
144        ));
145    }
146    let lines = text.split_inclusive('\n').collect::<Vec<_>>();
147    if end > lines.len() {
148        return Err(Error::Eval(format!(
149            "edit-lines: range {start}..{end} exceeds {} line(s)",
150            lines.len()
151        )));
152    }
153    let mut edited = String::new();
154    for line in &lines[..start - 1] {
155        edited.push_str(line);
156    }
157    edited.push_str(new);
158    for line in &lines[end..] {
159        edited.push_str(line);
160    }
161    Ok(edited)
162}