chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
//! 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)  <b>t</b>",
"/B",
"/R",
);
assert!(
html.contains("<script>") && html.contains("<b>"),
"{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");
}
}