use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use anyhow::Context;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Memo {
pub id: String,
pub server: String,
}
pub fn cache_path(config_dir: &Path) -> PathBuf {
config_dir.join("document.cache")
}
pub fn load(config_dir: &Path, target: &Path) -> Option<Memo> {
let key = resolve(target).ok()?;
let text = std::fs::read_to_string(cache_path(config_dir)).unwrap_or_default();
find(&text, &key)
}
pub fn save(config_dir: &Path, target: &Path, memo: &Memo) -> anyhow::Result<()> {
let key = resolve(target)?;
std::fs::create_dir_all(config_dir)
.with_context(|| format!("creating {}", config_dir.display()))?;
let lock_path = config_dir.join("document.cache.lock");
let lock = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("opening {}", lock_path.display()))?;
lock.lock()
.with_context(|| format!("locking {}", lock_path.display()))?;
let cache = cache_path(config_dir);
let text = match std::fs::read_to_string(&cache) {
Ok(t) => t,
Err(e) if e.kind() == ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).with_context(|| format!("reading {}", cache.display())),
};
let mut out = String::with_capacity(text.len() + key.len() + 64);
for line in text.lines() {
if line.is_empty() || parse_line(line).is_some_and(|(k, _)| k == key) {
continue;
}
out.push_str(line);
out.push('\n');
}
out.push_str(&format!("{key}\t{}\t{}\n", memo.server, memo.id));
let tmp = config_dir.join("document.cache.tmp");
std::fs::write(&tmp, out).with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, &cache).with_context(|| format!("writing {}", cache.display()))?;
Ok(())
}
fn resolve(target: &Path) -> anyhow::Result<String> {
let full =
std::fs::canonicalize(target).with_context(|| format!("resolving {}", target.display()))?;
key_of(&full).with_context(|| {
format!(
"{} can't be remembered: the path has a line break or isn't valid Unicode",
full.display()
)
})
}
fn key_of(full: &Path) -> Option<String> {
let s = full.to_str()?;
if s.contains(['\n', '\r']) {
return None;
}
Some(match s.strip_prefix(r"\\?\") {
Some(rest) => match rest.strip_prefix(r"UNC\") {
Some(share) => format!(r"\\{share}"),
None => rest.to_string(),
},
None => s.to_string(),
})
}
fn find(text: &str, key: &str) -> Option<Memo> {
text.lines()
.filter_map(parse_line)
.find(|(k, _)| *k == key)
.map(|(_, m)| m)
}
fn parse_line(line: &str) -> Option<(&str, Memo)> {
let mut parts = line.rsplitn(3, '\t');
let (id, server, path) = (parts.next()?, parts.next()?, parts.next()?);
(!path.is_empty() && !id.is_empty()).then(|| {
(
path,
Memo {
id: id.into(),
server: server.into(),
},
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn memo(server: &str, id: &str) -> Memo {
Memo {
id: id.into(),
server: server.into(),
}
}
#[test]
fn save_then_load_for_files_and_folders() {
let config = tempfile::tempdir().unwrap();
let work = tempfile::tempdir().unwrap();
let file = work.path().join("notes.md");
std::fs::write(&file, "x").unwrap();
save(config.path(), &file, &memo("https://a", "k3F9xQ2mLp")).unwrap();
save(config.path(), work.path(), &memo("https://a", "Folder0001")).unwrap();
assert_eq!(
load(config.path(), &file),
Some(memo("https://a", "k3F9xQ2mLp"))
);
assert_eq!(
load(config.path(), work.path()),
Some(memo("https://a", "Folder0001"))
);
assert_eq!(load(config.path(), &work.path().join("other.md")), None);
assert!(
std::fs::read_dir(work.path()).unwrap().all(|e| !e
.unwrap()
.file_name()
.to_string_lossy()
.starts_with('.'))
);
}
#[test]
fn the_same_path_by_another_name_is_the_same_entry() {
let config = tempfile::tempdir().unwrap();
let work = tempfile::tempdir().unwrap();
std::fs::create_dir(work.path().join("sub")).unwrap();
std::fs::write(work.path().join("notes.md"), "x").unwrap();
save(
config.path(),
&work.path().join("notes.md"),
&memo("https://a", "First00001"),
)
.unwrap();
let roundabout = work.path().join("sub").join("..").join("notes.md");
assert_eq!(
load(config.path(), &roundabout),
Some(memo("https://a", "First00001"))
);
save(config.path(), &roundabout, &memo("https://a", "Second0002")).unwrap();
let text = std::fs::read_to_string(cache_path(config.path())).unwrap();
assert_eq!(text.lines().count(), 1, "{text}");
assert!(
text.ends_with("notes.md\thttps://a\tSecond0002\n"),
"{text}"
);
}
#[test]
fn other_lines_survive_a_save() {
let config = tempfile::tempdir().unwrap();
let work = tempfile::tempdir().unwrap();
let file = work.path().join("a.md");
std::fs::write(&file, "x").unwrap();
std::fs::write(
cache_path(config.path()),
"/elsewhere/b.md\thttps://b\tBbbbbbbbbb\nnot a cache line\n",
)
.unwrap();
save(config.path(), &file, &memo("https://a", "Aaaaaaaaaa")).unwrap();
let text = std::fs::read_to_string(cache_path(config.path())).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines[..2],
["/elsewhere/b.md\thttps://b\tBbbbbbbbbb", "not a cache line"]
);
assert!(lines[2].ends_with("a.md\thttps://a\tAaaaaaaaaa"), "{text}");
}
#[test]
fn lines_split_from_the_right_so_a_tab_in_the_path_is_kept() {
let text = "/x/a\tb.md\thttps://a\tTabbed0001\r\n/x/a\thttps://b\tPlain00001\n";
assert_eq!(
find(text, "/x/a\tb.md"),
Some(memo("https://a", "Tabbed0001"))
);
assert_eq!(find(text, "/x/a"), Some(memo("https://b", "Plain00001")));
assert_eq!(find(text, "/x/b.md"), None);
assert_eq!(find("/x/c.md\tno-id\n", "/x/c.md"), None);
}
#[test]
fn keys_drop_the_windows_verbatim_prefix() {
assert_eq!(
key_of(Path::new(r"\\?\C:\Development\notes.md")).as_deref(),
Some(r"C:\Development\notes.md")
);
assert_eq!(
key_of(Path::new(r"\\?\UNC\nas\share\notes.md")).as_deref(),
Some(r"\\nas\share\notes.md")
);
assert_eq!(
key_of(Path::new("/home/a/notes.md")).as_deref(),
Some("/home/a/notes.md")
);
assert_eq!(
key_of(Path::new("/home/a/two\nlines.md")),
None,
"a line break can't be stored"
);
}
#[test]
fn saves_at_the_same_time_keep_every_line() {
let config = tempfile::tempdir().unwrap();
let work = tempfile::tempdir().unwrap();
let n = 16;
let files: Vec<PathBuf> = (0..n)
.map(|i| {
let f = work.path().join(format!("f{i}.md"));
std::fs::write(&f, "x").unwrap();
f
})
.collect();
let start = std::sync::Barrier::new(n);
std::thread::scope(|s| {
for (i, f) in files.iter().enumerate() {
let (start, config) = (&start, config.path());
s.spawn(move || {
start.wait();
save(config, f, &memo("https://a", &format!("Doc{i:07}"))).unwrap();
});
}
});
for (i, f) in files.iter().enumerate() {
assert_eq!(
load(config.path(), f),
Some(memo("https://a", &format!("Doc{i:07}"))),
"{}",
f.display()
);
}
}
}