wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Links: `.wsb/links/<id>.json` in a project's main repo.
//!
//! A link says two things belong together: a card and the worktree doing it,
//! a note and the file it is about, a message and the project it was pinned
//! to. Anything a [`Ref`] names can be at either end.
//!
//! The file's name is a digest of what the link says, and the file holds
//! nothing else — no time, no machine. The same link made twice, on two
//! machines or on two branches, is one file with one content, so it never
//! conflicts in a merge and is never held twice.

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

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

use super::refs::Ref;

/// The folder links live in, inside `.wsb/`.
pub const DIR: &str = "links";

/// The label a pin carries: a message linked to the project it was pinned
/// in.
pub const PINNED: &str = "pinned";

/// Two things that belong together.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Link {
    pub from: Ref,
    pub to: Ref,
    /// What the link means, when it means more than "these belong
    /// together": `pinned`, `implements`, ….
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
}

impl Link {
    pub fn new(from: Ref, to: Ref, label: Option<String>) -> Result<Self> {
        let link = Self { from, to, label };
        link.check()?;
        Ok(link)
    }

    fn check(&self) -> Result<()> {
        if self.from == self.to {
            bail!("a link joins two things, and {} is one", self.from);
        }
        if let Some(label) = &self.label {
            if label.is_empty() || !label.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
                bail!("a link's label is lowercase letters, digits and '-', not {label:?}");
            }
        }
        Ok(())
    }

    /// The link's id: the first sixteen hex digits of the sha256 of what it
    /// says.
    pub fn id(&self) -> String {
        use sha2::{Digest, Sha256};
        let said = format!("{}\n{}\n{}", self.from, self.to, self.label.as_deref().unwrap_or(""));
        let digest = Sha256::digest(said.as_bytes());
        digest.iter().take(8).map(|byte| format!("{byte:02x}")).collect()
    }

    /// The end of the link that is not `end`, when `end` is one of its ends.
    pub fn other_end(&self, end: &Ref) -> Option<&Ref> {
        if &self.from == end {
            Some(&self.to)
        } else if &self.to == end {
            Some(&self.from)
        } else {
            None
        }
    }

    /// Write the link into the checkout at `root`. Writing one that is
    /// already there changes nothing.
    pub fn write(&self, root: &Path) -> Result<PathBuf> {
        self.check()?;
        let path = path_of(root, &self.id());
        let mut text = serde_json::to_string_pretty(self)?;
        text.push('\n');
        super::write_file(&path, &text)?;
        Ok(path)
    }

    /// Every link in the checkout at `root`. A file whose name is not the
    /// digest of what it says is refused: it was edited by hand, and the
    /// same link made again would be a second file.
    pub fn all(root: &Path) -> Result<Vec<Self>> {
        let mut links = Vec::new();
        for (id, path) in super::files_with_extension(&dir_of(root), "json")? {
            let text = std::fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?;
            let link: Self =
                serde_json::from_str(&text).with_context(|| format!("{} is not a link wsb can read", path.display()))?;
            link.check().with_context(|| format!("{} is not a link wsb can read", path.display()))?;
            if link.id() != id {
                bail!(
                    "{} holds the link {} → {}, whose file is {}.json: it was edited by hand; \
                     remove it and make the link again",
                    path.display(),
                    link.from,
                    link.to,
                    link.id()
                );
            }
            links.push(link);
        }
        links.sort();
        Ok(links)
    }
}

/// The folder holding the links of the main repo checked out at `root`.
pub fn dir_of(root: &Path) -> PathBuf {
    wsb_prj::settings::dir_of(root).join(DIR)
}

/// The file of link `id` in the checkout at `root`.
pub fn path_of(root: &Path, id: &str) -> PathBuf {
    dir_of(root).join(format!("{id}.json"))
}