semora-cli 0.6.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! What this checkout last pulled or pushed for each document of its project, so `pull` can
//! tell a file changed here from one changed on the server, and a keyed push can name the
//! revision it started from.
//!
//! The record is `.semora-state/checkout.json`, next to `.semora`. It belongs to the folder, not
//! to the machine, so every worktree has its own. The folder holds a `.gitignore` of `*`, so it
//! never gets committed.

use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};

use anyhow::Context;
use semora_proto::DocKind;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::project_link::{Link, shown};

pub const DIR: &str = ".semora-state";
const FILE: &str = "checkout.json";
const IGNORE: &str =
    "# Semora's record of what this folder pulled and pushed. Not for committing.\n*\n";

/// One document as this checkout last saw it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
    pub id: String,
    pub rev: i64,
    pub kind: DocKind,
    /// The sha256 of each file, by its path from the project folder.
    pub files: BTreeMap<String, String>,
}

/// The records of a project, by document path.
pub type Records = BTreeMap<String, Record>;

#[derive(Debug, Default, Serialize, Deserialize)]
struct Stored {
    server: String,
    project: String,
    #[serde(default)]
    docs: Records,
}

pub fn sha256(data: &[u8]) -> String {
    format!("{:x}", Sha256::digest(data))
}

fn file(link: &Link) -> PathBuf {
    link.root.join(DIR).join(FILE)
}

fn read(link: &Link) -> anyhow::Result<Records> {
    let path = file(link);
    let text = match std::fs::read_to_string(&path) {
        Ok(t) => t,
        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Records::new()),
        Err(e) => return Err(e).with_context(|| format!("reading {}", shown(&path))),
    };
    let stored: Stored = serde_json::from_str(&text).with_context(|| {
        format!(
            "{} is unreadable; delete it and run `semora pull` to start the record again",
            shown(&path)
        )
    })?;
    // Records for another server or project (the `.semora` changed) say nothing about this one.
    if stored.server != link.server || stored.project != link.slug {
        return Ok(Records::new());
    }
    Ok(stored.docs)
}

/// The records of this checkout's project.
pub fn load(link: &Link) -> anyhow::Result<Records> {
    read(link)
}

/// Changes the records under a lock, so pushes and pulls in one checkout don't lose each
/// other's writes.
pub fn update<T>(link: &Link, change: impl FnOnce(&mut Records) -> T) -> anyhow::Result<T> {
    let dir = link.root.join(DIR);
    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", shown(&dir)))?;
    let ignore = dir.join(".gitignore");
    if !ignore.exists() {
        std::fs::write(&ignore, IGNORE).with_context(|| format!("writing {}", shown(&ignore)))?;
    }
    let lock_path = dir.join("lock");
    let lock = OpenOptions::new()
        .write(true)
        .create(true)
        .truncate(false)
        .open(&lock_path)
        .with_context(|| format!("opening {}", shown(&lock_path)))?;
    lock.lock()
        .with_context(|| format!("locking {}", shown(&lock_path)))?;
    let mut docs = read(link)?;
    let out = change(&mut docs);
    let stored = Stored {
        server: link.server.clone(),
        project: link.slug.clone(),
        docs,
    };
    let text = serde_json::to_string_pretty(&stored).expect("records serialize");
    // Readers don't lock: the rename swaps in the whole file at once.
    let tmp = dir.join(format!("{FILE}.tmp"));
    std::fs::write(&tmp, text).with_context(|| format!("writing {}", shown(&tmp)))?;
    let path = file(link);
    std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", shown(&path)))?;
    Ok(out)
}

/// A path from the server as a relative path on this machine, or `None` when it would leave
/// the project folder, reach into `.git`, `.semora` or the record, or can't be a file name here.
pub fn local_rel(path: &str) -> Option<PathBuf> {
    if path.is_empty()
        || path
            .chars()
            .any(|c| c.is_control() || c == '\\' || c == ':')
    {
        return None;
    }
    let mut out = PathBuf::new();
    for seg in path.split('/') {
        if seg.is_empty() || seg == "." || seg == ".." {
            return None;
        }
        let lower = seg.to_ascii_lowercase();
        if [".git", crate::project_link::FILE, DIR].contains(&lower.as_str()) {
            return None;
        }
        if cfg!(windows) && !windows_name(seg) {
            return None;
        }
        out.push(seg);
    }
    Some(out)
}

/// Characters, endings and device names Windows refuses in a file name.
fn windows_name(seg: &str) -> bool {
    const DEVICES: [&str; 22] = [
        "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
        "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
    ];
    let stem = seg.split('.').next().unwrap_or("").to_ascii_lowercase();
    !seg.contains(['<', '>', '"', '|', '?', '*'])
        && !seg.ends_with(['.', ' '])
        && !DEVICES.contains(&stem.as_str())
}

/// What is on disk at a document's path.
#[derive(Debug, PartialEq, Eq)]
pub enum Local {
    Missing,
    /// The sha256 of each file a push would send, by path from the project folder.
    Files(BTreeMap<String, String>),
    /// A folder where the document is a file, or the other way round.
    WrongType,
}

/// Reads the document at `key` as a push would: one file, or the files of a folder.
pub fn local(root: &Path, key: &str, kind: DocKind) -> anyhow::Result<Local> {
    let Some(rel) = local_rel(key) else {
        return Ok(Local::WrongType);
    };
    let full = root.join(rel);
    let meta = match std::fs::metadata(&full) {
        Ok(m) => m,
        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Local::Missing),
        // A file where one of the path's folders should be.
        Err(e) if e.kind() == ErrorKind::NotADirectory => return Ok(Local::WrongType),
        Err(e) => return Err(e).with_context(|| format!("reading {}", shown(&full))),
    };
    let mut files = BTreeMap::new();
    match (kind, meta.is_dir()) {
        (DocKind::Bundle, true) => {
            let (list, _) = crate::bundle::list(&full)?;
            for f in list {
                let data = std::fs::read(full.join(&f))
                    .with_context(|| format!("reading {}", shown(&full.join(&f))))?;
                files.insert(format!("{key}/{f}"), sha256(&data));
            }
        }
        (DocKind::Bundle, false) | (DocKind::Md | DocKind::Html, true) => {
            return Ok(Local::WrongType);
        }
        (DocKind::Md | DocKind::Html, false) => {
            let data = std::fs::read(&full).with_context(|| format!("reading {}", shown(&full)))?;
            files.insert(key.to_string(), sha256(&data));
        }
    }
    Ok(Local::Files(files))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn link(root: &Path) -> Link {
        Link {
            root: root.to_path_buf(),
            server: "https://a.example.com".into(),
            slug: "p".into(),
        }
    }

    fn record(rev: i64) -> Record {
        Record {
            id: "k3F9xQ2mLp".into(),
            rev,
            kind: DocKind::Md,
            files: [("docs/a.md".to_string(), sha256(b"# A"))].into(),
        }
    }

    #[test]
    fn records_round_trip_and_the_folder_ignores_itself() {
        let d = tempfile::tempdir().unwrap();
        let l = link(d.path());
        assert!(load(&l).unwrap().is_empty());
        update(&l, |r| r.insert("docs/a.md".into(), record(4))).unwrap();
        assert_eq!(load(&l).unwrap()["docs/a.md"], record(4));
        let ignore = std::fs::read_to_string(d.path().join(DIR).join(".gitignore")).unwrap();
        assert!(ignore.lines().any(|l| l == "*"), "{ignore}");
    }

    #[test]
    fn records_of_another_project_or_server_are_not_used() {
        let d = tempfile::tempdir().unwrap();
        let l = link(d.path());
        update(&l, |r| r.insert("docs/a.md".into(), record(4))).unwrap();
        let other = Link {
            slug: "q".into(),
            ..l.clone()
        };
        assert!(load(&other).unwrap().is_empty());
        let elsewhere = Link {
            server: "https://b.example.com".into(),
            ..l.clone()
        };
        assert!(load(&elsewhere).unwrap().is_empty());
        update(&other, |r| r.insert("x.md".into(), record(1))).unwrap();
        assert!(
            load(&l).unwrap().is_empty(),
            "the file now holds q's records"
        );
    }

    #[test]
    fn an_unreadable_record_says_how_to_start_again() {
        let d = tempfile::tempdir().unwrap();
        std::fs::create_dir(d.path().join(DIR)).unwrap();
        std::fs::write(d.path().join(DIR).join(FILE), "{not json").unwrap();
        let err = format!("{:#}", load(&link(d.path())).unwrap_err());
        assert!(err.contains("semora pull") && err.contains(FILE), "{err}");
    }

    #[test]
    fn updates_at_the_same_time_keep_every_record() {
        let d = tempfile::tempdir().unwrap();
        let l = link(d.path());
        let n = 16;
        let start = std::sync::Barrier::new(n);
        std::thread::scope(|s| {
            for i in 0..n {
                let (start, l) = (&start, &l);
                s.spawn(move || {
                    start.wait();
                    update(l, |r| r.insert(format!("f{i}.md"), record(i as i64))).unwrap();
                });
            }
        });
        assert_eq!(load(&l).unwrap().len(), n);
    }

    #[test]
    fn server_paths_stay_inside_the_project_folder() {
        assert_eq!(
            local_rel("docs/plan.md"),
            Some(Path::new("docs").join("plan.md"))
        );
        assert!(
            local_rel(".github/notes.md").is_some(),
            "a hidden folder is fine"
        );
        for bad in [
            "",
            "/etc/passwd",
            "../x.md",
            "docs/../../x.md",
            "docs//x.md",
            "./x.md",
            "a\\b.md",
            "C:/x.md",
            "x\n.md",
            ".git/hooks/x.md",
            "sub/.GIT/config.md",
            ".semora",
            ".semora-state/checkout.json",
        ] {
            assert_eq!(local_rel(bad), None, "{bad:?}");
        }
    }

    #[test]
    fn local_reads_a_file_or_a_folder_as_a_push_would() {
        let d = tempfile::tempdir().unwrap();
        let root = d.path();
        assert_eq!(local(root, "a.md", DocKind::Md).unwrap(), Local::Missing);
        std::fs::write(root.join("a.md"), "# A").unwrap();
        assert_eq!(
            local(root, "a.md", DocKind::Md).unwrap(),
            Local::Files([("a.md".to_string(), sha256(b"# A"))].into())
        );
        assert_eq!(
            local(root, "a.md", DocKind::Bundle).unwrap(),
            Local::WrongType
        );
        std::fs::create_dir_all(root.join("site/.cache")).unwrap();
        std::fs::write(root.join("site/index.md"), "# I").unwrap();
        std::fs::write(root.join("site/.cache/x.md"), "hidden").unwrap();
        std::fs::write(root.join("site/notes.txt"), "not served").unwrap();
        assert_eq!(
            local(root, "site", DocKind::Bundle).unwrap(),
            Local::Files([("site/index.md".to_string(), sha256(b"# I"))].into()),
            "only what a push would send"
        );
        assert_eq!(local(root, "site", DocKind::Md).unwrap(), Local::WrongType);
        assert_eq!(
            local(root, "../a.md", DocKind::Md).unwrap(),
            Local::WrongType
        );
    }
}