crates/gunnar-public-site/src/source.rs
//! **Where the public data comes from**, as a trait this crate does not fill in.
//!
//! The console (gunnar-ui-server, `public-site`) implements [`Source`] with the
//! calls it already makes for a visitor with no session: `Repos.List`,
//! `Refs.Log`, the object reads and the forge's issue reads (`Forge.ListIssues`,
//! `Forge.GetIssue`), all signed by the GRANTLESS guest key, so
//! gunnar narrows every answer to the public subset on its own side of the
//! wire. Nothing in this crate can ask for more than that, because nothing in
//! this crate can ask gunnar anything.
//!
//! Every call here is made by [`crate::Site::refresh`], on a timer, and never by
//! a request. That is the whole of the crawler rule "no expensive git work for
//! anonymous clients": a visitor's request reads a page that was built before
//! it arrived.
use std::future::Future;
use std::pin::Pin;
/// A boxed future, so the trait stays object-safe without a macro crate.
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// One repository, as the public list names it.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct RepoRef {
pub namespace: String,
pub name: String,
/// The repository's own one-line description; may be empty.
pub description: String,
/// True only when the source says the repository is PUBLIC. The site
/// drops every row where it is false, whatever the source returned: a
/// guest key enrolled with a grant by mistake must still publish nothing
/// private (the same second check `/login/repos` makes).
pub public: bool,
}
impl RepoRef {
/// `namespace/name`.
pub fn path(&self) -> String {
format!("{}/{}", self.namespace, self.name)
}
}
/// One commit, as a public page shows it: no author, no e-mail address.
///
/// The type has no field for either, so no page can print one: the privacy of
/// the people who push is kept by what this struct cannot hold.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Commit {
pub oid: String,
/// The first line of the message.
pub subject: String,
pub at_unix: i64,
}
/// **One issue of a public repository**, as a public page shows it (PUB-ISSUES,
/// owner 2026-10-29): gunnar's fold of its signed events, with no author, no
/// e-mail address and no key. Like [`Commit`], the type has no field for who
/// filed it or who answered, so no page can print one.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Issue {
/// The fold's number: the Nth issue opened in the repository is issue N.
pub number: u64,
pub title: String,
pub body: String,
pub open: bool,
pub opened_at_unix: i64,
/// How many comments the fold counts (the list's own number, so a refresh
/// can tell a thread that moved without reading it).
pub comments: u64,
/// The thread after the opening, oldest first; `None` while it is not read
/// yet. [`Source::issues`] leaves it `None`; the site fills it in.
pub thread: Option<Vec<IssueNote>>,
}
/// One entry of an issue's thread after its opening: a comment, or the close
/// with its reason.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueNote {
pub at_unix: i64,
pub body: String,
/// True for the close; its body is the reason given.
pub closed: bool,
}
impl Issue {
/// When anything last happened on it: the newest note, or the opening.
pub fn last_at_unix(&self) -> i64 {
self.thread.iter().flatten().map(|n| n.at_unix).chain([self.opened_at_unix]).max().unwrap_or(self.opened_at_unix)
}
}
/// One entry of a repository's file tree, by its full path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
pub path: String,
pub dir: bool,
}
/// What the site keeps of one public repository: a bounded snapshot, read on
/// the timer and served from memory. Its files are not kept (GUN-GUEST): a file's
/// page is the console's, pre-rendered on demand.
#[derive(Debug, Clone, Default)]
pub struct RepoSnapshot {
/// The default branch's newest commit; `None` for an empty repository.
pub head: Option<Commit>,
/// The README at the root, as `(file name, text)`.
pub readme: Option<(String, String)>,
/// The file tree, breadth first, at most [`Limits::max_tree_entries`].
pub tree: Vec<TreeEntry>,
/// False when the tree was cut at a limit.
pub tree_complete: bool,
}
/// The bounds on what one refresh may read. Each is a cap on gunnar calls or
/// on memory, never a promise to read that much.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Limits {
/// Public repositories carried; the rest are left out of every page.
pub max_repos: usize,
/// Tree entries per repository.
pub max_tree_entries: usize,
/// Tree objects read per repository (one gunnar call each).
pub max_tree_reads: usize,
/// The README, in bytes.
pub max_readme_bytes: u64,
/// Issues carried per repository (the newest, where there are more).
pub max_issues: usize,
/// Issue threads read per refresh, over the whole site (one gunnar call
/// each); a thread that did not move is never read again.
pub max_issue_reads: usize,
/// One issue's title, body or note, in bytes.
pub max_issue_text_bytes: usize,
}
impl Default for Limits {
fn default() -> Limits {
Limits {
max_repos: 200,
max_tree_entries: 2_000,
max_tree_reads: 64,
max_readme_bytes: 256 * 1024,
max_issues: 500,
max_issue_reads: 100,
max_issue_text_bytes: 64 * 1024,
}
}
}
/// One thing on offer, as the shop words it (`metered` builds only; the open
/// build's source answers no offer at all).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfferItem {
pub title: String,
pub lines: Vec<String>,
}
/// The offer the public home shows: every word is the shop's, none is this
/// crate's.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Offer {
pub heading: String,
pub items: Vec<OfferItem>,
}
/// The public data, asked for on the timer only.
pub trait Source: Send + Sync + 'static {
/// Every repository the grantless guest may list.
fn public_repos(&self) -> BoxFuture<'_, Result<Vec<RepoRef>, String>>;
/// The default branch's newest commit id, `None` for an empty repository:
/// ONE cheap call, so an unchanged repository is not read again.
fn head<'a>(&'a self, repo: &'a RepoRef) -> BoxFuture<'a, Result<Option<String>, String>>;
/// The bounded snapshot of one repository.
fn snapshot<'a>(&'a self, repo: &'a RepoRef, limits: &'a Limits) -> BoxFuture<'a, Result<RepoSnapshot, String>>;
/// The offer for the public home; `None` in the open build.
fn offer(&self) -> BoxFuture<'_, Option<Offer>> {
Box::pin(async { None })
}
/// **A public repository's issues**, folded, threads left out: ONE call
/// (PUB-ISSUES). None by default: a source that reads no issues publishes none.
fn issues<'a>(&'a self, _repo: &'a RepoRef) -> BoxFuture<'a, Result<Vec<Issue>, String>> {
Box::pin(async { Ok(Vec::new()) })
}
/// One issue's thread after its opening, oldest first: ONE call, asked only
/// for an issue whose list row moved since the last refresh.
fn issue_thread<'a>(&'a self, _repo: &'a RepoRef, _number: u64) -> BoxFuture<'a, Result<Vec<IssueNote>, String>> {
Box::pin(async { Ok(Vec::new()) })
}
}