//! 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] }) } /// The bookmark to show when the default bookmark does not exist: main, /// else master, else the first by name; `None` without any. fn stand_in(repo: &Repo) -> Result> { let view = repo.view()?; let names: Vec<&String> = view.bookmarks.keys().collect(); Ok(["main", "master"] .iter() .find(|n| names.iter().any(|b| b == *n)) .map(|n| n.to_string()) .or_else(|| names.iter().min().map(|b| b.to_string()))) } /// 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 bookmark, tag or change of this repository"))); } 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}"), } } } /// How a change is authenticated: by its own signature, or by a seal over /// it (a keyless agent seals at push, as `oxy seal` does; oxygenhub#352). pub fn signature(repo: &Repo, hash: &ChangeHash) -> SigView { match repo.authenticate_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()), } } /// Who signed for the author when it was not the author: a delegate, such /// as an agent, named by its identity, with the delegation that let it /// (Oxygen's agent workflows doc §6). #[derive(Clone, Debug, PartialEq, Eq)] pub struct ViaView { pub name: String, pub email: String, pub key: String, pub short: String, /// The delegation's scope as written, `changes` for an agent. pub scope: String, pub expires_ms: Option, /// The signing key was revoked, or the delegation is no longer in the /// author's identity: the change deserves a second look. pub revoked: bool, /// A short-lived key a hub certified under the author's grant (a /// keyless agent, spec §14.7), rather than a delegate in the author's /// identity. pub certified: bool, } impl ViaView { /// The delegation, as the change page's `via` row says it. pub fn delegation(&self) -> String { if self.certified { return format!("keyless, under your grant for {}", self.scope); } if self.revoked { return "delegation revoked".into(); } match self.expires_ms { Some(e) => format!("delegate for {}, until {}", self.scope, when_utc(e)), None => format!("delegate for {}", self.scope), } } /// What hovering the badge says. pub fn title(&self) -> String { let until = match self.expires_ms { Some(e) => format!(", until {}", when_utc(e)), None => String::new(), }; if self.certified { format!( "sealed by {} with a short-lived key the hub certified under the author's grant ({}), key {}", self.name, self.scope, self.short ) } else if self.revoked { format!("signed by {} as a delegate of the author; that delegation has since been revoked", self.name) } else { format!("signed by {} as a delegate of the author ({}{until}), key {}", self.name, self.scope, self.short) } } } /// `2026-10-04 20:12Z` for a wall time in milliseconds. fn when_utc(ms: u64) -> String { time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000) .ok() .and_then(|t| t.format(&time::macros::format_description!("[year]-[month]-[day] [hour]:[minute]Z")).ok()) .unwrap_or_else(|| ms.to_string()) } /// The delegate that signed `hash` for the author, when one did. pub fn via(repo: &Repo, author: &KeyId, sig: &SigView) -> Option { let SigView::Valid { key, name, revoked, .. } = sig else { return None }; if author.is_zero() || *key == author.to_hex() { return None; } let signer = KeyId::from_hex(key)?; let email = repo.store().get_identity(&signer).map(|i| i.email).unwrap_or_default(); // A keyless agent's key is in no identity: its certificate says for // whom and what; authenticating the change checked its grant. if let Some(cert) = certificate(repo, &signer) { return Some(ViaView { name: name.clone(), email, key: key.clone(), short: signer.short(12), scope: cert.scope.to_string(), expires_ms: Some(cert.expires), revoked: *revoked, certified: true, }); } let entry = repo.store().get_identity(author).ok().and_then(|a| a.delegate(&signer).copied()); Some(ViaView { name: name.clone(), email, key: key.clone(), short: signer.short(12), scope: entry.map(|d| d.scope.to_string()).unwrap_or_else(|| "changes".into()), expires_ms: entry.and_then(|d| d.expires), revoked: *revoked || entry.is_none(), certified: false, }) } /// The certificate a hub gave short-lived key `key`, if the store keeps one. fn certificate(repo: &Repo, key: &KeyId) -> Option { let r = repo.store().certificate_of(key).ok()??; repo.store().get_certificate(&r).ok().map(|c| c.cert) } /// The key that sealed `hash` for its author, when another key did: the /// first seal's signer (read, not verified: the change rows do that). fn sealer(repo: &Repo, hash: &ChangeHash, author: &KeyId) -> Option { let seals = repo.store().seals_for(hash).ok()?; seals.iter().find_map(|a| repo.store().signatures(a).ok()?.into_iter().map(|s| s.key).find(|k| k != author)) } /// 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, /// Signed for the author by a delegate (an agent), when it was. pub via: Option, /// Bookmarks and tags whose state has this change as a head. pub labels: Vec