1use 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
21pub 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
28pub 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 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 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
65fn 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
77fn 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
99fn 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}