use std::fmt;
use std::str::FromStr;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ProjectId {
pub owner: String,
pub repo: String,
}
impl ProjectId {
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 })
}
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)
}
}
#[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 {
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",
}
}
pub fn project_of(worktree_root: &std::path::Path) -> Result<Self> {
Ok(Ref::Project(ProjectId::of_checkout(worktree_root)?))
}
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,
})
}
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("/"),
})
}
pub fn project(&self) -> Option<&ProjectId> {
match self {
Ref::Project(project) | Ref::Worktree { project, .. } | Ref::Path { project, .. } => Some(project),
_ => None,
}
}
}
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"))
}
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())
}
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:#}")))
}
}