wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! A name for anything a note or a link can be about.
//!
//! Written as `<kind>:<value>`, the same on every machine, so a link
//! committed on one machine means the same thing on every other:
//!
//! | ref | names |
//! |---|---|
//! | `board:<owner>/<number>` | a GitHub Projects board |
//! | `card:<owner>/<number>/<item id>` | a card on one |
//! | `issue:<owner>/<repo>#<number>` | an issue or pull request |
//! | `repo:<owner>/<repo>` | a GitHub repository |
//! | `project:<owner>/<repo>` | a project, by its main repo on GitHub |
//! | `worktree:<owner>/<repo>/<worktree>` | one of a project's worktrees |
//! | `path:<owner>/<repo>/<path>` | a file or folder of a project, relative to its main repo |
//! | `machine:<name>` | a machine, by the name it is configured with |
//! | `note:<id>` | a note |
//! | `session:<agent>/<id>` | an agent session |
//! | `message:<agent>/<session>/<uuid>` | one message of one |
//! | `url:<url>` | anything else with an address |
//!
//! A project is named by its main repo on GitHub rather than by its folder
//! name: the folder is this machine's choice (a project cloned as `names`
//! lives in `namecheck/` here), and another machine may not have it at all —
//! the slug is what it is cloned from.

use std::fmt;
use std::str::FromStr;

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

/// A project, by its main repo on GitHub.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ProjectId {
    pub owner: String,
    pub repo: String,
}

impl ProjectId {
    /// The project whose main repo is checked out at `main_repo`: its
    /// `origin` remote on GitHub. A main repo with no GitHub origin is
    /// refused, naming what it has: nothing else names it the same way on
    /// every machine.
    pub fn of_checkout(main_repo: &std::path::Path) -> Result<Self> {
        let output = crate::git_backend::git_command()
            .arg("-C")
            .arg(main_repo)
            .args(["remote", "get-url", "origin"])
            .output()
            .with_context(|| format!("cannot run git in {}", main_repo.display()))?;
        if !output.status.success() {
            bail!(
                "{} has no origin remote, so the project it holds has no name other machines know it by: \
                 add its GitHub repository as origin",
                main_repo.display()
            );
        }
        let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
        let (owner, repo) = wsb_github::parse_github_url(&url).with_context(|| {
            format!(
                "the origin of {} is {url}, not a GitHub repository, so the project it holds has no name \
                 other machines know it by",
                main_repo.display()
            )
        })?;
        Ok(Self { owner, repo })
    }

    /// `https://github.com/<owner>/<repo>`, what a project is cloned from.
    pub fn clone_url(&self) -> String {
        format!("https://github.com/{}/{}", self.owner, self.repo)
    }
}

impl fmt::Display for ProjectId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}/{}", self.owner, self.repo)
    }
}

/// Anything a note or a link can be about.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Ref {
    Board { owner: String, number: u64 },
    Card { owner: String, number: u64, item: String },
    Issue { owner: String, repo: String, number: u64 },
    Repo { owner: String, repo: String },
    Project(ProjectId),
    Worktree { project: ProjectId, worktree: String },
    Path { project: ProjectId, path: String },
    Machine { name: String },
    Note { id: String },
    Session { agent: String, id: String },
    Message { agent: String, session: String, uuid: String },
    Url { url: String },
}

impl Ref {
    /// The kind, as the ref is written: `board`, `card`, ….
    pub fn kind(&self) -> &'static str {
        match self {
            Ref::Board { .. } => "board",
            Ref::Card { .. } => "card",
            Ref::Issue { .. } => "issue",
            Ref::Repo { .. } => "repo",
            Ref::Project(_) => "project",
            Ref::Worktree { .. } => "worktree",
            Ref::Path { .. } => "path",
            Ref::Machine { .. } => "machine",
            Ref::Note { .. } => "note",
            Ref::Session { .. } => "session",
            Ref::Message { .. } => "message",
            Ref::Url { .. } => "url",
        }
    }

    /// The project whose main repo is checked out at `worktree_root`.
    pub fn project_of(worktree_root: &std::path::Path) -> Result<Self> {
        Ok(Ref::Project(ProjectId::of_checkout(worktree_root)?))
    }

    /// The worktree checked out at `worktree_root`, which must be one of a
    /// workspace's in the workshop: a checkout elsewhere has no name another
    /// machine could find it by.
    pub fn worktree_of(worktree_root: &std::path::Path) -> Result<Self> {
        let workshop = wsb_prj::Workshop::from_env()?;
        let location = workshop.locate(worktree_root)?;
        let worktree = location
            .worktree
            .with_context(|| format!("{} is in no worktree of {}", worktree_root.display(), location.workspace.name()))?;
        Ok(Ref::Worktree {
            project: ProjectId::of_checkout(&location.workspace.main_worktree())?,
            worktree,
        })
    }

    /// The file or folder `path`, inside the worktree checked out at
    /// `worktree_root`, as a path of its project.
    pub fn path_in(worktree_root: &std::path::Path, path: &std::path::Path) -> Result<Self> {
        let root = crate::platform::canonicalize(worktree_root)
            .with_context(|| format!("resolving {}", worktree_root.display()))?;
        let resolved = crate::platform::canonicalize(path).with_context(|| format!("resolving {}", path.display()))?;
        let relative = resolved
            .strip_prefix(&root)
            .with_context(|| format!("{} is not inside the worktree at {}", path.display(), root.display()))?;
        let parts: Vec<String> = relative
            .components()
            .map(|component| component.as_os_str().to_string_lossy().into_owned())
            .collect();
        if parts.is_empty() {
            bail!("{} is the worktree itself; name the project or the worktree instead", path.display());
        }
        Ok(Ref::Path {
            project: ProjectId::of_checkout(&root)?,
            path: parts.join("/"),
        })
    }

    /// The project this ref lies inside, for the kinds that lie inside one.
    pub fn project(&self) -> Option<&ProjectId> {
        match self {
            Ref::Project(project) | Ref::Worktree { project, .. } | Ref::Path { project, .. } => Some(project),
            _ => None,
        }
    }
}

/// A name part that cannot hold a separator: owners, repos, worktrees,
/// agents, ids.
fn segment(text: &str, what: &str) -> Result<String> {
    if text.is_empty() || text.contains('/') || text.chars().any(|c| c.is_whitespace() || c.is_control()) {
        bail!("{what} {text:?} cannot be empty or hold '/' or whitespace");
    }
    Ok(text.to_string())
}

fn number(text: &str, what: &str) -> Result<u64> {
    text.parse::<u64>()
        .with_context(|| format!("{what} {text:?} is not a number"))
}

/// A GitHub account or organisation: letters, digits and single hyphens, not
/// at either end — so never something a command would read as an option.
fn owner_name(text: &str) -> Result<String> {
    let well_formed = !text.is_empty()
        && !text.starts_with('-')
        && !text.ends_with('-')
        && !text.contains("--")
        && text.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
    if !well_formed {
        bail!("{text:?} is not a GitHub owner: letters, digits and single hyphens, not at either end");
    }
    Ok(text.to_string())
}

/// A GitHub repository name: letters, digits, `-`, `_` and `.`, not `.` or
/// `..`, and not starting with `-`.
fn repo_name(text: &str) -> Result<String> {
    let well_formed = !text.is_empty()
        && text != "."
        && text != ".."
        && !text.starts_with('-')
        && text.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
    if !well_formed {
        bail!("{text:?} is not a GitHub repository name: letters, digits, '-', '_' and '.'");
    }
    Ok(text.to_string())
}

fn project(owner: &str, repo: &str) -> Result<ProjectId> {
    Ok(ProjectId {
        owner: owner_name(owner)?,
        repo: repo_name(repo)?,
    })
}

impl FromStr for Ref {
    type Err = anyhow::Error;

    fn from_str(text: &str) -> Result<Self> {
        let parse = || -> Result<Ref> {
            let (kind, value) = text
                .split_once(':')
                .context("a ref is written <kind>:<value>, such as board:owner/13")?;
            let parts: Vec<&str> = value.split('/').collect();
            Ok(match kind {
                "board" => match parts.as_slice() {
                    [owner, n] => Ref::Board {
                        owner: owner_name(owner)?,
                        number: number(n, "a board number")?,
                    },
                    _ => bail!("a board is board:<owner>/<number>"),
                },
                "card" => match parts.as_slice() {
                    [owner, n, item] => Ref::Card {
                        owner: owner_name(owner)?,
                        number: number(n, "a board number")?,
                        item: segment(item, "a card id")?,
                    },
                    _ => bail!("a card is card:<owner>/<number>/<item id>"),
                },
                "issue" => match parts.as_slice() {
                    [owner, rest] => {
                        let (repo, n) = rest.split_once('#').context("an issue is issue:<owner>/<repo>#<number>")?;
                        Ref::Issue {
                            owner: owner_name(owner)?,
                            repo: repo_name(repo)?,
                            number: number(n, "an issue number")?,
                        }
                    }
                    _ => bail!("an issue is issue:<owner>/<repo>#<number>"),
                },
                "repo" => match parts.as_slice() {
                    [owner, repo] => Ref::Repo {
                        owner: owner_name(owner)?,
                        repo: repo_name(repo)?,
                    },
                    _ => bail!("a repository is repo:<owner>/<repo>"),
                },
                "project" => match parts.as_slice() {
                    [owner, repo] => Ref::Project(project(owner, repo)?),
                    _ => bail!("a project is project:<owner>/<repo>, its main repo on GitHub"),
                },
                "worktree" => match parts.as_slice() {
                    [owner, repo, worktree] => {
                        if !wsb_prj::is_valid_name(worktree) {
                            bail!("{worktree:?} is not a worktree name");
                        }
                        Ref::Worktree {
                            project: project(owner, repo)?,
                            worktree: worktree.to_string(),
                        }
                    }
                    _ => bail!("a worktree is worktree:<owner>/<repo>/<worktree>"),
                },
                "path" => match parts.as_slice() {
                    [owner, repo, rest @ ..] if !rest.is_empty() => {
                        if rest.iter().any(|part| part.is_empty() || *part == "." || *part == "..") {
                            bail!("a path is relative to the project's main repo, with no empty, '.' or '..' parts");
                        }
                        Ref::Path {
                            project: project(owner, repo)?,
                            path: rest.join("/"),
                        }
                    }
                    _ => bail!("a path is path:<owner>/<repo>/<path in the main repo>"),
                },
                "machine" => Ref::Machine {
                    name: segment(value, "a machine name")?,
                },
                "note" => Ref::Note {
                    id: super::notes::check_id(value)?,
                },
                "session" => match parts.as_slice() {
                    [agent, id] => Ref::Session {
                        agent: segment(agent, "an agent")?,
                        id: segment(id, "a session id")?,
                    },
                    _ => bail!("a session is session:<agent>/<id>"),
                },
                "message" => match parts.as_slice() {
                    [agent, session, uuid] => Ref::Message {
                        agent: segment(agent, "an agent")?,
                        session: segment(session, "a session id")?,
                        uuid: segment(uuid, "a message uuid")?,
                    },
                    _ => bail!("a message is message:<agent>/<session>/<uuid>"),
                },
                "url" => {
                    if !(value.starts_with("https://") || value.starts_with("http://"))
                        || value.chars().any(char::is_whitespace)
                    {
                        bail!("a url is url:http(s)://… with no whitespace");
                    }
                    Ref::Url { url: value.to_string() }
                }
                other => bail!(
                    "{other:?} is not a kind of ref: board, card, issue, repo, project, worktree, path, \
                     machine, note, session, message or url"
                ),
            })
        };
        parse().with_context(|| format!("{text:?} is not a ref"))
    }
}

impl fmt::Display for Ref {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Ref::Board { owner, number } => write!(f, "board:{owner}/{number}"),
            Ref::Card { owner, number, item } => write!(f, "card:{owner}/{number}/{item}"),
            Ref::Issue { owner, repo, number } => write!(f, "issue:{owner}/{repo}#{number}"),
            Ref::Repo { owner, repo } => write!(f, "repo:{owner}/{repo}"),
            Ref::Project(project) => write!(f, "project:{project}"),
            Ref::Worktree { project, worktree } => write!(f, "worktree:{project}/{worktree}"),
            Ref::Path { project, path } => write!(f, "path:{project}/{path}"),
            Ref::Machine { name } => write!(f, "machine:{name}"),
            Ref::Note { id } => write!(f, "note:{id}"),
            Ref::Session { agent, id } => write!(f, "session:{agent}/{id}"),
            Ref::Message { agent, session, uuid } => write!(f, "message:{agent}/{session}/{uuid}"),
            Ref::Url { url } => write!(f, "url:{url}"),
        }
    }
}

impl Serialize for Ref {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
        serializer.collect_str(self)
    }
}

impl<'de> Deserialize<'de> for Ref {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
        let text = String::deserialize(deserializer)?;
        text.parse().map_err(|e: anyhow::Error| serde::de::Error::custom(format!("{e:#}")))
    }
}