chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

1667 lines · 52.1 KB Raw History
//! What the web pages show, as JSON, under `/_data` and the page's own
//! path. The pages themselves are a SvelteKit app in `web/`: this server
//! hands every request it does not answer itself to it, and the app reads
//! back here for what to show.
//!
//! Writing is for git, the CLI and agents over MCP; these only show. Each
//! answer's type is written out for the app, as TypeScript, by `cargo test`.

use crate::card::{self, Card};
use crate::diff::{self, Kind, Status};
use crate::objects::{self, Objects};
use crate::{App, AppError, highlight, repo_name};
use axum::Router;
use axum::extract::{Path, Query, Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use knotless::forge::{self, Item, Thread};
use knotless::git::Git;
use knotless::read;
use knotless::records::{RepoRecord, RepoRef};
use knotless::remote::Remote;
use knotless::session;
use knotless::url::RepoUrl;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, Instant};
use ts_rs::TS;

mod feed;
mod pages;

pub use feed::get_feed;
pub use pages::pages;

pub fn routes() -> Router<Arc<App>> {
    Router::new()
        .route("/_data/search", get(search))
        .route("/_data/{authority}", get(profile))
        .route("/_data/{authority}/{repo}", get(code))
        .route("/_data/{authority}/{repo}/tree/{*rest}", get(tree))
        .route("/_data/{authority}/{repo}/blob/{*rest}", get(blob))
        .route("/_data/{authority}/{repo}/commits/{*rest}", get(commits))
        .route("/_data/{authority}/{repo}/commit/{rev}", get(commit))
        .route("/_data/{authority}/{repo}/pulls", get(pulls))
        .route("/_data/{authority}/{repo}/issues", get(issues))
        .route("/_data/{authority}/{repo}/{kind}/{did}/{rkey}", get(item))
        .route("/{authority}/{repo}/raw/{*rest}", get(raw))
        .route("/card.png", get(home_card))
        .route("/{authority}/{repo}/card.png", get(repo_card))
        .route(
            "/{authority}/{repo}/{kind}/{did}/{rkey}/card.png",
            get(item_card_png),
        )
        .layer(middleware::from_fn(renamed))
        .layer(tower_http::compression::CompressionLayer::new())
}

/// Where a repository is now, on the answer to a request that used a name
/// it had before: its name, and its owner if it moved to another account.
#[derive(Clone)]
struct Renamed(String);

/// Points a request made with a repository's old name at the same path
/// under its new one. The redirect is temporary: the old name can be given
/// to another repository.
async fn renamed(req: Request, next: Next) -> Response {
    let path = req.uri().path().to_string();
    let query = req
        .uri()
        .query()
        .map(|q| format!("?{q}"))
        .unwrap_or_default();
    let mut res = next.run(req).await;
    if let Some(Renamed(now)) = res.extensions_mut().remove::<Renamed>() {
        let (prefix, rest) = match path.strip_prefix("/_data") {
            Some(rest) => ("/_data", rest),
            None => ("", path.as_str()),
        };
        // "", the authority, the name, and the rest.
        let mut parts: Vec<&str> = rest.splitn(4, '/').collect();
        match now.split_once('/') {
            Some((owner, name)) => (parts[1], parts[2]) = (owner, name),
            None => parts[2] = &now,
        }
        let to = format!("{prefix}{}{query}", parts.join("/"));
        res.headers_mut()
            .insert(header::LOCATION, HeaderValue::from_str(&to).unwrap());
    }
    res
}

/// Text inlined above, past this, is a link to the raw file instead.
const INLINE: usize = 1024 * 1024;
const COMMITS_PER_PAGE: usize = 50;
/// A file's diff with more rows than this is folded away.
const DIFF_ROWS: usize = 2000;

/// A path for a URL: each segment percent-encoded, the slashes kept.
fn url_path(path: &str) -> String {
    let mut out = String::new();
    for b in path.bytes() {
        match b {
            b'a'..=b'z'
            | b'A'..=b'Z'
            | b'0'..=b'9'
            | b'-'
            | b'.'
            | b'_'
            | b'~'
            | b'/'
            | b':'
            | b'@' => out.push(b as char),
            b => write!(out, "%{b:02X}").unwrap(),
        }
    }
    out
}

/// Caching for a file at `rev`: forever when it names a commit by hash,
/// else checked again each time, as a branch can move.
fn cache_for(rev: &str) -> &'static str {
    if read::is_pinned(rev) {
        "public, max-age=31536000, immutable"
    } else {
        "no-cache"
    }
}

/// `value` as the answer, checked again each time: even at a commit hash
/// it shows the repository as it is now, its authors' accounts, and a shape
/// a later release may change.
fn answer<T: Serialize>(value: T) -> Response {
    let mut res = axum::Json(value).into_response();
    res.headers_mut()
        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
    res
}

/// A failure, as the pages show it: a status and why.
pub struct Shown(StatusCode, String);

impl From<AppError> for Shown {
    fn from(e: AppError) -> Shown {
        Shown(e.0, e.1)
    }
}

#[derive(Serialize, TS)]
#[ts(export)]
pub struct Failure {
    message: String,
}

impl IntoResponse for Shown {
    fn into_response(self) -> Response {
        if self.0 == StatusCode::TEMPORARY_REDIRECT {
            let mut res = self.0.into_response();
            res.extensions_mut().insert(Renamed(self.1));
            return res;
        }
        let mut res = (self.0, axum::Json(Failure { message: self.1 })).into_response();
        res.headers_mut()
            .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
        res
    }
}

fn not_found(e: anyhow::Error) -> Shown {
    Shown(StatusCode::NOT_FOUND, format!("{e:#}"))
}

type Result<T> = std::result::Result<T, Shown>;

static HANDLES: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(Mutex::default);

/// The handle `did` claims, if it resolves back to `did`; else the DID.
async fn handle(app: &App, did: &str) -> String {
    if let Some(known) = HANDLES.lock().unwrap().get(did) {
        return known.clone();
    }
    let shown = knotless::identity::handle(&app.ctx, did)
        .await
        .unwrap_or(did.to_string());
    HANDLES
        .lock()
        .unwrap()
        .insert(did.to_string(), shown.clone());
    shown
}

/// How long what an account says of itself is kept before it is looked up
/// again: its picture, and the addresses it commits as.
const KEPT_FOR: Duration = Duration::from_secs(600);

/// Values looked up over the network, by key, each kept for a while.
struct Kept<V> {
    values: Mutex<HashMap<String, (Instant, V)>>,
    ttl: Duration,
}

impl<V: Clone> Kept<V> {
    fn new(ttl: Duration) -> Kept<V> {
        Kept {
            values: Mutex::default(),
            ttl,
        }
    }

    /// The value kept for `key`, unless it is missing or too old.
    fn get(&self, key: &str) -> Option<V> {
        let values = self.values.lock().unwrap();
        let (at, value) = values.get(key)?;
        (at.elapsed() < self.ttl).then(|| value.clone())
    }

    fn put(&self, key: &str, value: V) {
        let kept = (Instant::now(), value);
        self.values.lock().unwrap().insert(key.to_string(), kept);
    }
}

static AVATARS: LazyLock<Kept<Option<String>>> = LazyLock::new(|| Kept::new(KEPT_FOR));
static CLAIMS: LazyLock<Kept<Vec<String>>> = LazyLock::new(|| Kept::new(KEPT_FOR));

/// Where Bluesky's CDN serves profile pictures, by DID and blob CID.
const AVATAR_CDN: &str = "https://cdn.bsky.app/img/avatar/plain";

/// The account's Bluesky profile record, or nothing, if it has none.
async fn bsky_profile(remote: &Remote) -> serde_json::Value {
    let found = remote
        .get_record::<serde_json::Value>("app.bsky.actor.profile", "self")
        .await;
    found.ok().flatten().map(|r| r.value).unwrap_or_default()
}

/// The picture `about`, `did`'s profile record, names, as Bluesky's CDN
/// serves it.
fn avatar_url(did: &str, about: &serde_json::Value) -> Option<String> {
    let cid = about["avatar"]["ref"]["$link"].as_str()?;
    Some(format!("{AVATAR_CDN}/{did}/{cid}@jpeg"))
}

/// `did`'s profile picture, if it has one: looked up now and then, and
/// again whenever its profile page is shown.
async fn avatar(app: &App, did: &str) -> Option<String> {
    if let Some(known) = AVATARS.get(did) {
        return known;
    }
    let at = RepoUrl {
        authority: did.to_string(),
        name: "_".into(),
    };
    let remote = Remote::connect(&app.ctx, &at).await.ok();
    let about = futures::future::OptionFuture::from(remote.as_ref().map(bsky_profile)).await;
    let found = avatar_url(did, &about.unwrap_or_default());
    AVATARS.put(did, found.clone());
    found
}

/// The addresses `did` claims to commit as, hashed (`knotless::emails`).
async fn claims(app: &App, did: &str) -> Vec<String> {
    if let Some(known) = CLAIMS.get(did) {
        return known;
    }
    let found = knotless::emails::claimed(&app.ctx, did).await;
    let found = found.unwrap_or_default();
    CLAIMS.put(did, found.clone());
    found
}

/// `text` on one line, cut at `n` characters.
fn summary(text: &str, n: usize) -> String {
    let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
    match flat.char_indices().nth(n) {
        Some((at, _)) => format!("{}…", flat[..at].trim_end()),
        None => flat,
    }
}

/// The path of a page's card, under the version of what it shows.
fn card_path(page: &str, card: &Card) -> String {
    format!("{page}/card.png?v={}", card::version(&card.svg()))
}

/// Something shown by name, and its page.
#[derive(Serialize, TS, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Link {
    shown: String,
    href: String,
}

/// `handle/name`, and its page.
fn named(owner: &str, name: &str) -> Link {
    Link {
        shown: format!("{owner}/{name}"),
        href: format!("/{}/{}", url_path(owner), url_path(name)),
    }
}

/// A repository, under the name it has now if the index knows it.
async fn repo_link(app: &App, repo: &RepoRef) -> Option<Link> {
    let (did, _, named_then) = forge::parse_uri(&repo.uri).ok()?;
    let now = app
        .index
        .as_ref()
        .and_then(|i| i.name(did, &repo.key).ok().flatten());
    let name = now.as_deref().unwrap_or(named_then);
    Some(named(&handle(app, did).await, name))
}

/// A commit, as the pages show one.
#[derive(Serialize, TS, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Commit {
    sha: String,
    parents: Vec<String>,
    author: Author,
    /// Who its message's `Co-authored-by` trailers name.
    coauthors: Vec<Author>,
    date: String,
    message: String,
}

/// Who wrote a commit or helped to: by the name git gives, and by account,
/// with its picture, when that is one of the repository's own people whose
/// address it is (`knotless::emails`).
#[derive(Serialize, TS, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Author {
    name: String,
    /// Its handle.
    account: Option<String>,
    avatar: Option<String>,
}

/// One whose claims a repository's pages believe: its owner and its
/// collaborators everywhere in it, and the author of a pull merged into it
/// only in the commits the pull brought in, which the merge vouched for.
struct Vouched {
    did: String,
    /// The addresses it claims, hashed (`knotless::emails`).
    hashes: Vec<String>,
    /// The commits it is believed in, if not all.
    only: Option<HashSet<String>>,
}

/// `name` at `email`, by account if `email` is one of `people`'s in `sha`.
async fn author(app: &App, people: &[Vouched], sha: &str, name: String, email: &str) -> Author {
    let by = people.iter().find(|p| {
        let here = p.only.as_ref().is_none_or(|only| only.contains(sha));
        here && knotless::emails::is(&p.did, email, &p.hashes)
    });
    let (account, avatar) = match by {
        Some(p) => (Some(handle(app, &p.did).await), avatar(app, &p.did).await),
        None => (None, None),
    };
    Author {
        name,
        account,
        avatar,
    }
}

/// The co-authors a commit message's closing paragraph names, as
/// `Co-authored-by: Name <email>` trailers: names and addresses.
fn coauthors(message: &str) -> Vec<(String, String)> {
    let trailers = message
        .trim_end()
        .rsplit_once("\n\n")
        .map_or("", |(_, t)| t);
    trailers.lines().filter_map(coauthor).collect()
}

fn coauthor(line: &str) -> Option<(String, String)> {
    let (key, value) = line.split_once(':')?;
    let named = key.trim().eq_ignore_ascii_case("co-authored-by");
    let (name, email) = value.split_once('<').filter(|_| named)?;
    let email = email.trim_end().strip_suffix('>')?;
    Some((name.trim().to_string(), email.trim().to_string()))
}

/// What a merge as `merged` brought in, as `git rev-list` takes it: from
/// where the base was before it, or, for a merge recorded without that, a
/// merge commit's second parent less its first, which names nothing for
/// any other kind of merge.
fn brought(merged: &str, from: Option<&str>) -> String {
    match from {
        Some(from) => format!("{from}..{merged}"),
        None => format!("{merged}^1..{merged}^2"),
    }
}

static BROUGHT: LazyLock<Mutex<HashMap<String, HashSet<String>>>> = LazyLock::new(Mutex::default);

/// The commits `range` names in `repo`, kept once git has found them: a
/// merge never changes what it brought in.
async fn brought_in(repo: &Repo, range: String) -> HashSet<String> {
    if let Some(known) = BROUGHT.lock().unwrap().get(&range) {
        return known.clone();
    }
    let listed = repo.git.text(&["rev-list", &range], None).await;
    let shas: HashSet<String> = listed
        .iter()
        .flat_map(|l| l.lines())
        .map(String::from)
        .collect();
    if listed.is_ok() {
        BROUGHT.lock().unwrap().insert(range, shas.clone());
    }
    shas
}

/// Whose claims `repo`'s pages believe, and where: its owner and
/// collaborators first, so no pull's author can take their addresses.
async fn vouched(app: &App, repo: &Repo) -> Vec<Vouched> {
    let record = repo.record(app).unwrap_or_default();
    let entries = assembled(app, repo).await.unwrap_or_default();
    let merged = forge::assemble(&record, &repo.did, &entries)
        .into_iter()
        .filter_map(|i| Some((i.author, brought(&i.merged?, i.merged_from.as_deref()))));
    let everywhere =
        std::iter::once(repo.did.clone()).chain(record.collaborators.into_iter().map(|c| c.did));
    let people = everywhere
        .map(|did| (did, None))
        .chain(merged.map(|(did, range)| (did, Some(range))));
    futures::future::join_all(people.map(|(did, range)| async move {
        let only = match range {
            Some(range) => Some(brought_in(repo, range).await),
            None => None,
        };
        let hashes = claims(app, &did).await;
        Vouched { did, hashes, only }
    }))
    .await
}

/// `commits`, as the pages show them, each with the accounts that wrote it
/// where `repo`'s pages believe their claims (`vouched`).
async fn shown(app: &App, repo: &Repo, commits: Vec<read::Commit>) -> Vec<Commit> {
    let people = vouched(app, repo).await;
    let mut out = Vec::new();
    for c in commits {
        let mut helped = Vec::new();
        for (name, email) in coauthors(&c.message) {
            helped.push(author(app, &people, &c.sha, name, &email).await);
        }
        let by = author(app, &people, &c.sha, c.author.name, &c.author.email).await;
        out.push(Commit {
            sha: c.sha,
            parents: c.parents,
            author: by,
            coauthors: helped,
            date: c.author.date,
            message: c.message,
        });
    }
    out
}

/// What every page about a repository shows above itself.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct RepoHead {
    authority: String,
    /// The owner's profile picture, if it has one.
    avatar: Option<String>,
    name: String,
    description: Option<String>,
    fork_of: Option<Link>,
    /// The default branch.
    head: String,
    /// Open pulls and issues.
    pulls: usize,
    issues: usize,
    /// What its pages say of themselves to whatever unfurls a link to
    /// them, and the path of the picture they unfurl into.
    about: String,
    card: String,
}

/// A repository being shown.
struct Repo {
    authority: String,
    name: String,
    did: String,
    key: String,
    git: Git,
    /// The default branch.
    head: String,
    /// Its record as the PDS has it, for a private repository the index
    /// never sees.
    space: Option<RepoRecord>,
}

impl Repo {
    async fn open(app: &App, authority: &str, name: &str) -> Result<Repo> {
        let name = repo_name(name);
        let found = app.located(authority, name).await?;
        if found.moved {
            let now = format!("{}/{}", found.did, found.name);
            return Err(Shown(StatusCode::TEMPORARY_REDIRECT, now));
        }
        if found.name != name {
            return Err(Shown(StatusCode::TEMPORARY_REDIRECT, found.name));
        }
        let git = found.git;
        let head = objects::open(&git.dir)
            .map_err(AppError::internal)?
            .head()
            .unwrap_or_default();
        Ok(Repo {
            authority: authority.to_string(),
            name: name.to_string(),
            did: found.did,
            key: found.key,
            git,
            head,
            space: found.space.then_some(found.record),
        })
    }

    /// Its objects, read in process. Not held across an await: a reader
    /// belongs to one thread.
    fn objects(&self) -> Result<Objects> {
        Ok(objects::open(&self.git.dir).map_err(AppError::internal)?)
    }

    fn href(&self, tail: &str) -> String {
        format!(
            "/{}/{}{tail}",
            url_path(&self.authority),
            url_path(&self.name)
        )
    }

    fn uri(&self) -> String {
        forge::repo_uri(&self.did, &self.name)
    }

    /// The record, as the index has it, or the space for a private one.
    fn record(&self, app: &App) -> Option<RepoRecord> {
        if self.space.is_some() {
            return self.space.clone();
        }
        let value = app.index.as_ref()?.repo(&self.uri()).ok()??;
        serde_json::from_value(value).ok()
    }

    /// Its open pulls and issues.
    async fn open_items(&self, app: &App) -> Option<(usize, usize)> {
        let entries = assembled(app, self).await.ok()?;
        let record = self.record(app).unwrap_or_default();
        let items = forge::assemble(&record, &self.did, &entries);
        let open = |kind: &str| {
            items
                .iter()
                .filter(|i| i.kind == kind && i.state == "open")
                .count()
        };
        Some((open("pull"), open("issue")))
    }

    async fn head(&self, app: &App) -> RepoHead {
        let record = self.record(app);
        let fork_of = match record.as_ref().and_then(|r| r.fork_of.as_ref()) {
            Some(from) => repo_link(app, from).await,
            None => None,
        };
        let open = self.open_items(app).await.unwrap_or_default();
        let card = card_path(&self.href(""), &self.card(record.as_ref(), open));
        let description = record.and_then(|r| r.description);
        let about = description.clone().unwrap_or_else(|| {
            format!(
                "A git repository in {}'s atmosphere account.",
                self.authority
            )
        });
        RepoHead {
            authority: self.authority.clone(),
            avatar: avatar(app, &self.did).await,
            name: self.name.clone(),
            description,
            fork_of,
            head: self.head.clone(),
            pulls: open.0,
            issues: open.1,
            about,
            card,
        }
    }

    /// Its card, as `record` and the counts of open pulls and issues say.
    fn card(&self, record: Option<&RepoRecord>, (pulls, issues): (usize, usize)) -> Card {
        Card::Repo {
            owner: self.authority.clone(),
            name: self.name.clone(),
            description: record.and_then(|r| r.description.clone()),
            pulls,
            issues,
        }
    }

    /// Splits `rest` into a revision and a path: the longest branch or tag
    /// that starts it, else its first segment.
    fn locate<'a>(&self, rest: &'a str) -> Result<(String, &'a str)> {
        let names = self.objects()?.names().map_err(AppError::internal)?;
        let longest = names
            .iter()
            .filter(|n| rest == n.as_str() || rest.starts_with(&format!("{n}/")))
            .map(String::len)
            .max();
        let cut = longest.unwrap_or(rest.find('/').unwrap_or(rest.len()));
        let rev = read::rev(Some(&rest[..cut])).map_err(not_found)?;
        Ok((rev, rest[cut..].trim_start_matches('/')))
    }
}

#[derive(Deserialize, Default)]
pub struct SearchQuery {
    q: Option<String>,
}

/// A repository among others: whose, its name, and what it is.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Listed {
    owner: String,
    /// The owner's profile picture, if it has one.
    avatar: Option<String>,
    name: String,
    description: Option<String>,
    fork_of: Option<Link>,
    created_at: String,
    /// Whether it is private, in a space: shown only serving `--private`.
    private: bool,
}

#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Search {
    query: String,
    repos: Vec<Listed>,
}

/// The repositories whose name or description holds `q`, newest first.
async fn search(State(app): State<Arc<App>>, Query(query): Query<SearchQuery>) -> Result<Response> {
    let index = app.index.as_ref().ok_or(Shown(
        StatusCode::NOT_FOUND,
        "This server keeps no index, so it knows no repositories to search.".into(),
    ))?;
    let q = query.q.unwrap_or_default().trim().to_string();
    let found = index
        .repos(&q, 50)
        .map_err(anyhow::Error::from)
        .map_err(AppError::internal)?;
    let owners = futures::future::join_all(found.iter().map(|r| handle(&app, &r.did))).await;
    let avatars = futures::future::join_all(found.iter().map(|r| avatar(&app, &r.did))).await;
    let repos = found
        .into_iter()
        .zip(owners.into_iter().zip(avatars))
        .map(|(r, (owner, avatar))| Listed {
            owner,
            avatar,
            name: r.name,
            description: r.description,
            fork_of: None,
            created_at: r.created_at,
            private: false,
        })
        .collect();
    Ok(answer(Search { query: q, repos }))
}

/// An account: who it is, as its Bluesky profile says if it has one, and
/// its public repositories, read from its PDS, newest first. Serving
/// `--private`, the account logged in sees its private ones too.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Profile {
    did: String,
    handle: String,
    name: Option<String>,
    bio: Option<String>,
    /// Its profile picture, as Bluesky's CDN serves it, if it has one.
    avatar: Option<String>,
    /// Its bio on one line, for whatever unfurls a link to it.
    about: Option<String>,
    repos: Vec<Listed>,
}

async fn profile(State(app): State<Arc<App>>, Path(authority): Path<String>) -> Result<Response> {
    let at = RepoUrl {
        authority: authority.clone(),
        name: "_".into(),
    };
    let remote = Remote::connect(&app.ctx, &at).await.map_err(not_found)?;
    let did = remote.did().to_string();
    let shown = handle(&app, &did).await;
    let about = bsky_profile(&remote).await;
    let text = |field: &str| about[field].as_str().map(str::to_string);
    let name = text("displayName").filter(|n| !n.trim().is_empty());
    let avatar = avatar_url(&did, &about);
    AVATARS.put(&did, avatar.clone());
    let mut found = remote.list_repos().await.map_err(AppError::upstream)?;
    let mut private = Vec::new();
    if app.private
        && let Ok(me) = session::me(&app.ctx).await
        && me.did == did
    {
        let mine = Remote::private_repos(&app.ctx, &me)
            .await
            .map_err(AppError::upstream)?;
        private = mine.iter().map(|r| r.rkey.clone()).collect();
        found.extend(mine);
    }
    found.sort_by(|a, b| b.value.created_at.cmp(&a.value.created_at));
    let forks = futures::future::join_all(found.iter().map(|r| async {
        match &r.value.fork_of {
            Some(from) => repo_link(&app, from).await,
            None => None,
        }
    }))
    .await;
    let repos = found
        .into_iter()
        .zip(forks)
        .map(|(r, fork_of)| Listed {
            private: private.contains(&r.rkey),
            owner: shown.clone(),
            avatar: avatar.clone(),
            name: r.value.name,
            description: r.value.description,
            fork_of,
            created_at: r.value.created_at,
        })
        .collect();
    Ok(answer(Profile {
        did,
        handle: shown,
        name,
        bio: text("description"),
        avatar,
        about: about["description"].as_str().map(|bio| summary(bio, 200)),
        repos,
    }))
}

type RepoPath = Path<(String, String)>;
type RestPath = Path<(String, String, String)>;

/// One entry of a directory.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Entry {
    name: String,
    /// `tree` or `blob`.
    kind: String,
    size: Option<u64>,
}

/// A README, as Markdown made into HTML or as text.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Readme {
    name: String,
    html: Option<String>,
    text: Option<String>,
}

/// A directory at a revision: its latest commit, its entries, folders
/// first, and its README. Nothing at all, for a repository never pushed.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Listing {
    repo: RepoHead,
    rev: String,
    path: String,
    empty: bool,
    last: Option<Commit>,
    entries: Vec<Entry>,
    readme: Option<Readme>,
}

async fn code(State(app): State<Arc<App>>, Path((authority, name)): RepoPath) -> Result<Response> {
    let repo = Repo::open(&app, &authority, &name).await?;
    let head = repo.head.clone();
    if repo.objects()?.tree("HEAD", "").is_err() {
        let empty = Listing {
            repo: repo.head(&app).await,
            rev: head,
            path: String::new(),
            empty: true,
            last: None,
            entries: Vec::new(),
            readme: None,
        };
        return Ok(answer(empty));
    }
    listing(&app, &repo, &head, "").await
}

async fn tree(
    State(app): State<Arc<App>>,
    Path((authority, name, rest)): RestPath,
) -> Result<Response> {
    let repo = Repo::open(&app, &authority, &name).await?;
    let (rev, path) = repo.locate(&rest)?;
    listing(&app, &repo, &rev, path).await
}

async fn listing(app: &App, repo: &Repo, rev: &str, path: &str) -> Result<Response> {
    let head = repo.head(app).await;
    let o = repo.objects()?;
    let mut found = o.tree(rev, path).map_err(not_found)?;
    found.sort_by_key(|e| (e.kind != "tree", e.name.to_lowercase()));
    let last = o.log(rev, path, 0, 1).map_err(not_found)?;
    let prefix = if path.is_empty() {
        String::new()
    } else {
        format!("{path}/")
    };
    let readme = match found
        .iter()
        .find(|e| e.kind == "blob" && e.name.to_lowercase().starts_with("readme"))
    {
        Some(readme) => {
            let bytes = o
                .blob(rev, &format!("{prefix}{}", readme.name))
                .map_err(not_found)?;
            let text = String::from_utf8_lossy(&bytes).into_owned();
            let dir = |view: &str| {
                repo.href(&format!("/{view}/{}/{}", url_path(rev), url_path(path)))
                    .trim_end_matches('/')
                    .to_string()
            };
            let html = readme
                .name
                .to_lowercase()
                .ends_with(".md")
                .then(|| markdown(&text, &dir("blob"), &dir("raw")));
            Some(Readme {
                name: readme.name.clone(),
                text: html.is_none().then_some(text),
                html,
            })
        }
        None => None,
    };
    let entries = found
        .into_iter()
        .map(|e| Entry {
            name: e.name,
            kind: e.kind,
            size: e.size,
        })
        .collect();
    Ok(answer(Listing {
        repo: head,
        rev: rev.to_string(),
        path: path.to_string(),
        empty: false,
        last: shown(app, repo, last).await.pop(),
        entries,
        readme,
    }))
}

/// Markdown, with raw HTML shown as text and links made relative to the
/// directory it was found in.
fn markdown(text: &str, blobs: &str, raws: &str) -> String {
    use pulldown_cmark::{Event, Options, Parser, Tag};
    let options =
        Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS;
    let events = Parser::new_ext(text, options).map(|event| match event {
        Event::Html(html) | Event::InlineHtml(html) => Event::Text(html),
        Event::Start(Tag::Link {
            link_type,
            dest_url,
            title,
            id,
        }) => Event::Start(Tag::Link {
            link_type,
            dest_url: link(&dest_url, blobs).into(),
            title,
            id,
        }),
        Event::Start(Tag::Image {
            link_type,
            dest_url,
            title,
            id,
        }) => Event::Start(Tag::Image {
            link_type,
            dest_url: link(&dest_url, raws).into(),
            title,
            id,
        }),
        other => other,
    });
    let mut out = String::new();
    pulldown_cmark::html::push_html(&mut out, events);
    out
}

/// A link from a README: web and mail links as they are, other schemes
/// nowhere, and paths into the repository.
fn link(dest: &str, base: &str) -> String {
    let scheme = dest
        .split_once(':')
        .map(|(s, _)| s)
        .filter(|s| !s.contains(['/', '#', '?']));
    match scheme.map(str::to_ascii_lowercase).as_deref() {
        Some("http" | "https" | "mailto") => dest.to_string(),
        Some(_) => "#".to_string(),
        None if dest.starts_with(['#', '/']) => dest.to_string(),
        None => format!("{base}/{}", dest.trim_start_matches("./")),
    }
}

fn is_image(path: &str) -> Option<&'static str> {
    let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?;
    Some(match ext.as_str() {
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "gif" => "image/gif",
        "webp" => "image/webp",
        "avif" => "image/avif",
        "svg" => "image/svg+xml",
        "ico" => "image/x-icon",
        _ => return None,
    })
}

fn is_binary(bytes: &[u8]) -> bool {
    bytes[..bytes.len().min(8000)].contains(&0) || std::str::from_utf8(bytes).is_err()
}

/// One file at a revision: an image, a binary, too large to show, or text
/// with the ranges its highlighting paints.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Blob {
    repo: RepoHead,
    rev: String,
    path: String,
    /// `image`, `binary`, `large` or `text`.
    shown: String,
    size: u64,
    lines: usize,
    text: Option<String>,
    ranges: Option<String>,
}

async fn blob(
    State(app): State<Arc<App>>,
    Path((authority, name, rest)): RestPath,
) -> Result<Response> {
    let repo = Repo::open(&app, &authority, &name).await?;
    let (rev, path) = repo.locate(&rest)?;
    let bytes = repo.objects()?.blob(&rev, path).map_err(not_found)?;
    let text = String::from_utf8_lossy(&bytes);
    let shown = if is_image(path).is_some() {
        "image"
    } else if is_binary(&bytes) {
        "binary"
    } else if bytes.len() > INLINE {
        "large"
    } else {
        "text"
    };
    let ranges = (shown == "text").then(|| {
        highlight::code(path, &text)
            .map(|r| r.encode())
            .unwrap_or_default()
    });
    Ok(answer(Blob {
        repo: repo.head(&app).await,
        path: path.to_string(),
        shown: shown.to_string(),
        size: bytes.len() as u64,
        lines: text.lines().count().max(1),
        text: ranges.is_some().then(|| text.into_owned()),
        ranges,
        rev,
    }))
}

/// The file itself. Never run as a page: sandboxed, and typed only as text
/// or an image.
async fn raw(
    State(app): State<Arc<App>>,
    Path((authority, name, rest)): RestPath,
) -> Result<Response> {
    let repo = Repo::open(&app, &authority, &name).await?;
    let (rev, path) = repo.locate(&rest)?;
    let bytes = repo.objects()?.blob(&rev, path).map_err(not_found)?;
    let kind = match is_image(path) {
        Some(image) => image,
        None if is_binary(&bytes) => "application/octet-stream",
        None => "text/plain; charset=utf-8",
    };
    Ok((
        [
            (header::CONTENT_TYPE, kind),
            (header::CACHE_CONTROL, cache_for(&rev)),
            (
                header::CONTENT_SECURITY_POLICY,
                "sandbox; default-src 'none'; style-src 'unsafe-inline'",
            ),
            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
        ],
        bytes,
    )
        .into_response())
}

#[derive(Deserialize, Default)]
pub struct PageQuery {
    page: Option<usize>,
}

/// History, a page at a time, newest first.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct History {
    repo: RepoHead,
    rev: String,
    path: String,
    page: usize,
    commits: Vec<Commit>,
    /// Whether there may be older commits than these.
    more: bool,
}

async fn commits(
    State(app): State<Arc<App>>,
    Path((authority, name, rest)): RestPath,
    Query(q): Query<PageQuery>,
) -> Result<Response> {
    let repo = Repo::open(&app, &authority, &name).await?;
    let (rev, path) = repo.locate(&rest)?;
    let page = q.page.unwrap_or(0);
    let log = repo
        .objects()?
        .log(&rev, path, page * COMMITS_PER_PAGE, COMMITS_PER_PAGE)
        .map_err(not_found)?;
    Ok(answer(History {
        repo: repo.head(&app).await,
        path: path.to_string(),
        page,
        more: log.len() == COMMITS_PER_PAGE,
        commits: shown(&app, &repo, log).await,
        rev,
    }))
}

/// One row of a file's diff: a hunk header, a note, or a line with its old
/// and new numbers.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Row {
    /// `h`, `x`, `a`, `d`, or empty for context.
    kind: String,
    line: bool,
    old: Option<u32>,
    new: Option<u32>,
    text: String,
}

/// One file of a diff, or why its rows are not shown.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct FileDiff {
    old: String,
    new: String,
    /// `added`, `deleted`, `renamed` or `modified`.
    status: String,
    added: usize,
    removed: usize,
    note: Option<String>,
    ranges: Option<String>,
    rows: Vec<Row>,
}

fn file_diff(f: diff::File) -> FileDiff {
    let note = if f.binary {
        Some("A binary file, not shown.")
    } else if f.rows.is_empty() {
        Some(if f.status == Status::Renamed {
            "Moved without changes."
        } else {
            "No changes to the content."
        })
    } else if f.rows.len() > DIFF_ROWS {
        Some("A large diff, not shown.")
    } else {
        None
    };
    let path = if f.status == Status::Deleted {
        &f.old
    } else {
        &f.new
    };
    let ranges = note
        .is_none()
        .then(|| highlight::rows(path, &f.rows).encode());
    let rows = match note {
        Some(_) => Vec::new(),
        None => f.rows.into_iter().map(row).collect(),
    };
    let status = match f.status {
        Status::Added => "added",
        Status::Deleted => "deleted",
        Status::Renamed => "renamed",
        Status::Modified => "modified",
    };
    FileDiff {
        old: f.old,
        new: f.new,
        status: status.to_string(),
        added: f.added,
        removed: f.removed,
        note: note.map(str::to_string),
        ranges,
        rows,
    }
}

fn row(r: diff::Row) -> Row {
    let kind = match r.kind {
        Kind::Hunk => "h",
        Kind::Note => "x",
        Kind::Add => "a",
        Kind::Del => "d",
        Kind::Context => "",
    };
    Row {
        kind: kind.to_string(),
        line: r.kind.is_line(),
        old: r.old,
        new: r.new,
        text: r.text,
    }
}

/// A commit and what it changed.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Change {
    repo: RepoHead,
    commit: Commit,
    files: Vec<FileDiff>,
}

async fn commit(
    State(app): State<Arc<App>>,
    Path((authority, name, rev)): RestPath,
) -> Result<Response> {
    let repo = Repo::open(&app, &authority, &name).await?;
    let rev = read::rev(Some(&rev)).map_err(not_found)?;
    let log = repo.objects()?.log(&rev, "", 0, 1).map_err(not_found)?;
    let c = shown(&app, &repo, log).await.remove(0);
    let patch = repo
        .git
        .text(
            &[
                "-c",
                "core.quotePath=false",
                "show",
                "--format=",
                "--patch",
                "-M",
                "--no-color",
                "--no-ext-diff",
                &c.sha,
            ],
            None,
        )
        .await
        .map_err(not_found)?;
    let files = diff::parse(&patch).into_iter().map(file_diff).collect();
    Ok(answer(Change {
        repo: repo.head(&app).await,
        commit: c,
        files,
    }))
}

#[derive(Deserialize, Default)]
pub struct StateQuery {
    state: Option<String>,
}

async fn pulls(
    State(app): State<Arc<App>>,
    Path((authority, name)): RepoPath,
    Query(q): Query<StateQuery>,
) -> Result<Response> {
    items(app, authority, name, "pull", q).await
}

async fn issues(
    State(app): State<Arc<App>>,
    Path((authority, name)): RepoPath,
    Query(q): Query<StateQuery>,
) -> Result<Response> {
    items(app, authority, name, "issue", q).await
}

/// Everything known about the repository's pulls and issues: from the
/// index, or for a private one from its members' repos in the space.
async fn assembled(app: &App, repo: &Repo) -> Result<Vec<forge::Entry>> {
    if repo.space.is_some() {
        let url = RepoUrl {
            authority: repo.did.clone(),
            name: repo.name.clone(),
        };
        let target = forge::Target::open(&app.ctx, &url)
            .await
            .map_err(AppError::upstream)?;
        return Ok(target.entries(&app.ctx).await.map_err(AppError::upstream)?);
    }
    let index = app.index.as_ref().ok_or(Shown(
        StatusCode::NOT_FOUND,
        "This server keeps no index, so it knows no pulls or issues.".into(),
    ))?;
    let entries = index
        .forge(&repo.did, &repo.key)
        .map_err(anyhow::Error::from)
        .map_err(AppError::internal)?;
    Ok(entries)
}

/// A pull or an issue in a list of them.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Summary {
    title: String,
    state: String,
    href: String,
    author: String,
    created_at: String,
    comments: usize,
}

/// A repository's pulls or issues, open or closed.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Items {
    repo: RepoHead,
    /// `pull` or `issue`.
    kind: String,
    /// Whether these are the open ones, rather than the closed.
    open: bool,
    opened: usize,
    closed: usize,
    items: Vec<Summary>,
}

async fn items(
    app: Arc<App>,
    authority: String,
    name: String,
    kind: &str,
    q: StateQuery,
) -> Result<Response> {
    let repo = Repo::open(&app, &authority, &name).await?;
    let entries = assembled(&app, &repo).await?;
    let record = repo.record(&app).unwrap_or_default();
    let all: Vec<Item> = forge::assemble(&record, &repo.did, &entries)
        .into_iter()
        .filter(|i| i.kind == kind)
        .collect();
    let open = q.state.as_deref() != Some("closed");
    let (opened, closed): (Vec<Item>, Vec<Item>) = all.into_iter().partition(|i| i.state == "open");
    let (counts, shown) = (
        (opened.len(), closed.len()),
        if open { opened } else { closed },
    );
    let authors = futures::future::join_all(shown.iter().map(|i| handle(&app, &i.author))).await;
    let mut items = Vec::new();
    for (i, author) in shown.into_iter().zip(authors) {
        let (did, _, rkey) = forge::parse_uri(&i.uri).map_err(not_found)?;
        items.push(Summary {
            href: repo.href(&format!("/{kind}/{did}/{rkey}")),
            title: i.title,
            state: i.state,
            author,
            created_at: i.created_at,
            comments: i.comments,
        });
    }
    Ok(answer(Items {
        repo: repo.head(&app).await,
        kind: kind.to_string(),
        open,
        opened: counts.0,
        closed: counts.1,
        items,
    }))
}

/// Something said on a pull or an issue, made into HTML.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Said {
    author: String,
    /// The author's profile picture, if it has one.
    avatar: Option<String>,
    created_at: String,
    html: String,
    /// `approve` or `changes`, for a review.
    verdict: Option<String>,
}

/// A pull or an issue, and everything said on it.
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Discussion {
    repo: RepoHead,
    /// `pull` or `issue`.
    kind: String,
    title: String,
    state: String,
    author: String,
    /// The author's profile picture, if it has one.
    avatar: Option<String>,
    created_at: String,
    /// A pull's target branch, and the repository and branch it brings.
    base: Option<String>,
    from: Option<String>,
    branch: Option<String>,
    /// What it says, as its author opened it.
    body: Option<String>,
    comments: Vec<Said>,
    /// The merge, for a merged pull.
    merged: Option<String>,
    /// What it says of itself to whatever unfurls a link to it, and the
    /// path of the picture it unfurls into.
    about: String,
    card: String,
}

type ItemPath = Path<(String, String, String, String, String)>;

/// A pull or an issue, by the kind its path names, and who opened it.
async fn thread(
    app: &App,
    repo: &Repo,
    kind: &str,
    did: &str,
    rkey: &str,
) -> Result<(Thread, String)> {
    let collection = match kind {
        "pull" => knotless::records::nsid::PULL,
        "issue" => knotless::records::nsid::ISSUE,
        _ => {
            return Err(Shown(
                StatusCode::NOT_FOUND,
                format!("no such page: {kind}"),
            ));
        }
    };
    let entries = assembled(app, repo).await?;
    let record = repo.record(app).unwrap_or_default();
    let uri = format!("at://{did}/{collection}/{rkey}");
    let thread = forge::thread(&record, &repo.did, &entries, &uri).map_err(not_found)?;
    let author = handle(app, &thread.item.author).await;
    Ok((thread, author))
}

fn item_card(repo: &Repo, kind: &str, thread: &Thread, author: &str) -> Card {
    Card::Item {
        owner: repo.authority.clone(),
        name: repo.name.clone(),
        pull: kind == "pull",
        state: thread.item.state.clone(),
        title: thread.item.title.clone(),
        author: author.to_string(),
        comments: thread.comments.len(),
    }
}

async fn item(
    State(app): State<Arc<App>>,
    Path((authority, name, kind, did, rkey)): ItemPath,
) -> Result<Response> {
    let repo = Repo::open(&app, &authority, &name).await?;
    let (thread, author) = self::thread(&app, &repo, &kind, &did, &rkey).await?;
    let page = repo.href(&format!("/{kind}/{did}/{rkey}"));
    let card = card_path(&page, &item_card(&repo, &kind, &thread, &author));
    let Thread { item, comments, .. } = thread;
    let what = if kind == "pull" { "A pull" } else { "An issue" };
    let about = match &item.body {
        Some(text) => summary(text, 200),
        None => format!("{what} on {}/{} by {author}.", repo.authority, repo.name),
    };
    let from = match &item.head {
        Some(head) => repo_link(&app, &head.repo).await.map(|l| l.shown),
        None => None,
    };
    let base = repo.href("");
    let names = futures::future::join_all(comments.iter().map(|c| handle(&app, &c.author))).await;
    let avatars = futures::future::join_all(comments.iter().map(|c| avatar(&app, &c.author))).await;
    let said = comments
        .iter()
        .zip(names.into_iter().zip(avatars))
        .map(|(c, (author, avatar))| Said {
            author,
            avatar,
            created_at: c.value["createdAt"].as_str().unwrap_or_default().into(),
            html: markdown(c.value["body"].as_str().unwrap_or_default(), &base, &base),
            verdict: c.value["verdict"].as_str().map(str::to_string),
        })
        .collect();
    Ok(answer(Discussion {
        repo: repo.head(&app).await,
        kind,
        body: item.body.as_deref().map(|b| markdown(b, &base, &base)),
        title: item.title,
        state: item.state,
        author,
        avatar: avatar(&app, &item.author).await,
        created_at: item.created_at,
        from,
        branch: item.head.map(|h| h.branch),
        base: item.base,
        comments: said,
        merged: item.merged,
        about,
        card,
    }))
}

static CARDS: LazyLock<card::Cache> = LazyLock::new(|| card::Cache::new(512));

#[derive(Deserialize, Default)]
pub struct CardQuery {
    v: Option<String>,
}

/// A card as a PNG: cached for good under the version its page names, and
/// checked again under any other, as it may have changed since.
async fn card_png(card: Card, asked: Option<String>, headers: &HeaderMap) -> Response {
    let svg = card.svg();
    let version = card::version(&svg);
    let tag = format!("\"{version}\"");
    let cache = if asked.as_deref() == Some(version.as_str()) {
        "public, max-age=31536000, immutable"
    } else {
        "no-cache"
    };
    let mut res = if headers
        .get(header::IF_NONE_MATCH)
        .is_some_and(|v| v.as_bytes() == tag.as_bytes())
    {
        StatusCode::NOT_MODIFIED.into_response()
    } else {
        let png = CARDS.png(&version, svg).await;
        let mut res = axum::body::Body::from(axum::body::Bytes::from_owner(png)).into_response();
        res.headers_mut()
            .insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
        res
    };
    let h = res.headers_mut();
    h.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
    h.insert(header::ETAG, HeaderValue::from_str(&tag).unwrap());
    res
}

async fn home_card(headers: HeaderMap, Query(q): Query<CardQuery>) -> Response {
    card_png(Card::Home, q.v, &headers).await
}

async fn repo_card(
    State(app): State<Arc<App>>,
    Path((authority, name)): RepoPath,
    headers: HeaderMap,
    Query(q): Query<CardQuery>,
) -> Result<Response> {
    let repo = Repo::open(&app, &authority, &name).await?;
    let open = repo.open_items(&app).await.unwrap_or_default();
    let card = repo.card(repo.record(&app).as_ref(), open);
    Ok(card_png(card, q.v, &headers).await)
}

async fn item_card_png(
    State(app): State<Arc<App>>,
    Path((authority, name, kind, did, rkey)): ItemPath,
    headers: HeaderMap,
    Query(q): Query<CardQuery>,
) -> Result<Response> {
    let repo = Repo::open(&app, &authority, &name).await?;
    let (thread, author) = self::thread(&app, &repo, &kind, &did, &rkey).await?;
    let card = item_card(&repo, &kind, &thread, &author);
    Ok(card_png(card, q.v, &headers).await)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn what_merges_brought() {
        assert_eq!(brought("m", Some("f")), "f..m");
        assert_eq!(brought("m", None), "m^1..m^2");
    }

    #[test]
    fn coauthor_trailers() {
        let message = "Fix it\n\nCo-authored-by: Claude <noreply@anthropic.com>\nSigned-off-by: A <a@x>\nco-authored-by:  B  < b@x > \nCo-authored-by: nobody\n";
        let named = |n: &str, e: &str| (n.to_string(), e.to_string());
        assert_eq!(
            coauthors(message),
            [named("Claude", "noreply@anthropic.com"), named("B", "b@x")]
        );
        assert!(coauthors("Co-authored-by: A <a@x>").is_empty());
        assert!(coauthors("Subject\n\nCo-authored-by: A <a@x").is_empty());
    }

    #[test]
    fn kept_values_go_stale() {
        let stale = Kept::new(Duration::ZERO);
        stale.put("k", 1);
        assert_eq!(stale.get("k"), None);
        let fresh = Kept::new(KEPT_FOR);
        fresh.put("k", 1);
        assert_eq!((fresh.get("k"), fresh.get("j")), (Some(1), None));
    }

    #[test]
    fn readme_links() {
        let base = "/a/r/blob/main";
        assert_eq!(link("https://x.dev", base), "https://x.dev");
        assert_eq!(link("MAILTO:a@b.c", base), "MAILTO:a@b.c");
        assert_eq!(link("javascript:alert(1)", base), "#");
        assert_eq!(link("#usage", base), "#usage");
        assert_eq!(link("/elsewhere", base), "/elsewhere");
        assert_eq!(link("./docs/a.md", base), "/a/r/blob/main/docs/a.md");
        assert_eq!(
            link("docs/a.md?x=y:z", base),
            "/a/r/blob/main/docs/a.md?x=y:z"
        );
        let html = markdown(
            "<script>x</script>\n\n[a](b.md) ![i](i.png) <b>t</b>",
            "/B",
            "/R",
        );
        assert!(
            html.contains("&lt;script&gt;") && html.contains("&lt;b&gt;"),
            "{html}"
        );
        assert!(
            html.contains(r#"href="/B/b.md""#) && html.contains(r#"src="/R/i.png""#),
            "{html}"
        );
    }

    #[test]
    fn summaries() {
        assert_eq!(summary("  one\n\ntwo  ", 200), "one two");
        assert_eq!(summary("one two three", 8), "one two…");
        assert_eq!(summary("ab", 2), "ab");
    }

    #[test]
    fn small_things() {
        assert_eq!(url_path("a b/c#d?é"), "a%20b/c%23d%3F%C3%A9");
        assert_eq!(is_image("a/B.PNG"), Some("image/png"));
        assert_eq!(is_image("Makefile"), None);
        assert!(is_binary(&[0, 1]) && is_binary(&[0xff, 0xfe]) && !is_binary(b"text"));
    }

    #[test]
    fn files_of_a_diff() {
        let file = |status, binary, rows: usize| diff::File {
            old: "a.rs".into(),
            new: "b.rs".into(),
            status,
            binary,
            rows: (0..rows)
                .map(|i| diff::Row {
                    kind: Kind::Add,
                    old: None,
                    new: Some(i as u32),
                    text: "x".into(),
                })
                .collect(),
            added: rows,
            removed: 0,
        };
        let note = |f| file_diff(f).note.unwrap_or_default();
        assert_eq!(
            note(file(Status::Renamed, false, 0)),
            "Moved without changes."
        );
        assert_eq!(
            note(file(Status::Modified, false, 0)),
            "No changes to the content."
        );
        assert_eq!(
            note(file(Status::Added, true, 0)),
            "A binary file, not shown."
        );
        let large = file_diff(file(Status::Deleted, false, DIFF_ROWS + 1));
        assert_eq!(large.note.as_deref(), Some("A large diff, not shown."));
        assert!(large.rows.is_empty() && large.ranges.is_none());
        assert_eq!(large.status, "deleted");
        let row = |kind, old, new, text: &str| diff::Row {
            kind,
            old,
            new,
            text: text.into(),
        };
        let mut mixed = file(Status::Modified, false, 0);
        mixed.rows = vec![
            row(Kind::Hunk, None, None, "@@ -1,2 +1,2 @@"),
            row(Kind::Context, Some(1), Some(1), "a"),
            row(Kind::Del, Some(2), None, "b"),
            row(Kind::Note, None, None, "\\ No newline at end of file"),
            row(Kind::Add, None, Some(2), "c"),
        ];
        let shown = file_diff(mixed);
        let kinds: Vec<(&str, bool)> = shown
            .rows
            .iter()
            .map(|r| (r.kind.as_str(), r.line))
            .collect();
        assert_eq!(
            kinds,
            [
                ("h", false),
                ("", true),
                ("d", true),
                ("x", false),
                ("a", true)
            ]
        );
        assert!(shown.note.is_none() && shown.ranges.is_some());
        assert_eq!(file_diff(file(Status::Added, false, 1)).status, "added");
        assert_eq!(file_diff(file(Status::Renamed, false, 1)).status, "renamed");
    }
}