semora-cli 0.6.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! Remembers which document each pushed path became, so the next push of that
//! path replaces it. `document.cache` in the config folder has one line per
//! path: `<resolved path>\t<server>\t<id>`.

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")
}

/// The document remembered for `target`, if any.
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)
}

/// Remembers `memo` for `target`, replacing its earlier line.
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()))?;
    // Writers take turns on a lock file that is never replaced, so no save is lost.
    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));
    // Readers don't lock: the rename swaps in the whole file at once.
    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(())
}

/// The absolute path with symlinks followed, as a cache key.
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()
        )
    })
}

/// `canonicalize` gives `\\?\C:\...` on Windows; the key is the ordinary form.
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)
}

/// Splits from the right, so a tab in the path stays in the path.
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()
            );
        }
    }
}