Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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()) })
    }
}