//! Page-ready views of a repository, computed from `oxy-core` alone (hub //! spec §1.4). Nothing here knows about HTTP or templates; every function //! takes an open `Repo` and returns plain data. use super::{RepoError, StateCache}; use oxy_core::hash::{ChangeHash, KeyId}; use oxy_core::objects::{Change, NodeKind}; use oxy_core::repo::{BlameLine, FileDiff, Repo, SignatureStatus}; use oxy_core::state::{FileContent, Snapshot}; use oxy_core::tree::TreeConflict; use std::collections::{BTreeMap, BTreeSet, HashMap}; type Result = std::result::Result; fn fail(msg: impl Into) -> RepoError { RepoError::Oxy(oxy_core::repo::RepoError::Msg(msg.into())) } // ------------------------------------------------------------------ refs #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RefKind { Bookmark, Tag, Change, } /// What a `{ref}` path segment resolved to. #[derive(Clone, Debug)] pub struct RefTarget { /// The name as given (bookmark, tag) or the full hash. pub label: String, pub kind: RefKind, pub heads: Vec, } /// Most heads a state address or a "before this change" link carries. pub const MAX_STATE_HEADS: usize = 32; impl RefTarget { /// The label for a ref bar: the name, or `# abc123…` for a change, /// with `+N` for the further heads of a state. pub fn short_label(&self) -> String { match self.kind { RefKind::Change => { let mut parts = self.label.split('+'); let first: String = parts.next().unwrap_or("").chars().take(12).collect(); match parts.count() { 0 => first, n => format!("{first} +{n}"), } } _ => self.label.clone(), } } /// The `{ref}` that names this target's state for good: its heads /// joined with `+` (hub spec §6.6). None for an empty state. pub fn permalink(&self) -> Option { state_spec(&self.heads) } } /// Heads as a state address, sorted so one state has one address. pub fn state_spec(heads: &[ChangeHash]) -> Option { if heads.is_empty() || heads.len() > MAX_STATE_HEADS { return None; } let mut hex: Vec = heads.iter().map(|h| h.to_hex()).collect(); hex.sort(); hex.dedup(); Some(hex.join("+")) } /// Resolve a bookmark name, a tag name, a change hash prefix, or a state /// (full hashes joined with `+`). pub fn resolve(repo: &Repo, cache: &StateCache, spec: &str) -> Result { if spec.contains('+') { let parts: Vec<&str> = spec.split('+').collect(); if parts.len() > MAX_STATE_HEADS { return Err(fail(format!("a state address has at most {MAX_STATE_HEADS} heads"))); } let mut heads = Vec::with_capacity(parts.len()); for p in parts { match ChangeHash::from_hex(p) { Some(h) if p.len() == 64 && repo.store().has(&h) => heads.push(h), _ => return Err(fail(format!("{p:?} is not a change of this repository"))), } } heads.sort(); heads.dedup(); let label = state_spec(&heads).unwrap_or_default(); return Ok(RefTarget { label, kind: RefKind::Change, heads }); } let view = repo.view()?; if let Some(heads) = view.bookmarks.get(spec).map(|st| repo.store().get_state(st).map(|s| s.heads)).transpose()? { return Ok(RefTarget { label: spec.into(), kind: RefKind::Bookmark, heads }); } if let Some(heads) = repo.tag_heads(spec)? { return Ok(RefTarget { label: spec.into(), kind: RefKind::Tag, heads }); } let hash = resolve_change(repo, cache, spec)?; Ok(RefTarget { label: hash.to_hex(), kind: RefKind::Change, heads: vec![hash] }) } /// A change by full hash or unique hex prefix, searched over every /// bookmark's state. pub fn resolve_change(repo: &Repo, cache: &StateCache, spec: &str) -> Result { let spec = spec.trim_start_matches('#'); if spec.len() < 4 || !spec.chars().all(|c| c.is_ascii_hexdigit()) { return Err(fail(format!("{spec:?} is not a change"))); } if let Some(h) = ChangeHash::from_hex(spec) { if repo.store().has(&h) { return Ok(h); } } let heads = all_heads(repo)?; let loaded = cache.load(repo, &heads)?; let matches: Vec = loaded.state.changes().map(|(h, _)| *h).filter(|h| h.starts_with_hex(spec)).collect(); match matches.len() { 1 => Ok(matches[0]), 0 => Err(fail(format!("no change matches {spec:?}"))), _ => Err(fail(format!("{spec:?} is ambiguous"))), } } /// The heads of every bookmark, deduplicated. pub fn all_heads(repo: &Repo) -> Result> { let mut set = BTreeSet::new(); for b in repo.bookmarks()? { set.extend(b.heads); } Ok(set.into_iter().collect()) } // ---------------------------------------------------------------- authors #[derive(Clone, Debug, PartialEq, Eq)] pub struct AuthorView { pub name: String, pub email: String, pub key: String, /// True when the key is not an identity the store knows. pub unknown: bool, /// The hub account the key is linked to, filled by the web layer. pub account: Option, } pub fn author(repo: &Repo, key: &KeyId, message: &str) -> AuthorView { if key.is_zero() { // Imported from Git: the trailer carries the author. let line = message.lines().rev().find_map(|l| l.strip_prefix("Git-Author: ")).unwrap_or("unknown"); let (name, email) = match line.split_once('<') { Some((n, e)) => (n.trim().to_string(), e.trim_end_matches('>').trim().to_string()), None => (line.trim().to_string(), String::new()), }; return AuthorView { name, email, key: String::new(), unknown: true, account: None }; } match repo.store().get_identity(key) { Ok(id) => AuthorView { name: id.name, email: id.email, key: key.to_hex(), unknown: false, account: None }, Err(_) => AuthorView { name: key.short(12), email: String::new(), key: key.to_hex(), unknown: true, account: None }, } } // ---------------------------------------------------------------- changes #[derive(Clone, Debug, PartialEq, Eq)] pub enum SigView { Unsigned, Valid { key: String, name: String, sealed: bool, revoked: bool }, Invalid(String), } impl SigView { pub fn glyph(&self) -> &'static str { match self { SigView::Unsigned => "○", SigView::Valid { revoked: true, .. } => "⊘", SigView::Valid { sealed: true, .. } => "◈", SigView::Valid { .. } => "✔", SigView::Invalid(_) => "✘", } } pub fn class(&self) -> &'static str { match self { SigView::Unsigned => "sig-unsigned", SigView::Valid { revoked: true, .. } => "sig-revoked", SigView::Valid { .. } => "sig-valid", SigView::Invalid(_) => "sig-invalid", } } pub fn title(&self) -> String { match self { SigView::Unsigned => "unsigned".into(), SigView::Valid { name, sealed: true, revoked: false, .. } => format!("sealed by {name}"), SigView::Valid { name, revoked: true, .. } => format!("signed by {name}, key revoked"), SigView::Valid { name, .. } => format!("signed by {name}"), SigView::Invalid(why) => format!("INVALID: {why}"), } } } pub fn signature(repo: &Repo, hash: &ChangeHash) -> SigView { match repo.verify_change(hash) { Ok(SignatureStatus::Unsigned) => SigView::Unsigned, Ok(SignatureStatus::Valid { key, name, sealed, revoked, .. }) => SigView::Valid { key: key.to_hex(), name, sealed, revoked }, Ok(SignatureStatus::Invalid(why)) => SigView::Invalid(why), Err(e) => SigView::Invalid(e.to_string()), } } /// One change as history rows and change pages show it. #[derive(Clone, Debug)] pub struct ChangeView { pub hash: String, pub short: String, pub title: String, pub body: String, pub author: AuthorView, pub when_ms: u64, pub deps: Vec, pub head: bool, pub resolution: bool, pub signature: SigView, /// Bookmarks and tags whose state has this change as a head. pub labels: Vec