semora-cli 1.1.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! Records on this machine that a `.semora` naming another server is used with this CLI's
//! server (`semora project link`). They live in `project-links.toml` in the config folder, never
//! in the checkout, so nobody else's copy of the project changes.

use std::path::{Path, PathBuf};

use anyhow::Context;
use serde::{Deserialize, Serialize};

use crate::cachefile;

const FILE: &str = "project-links.toml";

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalLink {
    /// The file's `server` value, normalized: the id, or the origin of a URL from before ids.
    pub file_server: String,
    /// The project id the file names.
    pub file_project: String,
    /// The server used instead.
    pub origin: String,
    pub server_id: String,
    /// The project id used on that server.
    pub project: String,
    /// The folder `semora project link` ran for. Other checkouts of the same repository use the
    /// record too; only this folder drops it once its file names this server.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub root: Option<PathBuf>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
struct Links {
    #[serde(default)]
    link: Vec<LocalLink>,
}

fn parse(dir: &Path, text: &str) -> anyhow::Result<Links> {
    toml::from_str(text)
        .with_context(|| format!("reading {}: fix or delete it", dir.join(FILE).display()))
}

pub fn load_all(dir: &Path) -> anyhow::Result<Vec<LocalLink>> {
    match std::fs::read_to_string(dir.join(FILE)) {
        Ok(text) => Ok(parse(dir, &text)?.link),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
        Err(e) => Err(e).with_context(|| format!("reading {}", dir.join(FILE).display())),
    }
}

pub fn find(
    dir: &Path,
    file_server: &str,
    file_project: &str,
) -> anyhow::Result<Option<LocalLink>> {
    Ok(load_all(dir)?
        .into_iter()
        .find(|l| l.file_server == file_server && l.file_project == file_project))
}

/// Rewrites the file with `change` applied to its entries. A broken file is reported, not
/// replaced, so a hand edit is never lost.
fn change<T>(dir: &Path, f: impl FnOnce(&mut Vec<LocalLink>) -> T) -> anyhow::Result<T> {
    let mut out = None;
    let mut failed = None;
    cachefile::rewrite(dir, FILE, |text| match parse(dir, text) {
        Ok(mut links) => {
            out = Some(f(&mut links.link));
            toml::to_string(&links).unwrap_or_else(|_| text.to_string())
        }
        Err(e) => {
            failed = Some(e);
            text.to_string()
        }
    })?;
    match (out, failed) {
        (Some(v), _) => Ok(v),
        (None, Some(e)) => Err(e),
        (None, None) => unreachable!("the change runs once"),
    }
}

/// Saves `link`, replacing the entry for the same file.
pub fn save(dir: &Path, link: LocalLink) -> anyhow::Result<()> {
    change(dir, |all| {
        all.retain(|l| !(l.file_server == link.file_server && l.file_project == link.file_project));
        all.push(link);
    })
}

/// Drops the entry for the file. `false` when there was none.
pub fn remove(dir: &Path, file_server: &str, file_project: &str) -> anyhow::Result<bool> {
    if find(dir, file_server, file_project)?.is_none() {
        return Ok(false);
    }
    change(dir, |all| {
        let before = all.len();
        all.retain(|l| !(l.file_server == file_server && l.file_project == file_project));
        all.len() < before
    })
}

/// The file in `root` named another server and now names this one (`server_id`), so the entries
/// made for that folder that sent its project here are no longer used: drops them and returns
/// them. An entry made for another folder belongs to another checkout, which may be another
/// repository with the same project id, and is kept.
pub fn remove_shadowed(
    dir: &Path,
    server_id: &str,
    file_project: &str,
    root: &Path,
) -> anyhow::Result<Vec<LocalLink>> {
    let shadowed = |l: &LocalLink| {
        l.server_id == server_id
            && l.file_project == file_project
            && l.file_server != server_id
            && l.root.as_deref() == Some(root)
    };
    if !load_all(dir)?.iter().any(shadowed) {
        return Ok(Vec::new());
    }
    change(dir, |all| {
        let (gone, kept) = std::mem::take(all).into_iter().partition(shadowed);
        *all = kept;
        gone
    })
}

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

    fn link(file_server: &str, project: &str) -> LocalLink {
        LocalLink {
            file_server: file_server.into(),
            file_project: "proj".into(),
            origin: "https://mine.example.com".into(),
            server_id: "1".repeat(32),
            project: project.into(),
            root: Some(PathBuf::from("/r")),
        }
    }

    #[test]
    fn links_are_saved_found_replaced_and_removed() {
        let d = tempfile::tempdir().unwrap();
        assert_eq!(find(d.path(), "a", "proj").unwrap(), None);
        save(d.path(), link("a", "proj")).unwrap();
        save(d.path(), link("b", "proj")).unwrap();
        save(d.path(), link("a", "mine")).unwrap();
        assert_eq!(
            find(d.path(), "a", "proj").unwrap().unwrap().project,
            "mine"
        );
        assert_eq!(load_all(d.path()).unwrap().len(), 2);
        assert!(remove(d.path(), "a", "proj").unwrap());
        assert!(!remove(d.path(), "a", "proj").unwrap());
        assert_eq!(load_all(d.path()).unwrap().len(), 1);
    }

    #[test]
    fn shadowed_links_are_dropped() {
        let d = tempfile::tempdir().unwrap();
        save(d.path(), link("a", "mine")).unwrap();
        let other = remove_shadowed(d.path(), &"1".repeat(32), "proj", Path::new("/s")).unwrap();
        assert!(other.is_empty(), "another folder's link is kept");
        let gone = remove_shadowed(d.path(), &"1".repeat(32), "proj", Path::new("/r")).unwrap();
        assert_eq!(gone.len(), 1);
        assert!(load_all(d.path()).unwrap().is_empty());
    }

    #[test]
    fn a_broken_links_file_names_itself() {
        let d = tempfile::tempdir().unwrap();
        std::fs::write(d.path().join("project-links.toml"), "[[link]\n").unwrap();
        let err = format!("{:#}", load_all(d.path()).unwrap_err());
        assert!(
            err.contains("project-links.toml") && err.contains("fix or delete it"),
            "{err}"
        );
        let err = format!("{:#}", save(d.path(), link("a", "proj")).unwrap_err());
        assert!(err.contains("fix or delete it"), "{err}");
        assert_eq!(
            std::fs::read_to_string(d.path().join("project-links.toml")).unwrap(),
            "[[link]\n",
            "a broken file is left as it is"
        );
    }
}