Skip to main content

semora_cli/
remember.rs

1//! Remembers which document each pushed path became, so the next push of that
2//! path replaces it. `document.cache` in the config folder has one line per
3//! path: `<resolved path>\t<server>\t<id>`.
4
5use std::fs::OpenOptions;
6use std::io::ErrorKind;
7use std::path::{Path, PathBuf};
8
9use anyhow::Context;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Memo {
13    pub id: String,
14    pub server: String,
15}
16
17pub fn cache_path(config_dir: &Path) -> PathBuf {
18    config_dir.join("document.cache")
19}
20
21/// The document remembered for `target`, if any.
22pub fn load(config_dir: &Path, target: &Path) -> Option<Memo> {
23    let key = resolve(target).ok()?;
24    let text = std::fs::read_to_string(cache_path(config_dir)).unwrap_or_default();
25    find(&text, &key)
26}
27
28/// Remembers `memo` for `target`, replacing its earlier line.
29pub fn save(config_dir: &Path, target: &Path, memo: &Memo) -> anyhow::Result<()> {
30    let key = resolve(target)?;
31    std::fs::create_dir_all(config_dir)
32        .with_context(|| format!("creating {}", config_dir.display()))?;
33    // Writers take turns on a lock file that is never replaced, so no save is lost.
34    let lock_path = config_dir.join("document.cache.lock");
35    let lock = OpenOptions::new()
36        .write(true)
37        .create(true)
38        .truncate(false)
39        .open(&lock_path)
40        .with_context(|| format!("opening {}", lock_path.display()))?;
41    lock.lock()
42        .with_context(|| format!("locking {}", lock_path.display()))?;
43    let cache = cache_path(config_dir);
44    let text = match std::fs::read_to_string(&cache) {
45        Ok(t) => t,
46        Err(e) if e.kind() == ErrorKind::NotFound => String::new(),
47        Err(e) => return Err(e).with_context(|| format!("reading {}", cache.display())),
48    };
49    let mut out = String::with_capacity(text.len() + key.len() + 64);
50    for line in text.lines() {
51        if line.is_empty() || parse_line(line).is_some_and(|(k, _)| k == key) {
52            continue;
53        }
54        out.push_str(line);
55        out.push('\n');
56    }
57    out.push_str(&format!("{key}\t{}\t{}\n", memo.server, memo.id));
58    // Readers don't lock: the rename swaps in the whole file at once.
59    let tmp = config_dir.join("document.cache.tmp");
60    std::fs::write(&tmp, out).with_context(|| format!("writing {}", tmp.display()))?;
61    std::fs::rename(&tmp, &cache).with_context(|| format!("writing {}", cache.display()))?;
62    Ok(())
63}
64
65/// The absolute path with symlinks followed, as a cache key.
66fn resolve(target: &Path) -> anyhow::Result<String> {
67    let full =
68        std::fs::canonicalize(target).with_context(|| format!("resolving {}", target.display()))?;
69    key_of(&full).with_context(|| {
70        format!(
71            "{} can't be remembered: the path has a line break or isn't valid Unicode",
72            full.display()
73        )
74    })
75}
76
77/// `canonicalize` gives `\\?\C:\...` on Windows; the key is the ordinary form.
78fn key_of(full: &Path) -> Option<String> {
79    let s = full.to_str()?;
80    if s.contains(['\n', '\r']) {
81        return None;
82    }
83    Some(match s.strip_prefix(r"\\?\") {
84        Some(rest) => match rest.strip_prefix(r"UNC\") {
85            Some(share) => format!(r"\\{share}"),
86            None => rest.to_string(),
87        },
88        None => s.to_string(),
89    })
90}
91
92fn find(text: &str, key: &str) -> Option<Memo> {
93    text.lines()
94        .filter_map(parse_line)
95        .find(|(k, _)| *k == key)
96        .map(|(_, m)| m)
97}
98
99/// Splits from the right, so a tab in the path stays in the path.
100fn parse_line(line: &str) -> Option<(&str, Memo)> {
101    let mut parts = line.rsplitn(3, '\t');
102    let (id, server, path) = (parts.next()?, parts.next()?, parts.next()?);
103    (!path.is_empty() && !id.is_empty()).then(|| {
104        (
105            path,
106            Memo {
107                id: id.into(),
108                server: server.into(),
109            },
110        )
111    })
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn memo(server: &str, id: &str) -> Memo {
119        Memo {
120            id: id.into(),
121            server: server.into(),
122        }
123    }
124
125    #[test]
126    fn save_then_load_for_files_and_folders() {
127        let config = tempfile::tempdir().unwrap();
128        let work = tempfile::tempdir().unwrap();
129        let file = work.path().join("notes.md");
130        std::fs::write(&file, "x").unwrap();
131        save(config.path(), &file, &memo("https://a", "k3F9xQ2mLp")).unwrap();
132        save(config.path(), work.path(), &memo("https://a", "Folder0001")).unwrap();
133        assert_eq!(
134            load(config.path(), &file),
135            Some(memo("https://a", "k3F9xQ2mLp"))
136        );
137        assert_eq!(
138            load(config.path(), work.path()),
139            Some(memo("https://a", "Folder0001"))
140        );
141        assert_eq!(load(config.path(), &work.path().join("other.md")), None);
142        assert!(
143            std::fs::read_dir(work.path()).unwrap().all(|e| !e
144                .unwrap()
145                .file_name()
146                .to_string_lossy()
147                .starts_with('.'))
148        );
149    }
150
151    #[test]
152    fn the_same_path_by_another_name_is_the_same_entry() {
153        let config = tempfile::tempdir().unwrap();
154        let work = tempfile::tempdir().unwrap();
155        std::fs::create_dir(work.path().join("sub")).unwrap();
156        std::fs::write(work.path().join("notes.md"), "x").unwrap();
157        save(
158            config.path(),
159            &work.path().join("notes.md"),
160            &memo("https://a", "First00001"),
161        )
162        .unwrap();
163        let roundabout = work.path().join("sub").join("..").join("notes.md");
164        assert_eq!(
165            load(config.path(), &roundabout),
166            Some(memo("https://a", "First00001"))
167        );
168        save(config.path(), &roundabout, &memo("https://a", "Second0002")).unwrap();
169        let text = std::fs::read_to_string(cache_path(config.path())).unwrap();
170        assert_eq!(text.lines().count(), 1, "{text}");
171        assert!(
172            text.ends_with("notes.md\thttps://a\tSecond0002\n"),
173            "{text}"
174        );
175    }
176
177    #[test]
178    fn other_lines_survive_a_save() {
179        let config = tempfile::tempdir().unwrap();
180        let work = tempfile::tempdir().unwrap();
181        let file = work.path().join("a.md");
182        std::fs::write(&file, "x").unwrap();
183        std::fs::write(
184            cache_path(config.path()),
185            "/elsewhere/b.md\thttps://b\tBbbbbbbbbb\nnot a cache line\n",
186        )
187        .unwrap();
188        save(config.path(), &file, &memo("https://a", "Aaaaaaaaaa")).unwrap();
189        let text = std::fs::read_to_string(cache_path(config.path())).unwrap();
190        let lines: Vec<&str> = text.lines().collect();
191        assert_eq!(
192            lines[..2],
193            ["/elsewhere/b.md\thttps://b\tBbbbbbbbbb", "not a cache line"]
194        );
195        assert!(lines[2].ends_with("a.md\thttps://a\tAaaaaaaaaa"), "{text}");
196    }
197
198    #[test]
199    fn lines_split_from_the_right_so_a_tab_in_the_path_is_kept() {
200        let text = "/x/a\tb.md\thttps://a\tTabbed0001\r\n/x/a\thttps://b\tPlain00001\n";
201        assert_eq!(
202            find(text, "/x/a\tb.md"),
203            Some(memo("https://a", "Tabbed0001"))
204        );
205        assert_eq!(find(text, "/x/a"), Some(memo("https://b", "Plain00001")));
206        assert_eq!(find(text, "/x/b.md"), None);
207        assert_eq!(find("/x/c.md\tno-id\n", "/x/c.md"), None);
208    }
209
210    #[test]
211    fn keys_drop_the_windows_verbatim_prefix() {
212        assert_eq!(
213            key_of(Path::new(r"\\?\C:\Development\notes.md")).as_deref(),
214            Some(r"C:\Development\notes.md")
215        );
216        assert_eq!(
217            key_of(Path::new(r"\\?\UNC\nas\share\notes.md")).as_deref(),
218            Some(r"\\nas\share\notes.md")
219        );
220        assert_eq!(
221            key_of(Path::new("/home/a/notes.md")).as_deref(),
222            Some("/home/a/notes.md")
223        );
224        assert_eq!(
225            key_of(Path::new("/home/a/two\nlines.md")),
226            None,
227            "a line break can't be stored"
228        );
229    }
230
231    #[test]
232    fn saves_at_the_same_time_keep_every_line() {
233        let config = tempfile::tempdir().unwrap();
234        let work = tempfile::tempdir().unwrap();
235        let n = 16;
236        let files: Vec<PathBuf> = (0..n)
237            .map(|i| {
238                let f = work.path().join(format!("f{i}.md"));
239                std::fs::write(&f, "x").unwrap();
240                f
241            })
242            .collect();
243        let start = std::sync::Barrier::new(n);
244        std::thread::scope(|s| {
245            for (i, f) in files.iter().enumerate() {
246                let (start, config) = (&start, config.path());
247                s.spawn(move || {
248                    start.wait();
249                    save(config, f, &memo("https://a", &format!("Doc{i:07}"))).unwrap();
250                });
251            }
252        });
253        for (i, f) in files.iter().enumerate() {
254            assert_eq!(
255                load(config.path(), f),
256                Some(memo("https://a", &format!("Doc{i:07}"))),
257                "{}",
258                f.display()
259            );
260        }
261    }
262}