OXYGENHUB

Coverage / crates/hub/src/repos/view.rs 93.3%

1031 of 1105 lines run, 182 of 365 functions · run #534 on main · run missed

1
//! Page-ready views of a repository, computed from `oxy-core` alone (hub
2
//! spec §1.4). Nothing here knows about HTTP or templates; every function
3
//! takes an open `Repo` and returns plain data.
4
5
use super::{RepoError, StateCache};
6
use oxy_core::hash::{ChangeHash, KeyId};
7
use oxy_core::objects::{Change, NodeKind};
8
use oxy_core::repo::{BlameLine, FileDiff, Repo, SignatureStatus};
9
use oxy_core::state::{FileContent, Snapshot};
10
use oxy_core::tree::TreeConflict;
11
use std::collections::{BTreeMap, BTreeSet, HashMap};
12
13
type Result<T> = std::result::Result<T, RepoError>;
14
152×
fn fail(msg: impl Into<String>) -> RepoError {
162×
    RepoError::Oxy(oxy_core::repo::RepoError::Msg(msg.into()))
172×
}
18
19
// ------------------------------------------------------------------ refs
20
21
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22
pub enum RefKind {
23
    Bookmark,
24
    Tag,
25
    Change,
26
}
27
28
/// What a `{ref}` path segment resolved to.
29
#[derive(Clone, Debug)]
30
pub struct RefTarget {
31
    /// The name as given (bookmark, tag) or the full hash.
32
    pub label: String,
33
    pub kind: RefKind,
34
    pub heads: Vec<ChangeHash>,
35
}
36
37
/// Most heads a state address or a "before this change" link carries.
38
pub const MAX_STATE_HEADS: usize = 32;
39
40
impl RefTarget {
41
    /// The label for a ref bar: the name, or `# abc123…` for a change,
42
    /// with `+N` for the further heads of a state.
4343×
    pub fn short_label(&self) -> String {
4443×
        match self.kind {
45
            RefKind::Change => {
465×
                let mut parts = self.label.split('+');
475×
                let first: String = parts.next().unwrap_or("").chars().take(12).collect();
485×
                match parts.count() {
492×
                    0 => first,
503×
                    n => format!("{first} +{n}"),
51
                }
52
            }
5338×
            _ => self.label.clone(),
54
        }
5543×
    }
56
57
    /// The `{ref}` that names this target's state for good: its heads
58
    /// joined with `+` (hub spec §6.6). None for an empty state.
5917×
    pub fn permalink(&self) -> Option<String> {
6017×
        state_spec(&self.heads)
6117×
    }
62
}
63
64
/// Heads as a state address, sorted so one state has one address.
6532×
pub fn state_spec(heads: &[ChangeHash]) -> Option<String> {
6632×
    if heads.is_empty() || heads.len() > MAX_STATE_HEADS {
676×
        return None;
6826×
    }
6955×
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
7026×
    hex.sort();
7126×
    hex.dedup();
7226×
    Some(hex.join("+"))
7332×
}
74
75
/// Resolve a bookmark name, a tag name, a change hash prefix, or a state
76
/// (full hashes joined with `+`).
77223×
pub fn resolve(repo: &Repo, cache: &StateCache, spec: &str) -> Result<RefTarget> {
78223×
    if spec.contains('+') {
793×
        let parts: Vec<&str> = spec.split('+').collect();
803×
        if parts.len() > MAX_STATE_HEADS {
810×
            return Err(fail(format!("a state address has at most {MAX_STATE_HEADS} heads")));
823×
        }
833×
        let mut heads = Vec::with_capacity(parts.len());
8410×
        for p in parts {
8510×
            match ChangeHash::from_hex(p) {
8610×
                Some(h) if p.len() == 64 && repo.store().has(&h) => heads.push(h),
870×
                _ => return Err(fail(format!("{p:?} is not a change of this repository"))),
88
            }
89
        }
903×
        heads.sort();
913×
        heads.dedup();
923×
        let label = state_spec(&heads).unwrap_or_default();
933×
        return Ok(RefTarget { label, kind: RefKind::Change, heads });
94220×
    }
95220×
    let view = repo.view()?;
96220×
    if let Some(heads) = view.bookmarks.get(spec).map(|st| repo.store().get_state(st).map(|s| s.heads)).transpose()? {
97211×
        return Ok(RefTarget { label: spec.into(), kind: RefKind::Bookmark, heads });
989×
    }
999×
    if let Some(heads) = repo.tag_heads(spec)? {
1002×
        return Ok(RefTarget { label: spec.into(), kind: RefKind::Tag, heads });
1017×
    }
1027×
    let hash = resolve_change(repo, cache, spec)?;
1035×
    Ok(RefTarget { label: hash.to_hex(), kind: RefKind::Change, heads: vec![hash] })
104223×
}
105
106
/// The bookmark to show when the default bookmark does not exist: main,
107
/// else master, else the first by name; `None` without any.
1081×
fn stand_in(repo: &Repo) -> Result<Option<String>> {
1091×
    let view = repo.view()?;
1101×
    let names: Vec<&String> = view.bookmarks.keys().collect();
1111×
    Ok(["main", "master"]
1121×
        .iter()
1132×
        .find(|n| names.iter().any(|b| b == *n))
1141×
        .map(|n| n.to_string())
1151×
        .or_else(|| names.iter().min().map(|b| b.to_string())))
1161×
}
117
118
/// A change by full hash or unique hex prefix, searched over every
119
/// bookmark's state.
12025×
pub fn resolve_change(repo: &Repo, cache: &StateCache, spec: &str) -> Result<ChangeHash> {
12125×
    let spec = spec.trim_start_matches('#');
1221364×
    if spec.len() < 4 || !spec.chars().all(|c| c.is_ascii_hexdigit()) {
1232×
        return Err(fail(format!("{spec:?} is not a bookmark, tag or change of this repository")));
12423×
    }
12523×
    if let Some(h) = ChangeHash::from_hex(spec) {
12621×
        if repo.store().has(&h) {
12721×
            return Ok(h);
1280×
        }
1292×
    }
1302×
    let heads = all_heads(repo)?;
1312×
    let loaded = cache.load(repo, &heads)?;
1324×
    let matches: Vec<ChangeHash> = loaded.state.changes().map(|(h, _)| *h).filter(|h| h.starts_with_hex(spec)).collect();
1332×
    match matches.len() {
1342×
        1 => Ok(matches[0]),
1350×
        0 => Err(fail(format!("no change matches {spec:?}"))),
1360×
        _ => Err(fail(format!("{spec:?} is ambiguous"))),
137
    }
13825×
}
139
140
/// The heads of every bookmark, deduplicated.
14125×
pub fn all_heads(repo: &Repo) -> Result<Vec<ChangeHash>> {
14225×
    let mut set = BTreeSet::new();
14345×
    for b in repo.bookmarks()? {
14445×
        set.extend(b.heads);
14545×
    }
14625×
    Ok(set.into_iter().collect())
14725×
}
148
149
// ---------------------------------------------------------------- authors
150
151
#[derive(Clone, Debug, PartialEq, Eq)]
152
pub struct AuthorView {
153
    pub name: String,
154
    pub email: String,
155
    pub key: String,
156
    /// True when the key is not an identity the store knows.
157
    pub unknown: bool,
158
    /// The hub account the key is linked to, filled by the web layer.
159
    pub account: Option<String>,
160
}
161
162479×
pub fn author(repo: &Repo, key: &KeyId, message: &str) -> AuthorView {
163479×
    if key.is_zero() {
164
        // Imported from Git: the trailer carries the author.
165152×
        let line = message.lines().rev().find_map(|l| l.strip_prefix("Git-Author: ")).unwrap_or("unknown");
166138×
        let (name, email) = match line.split_once('<') {
1670×
            Some((n, e)) => (n.trim().to_string(), e.trim_end_matches('>').trim().to_string()),
168138×
            None => (line.trim().to_string(), String::new()),
169
        };
170138×
        return AuthorView { name, email, key: String::new(), unknown: true, account: None };
171341×
    }
172341×
    match repo.store().get_identity(key) {
173341×
        Ok(id) => AuthorView { name: id.name, email: id.email, key: key.to_hex(), unknown: false, account: None },
1740×
        Err(_) => AuthorView { name: key.short(12), email: String::new(), key: key.to_hex(), unknown: true, account: None },
175
    }
176479×
}
177
178
// ---------------------------------------------------------------- changes
179
180
#[derive(Clone, Debug, PartialEq, Eq)]
181
pub enum SigView {
182
    Unsigned,
183
    Valid { key: String, name: String, sealed: bool, revoked: bool },
184
    Invalid(String),
185
}
186
187
impl SigView {
18875×
    pub fn glyph(&self) -> &'static str {
18975×
        match self {
19011×
            SigView::Unsigned => "○",
1910×
            SigView::Valid { revoked: true, .. } => "⊘",
19212×
            SigView::Valid { sealed: true, .. } => "◈",
19352×
            SigView::Valid { .. } => "✔",
1940×
            SigView::Invalid(_) => "✘",
195
        }
19675×
    }
197
19875×
    pub fn class(&self) -> &'static str {
19975×
        match self {
20011×
            SigView::Unsigned => "sig-unsigned",
2010×
            SigView::Valid { revoked: true, .. } => "sig-revoked",
20264×
            SigView::Valid { .. } => "sig-valid",
2030×
            SigView::Invalid(_) => "sig-invalid",
204
        }
20575×
    }
206
20789×
    pub fn title(&self) -> String {
20889×
        match self {
20914×
            SigView::Unsigned => "unsigned".into(),
21015×
            SigView::Valid { name, sealed: true, revoked: false, .. } => format!("sealed by {name}"),
2110×
            SigView::Valid { name, revoked: true, .. } => format!("signed by {name}, key revoked"),
21260×
            SigView::Valid { name, .. } => format!("signed by {name}"),
2130×
            SigView::Invalid(why) => format!("INVALID: {why}"),
214
        }
21589×
    }
216
}
217
218
/// How a change is authenticated: by its own signature, or by a seal over
219
/// it (a keyless agent seals at push, as `oxy seal` does; oxygenhub#352).
220426×
pub fn signature(repo: &Repo, hash: &ChangeHash) -> SigView {
221426×
    match repo.authenticate_change(hash) {
222119×
        Ok(SignatureStatus::Unsigned) => SigView::Unsigned,
223307×
        Ok(SignatureStatus::Valid { key, name, sealed, revoked, .. }) => SigView::Valid { key: key.to_hex(), name, sealed, revoked },
2240×
        Ok(SignatureStatus::Invalid(why)) => SigView::Invalid(why),
2250×
        Err(e) => SigView::Invalid(e.to_string()),
226
    }
227426×
}
228
229
/// Who signed for the author when it was not the author: a delegate, such
230
/// as an agent, named by its identity, with the delegation that let it
231
/// (Oxygen's agent workflows doc §6).
232
#[derive(Clone, Debug, PartialEq, Eq)]
233
pub struct ViaView {
234
    pub name: String,
235
    pub email: String,
236
    pub key: String,
237
    pub short: String,
238
    /// The delegation's scope as written, `changes` for an agent.
239
    pub scope: String,
240
    pub expires_ms: Option<u64>,
241
    /// The signing key was revoked, or the delegation is no longer in the
242
    /// author's identity: the change deserves a second look.
243
    pub revoked: bool,
244
    /// A short-lived key a hub certified under the author's grant (a
245
    /// keyless agent, spec §14.7), rather than a delegate in the author's
246
    /// identity.
247
    pub certified: bool,
248
}
249
250
impl ViaView {
251
    /// The delegation, as the change page's `via` row says it.
2525×
    pub fn delegation(&self) -> String {
2535×
        if self.certified {
2541×
            return format!("keyless, under your grant for {}", self.scope);
2554×
        }
2564×
        if self.revoked {
2570×
            return "delegation revoked".into();
2584×
        }
2594×
        match self.expires_ms {
2600×
            Some(e) => format!("delegate for {}, until {}", self.scope, when_utc(e)),
2614×
            None => format!("delegate for {}", self.scope),
262
        }
2635×
    }
264
265
    /// What hovering the badge says.
26630×
    pub fn title(&self) -> String {
26730×
        let until = match self.expires_ms {
2681×
            Some(e) => format!(", until {}", when_utc(e)),
26929×
            None => String::new(),
270
        };
27130×
        if self.certified {
2721×
            format!(
273
                "sealed by {} with a short-lived key the hub certified under the author's grant ({}), key {}",
274
                self.name, self.scope, self.short
275
            )
27629×
        } else if self.revoked {
2770×
            format!("signed by {} as a delegate of the author; that delegation has since been revoked", self.name)
278
        } else {
27929×
            format!("signed by {} as a delegate of the author ({}{until}), key {}", self.name, self.scope, self.short)
280
        }
28130×
    }
282
}
283
284
/// `2026-10-04 20:12Z` for a wall time in milliseconds.
2851×
fn when_utc(ms: u64) -> String {
2861×
    time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000)
2871×
        .ok()
2881×
        .and_then(|t| t.format(&time::macros::format_description!("[year]-[month]-[day] [hour]:[minute]Z")).ok())
2891×
        .unwrap_or_else(|| ms.to_string())
2901×
}
291
292
/// The delegate that signed `hash` for the author, when one did.
293419×
pub fn via(repo: &Repo, author: &KeyId, sig: &SigView) -> Option<ViaView> {
294419×
    let SigView::Valid { key, name, revoked, .. } = sig else { return None };
295301×
    if author.is_zero() || *key == author.to_hex() {
296174×
        return None;
297127×
    }
298127×
    let signer = KeyId::from_hex(key)?;
299127×
    let email = repo.store().get_identity(&signer).map(|i| i.email).unwrap_or_default();
300
    // A keyless agent's key is in no identity: its certificate says for
301
    // whom and what; authenticating the change checked its grant.
302127×
    if let Some(cert) = certificate(repo, &signer) {
3033×
        return Some(ViaView {
3043×
            name: name.clone(),
3053×
            email,
3063×
            key: key.clone(),
3073×
            short: signer.short(12),
3083×
            scope: cert.scope.to_string(),
3093×
            expires_ms: Some(cert.expires),
3103×
            revoked: *revoked,
3113×
            certified: true,
3123×
        });
313124×
    }
314124×
    let entry = repo.store().get_identity(author).ok().and_then(|a| a.delegate(&signer).copied());
315
    Some(ViaView {
316124×
        name: name.clone(),
317124×
        email,
318124×
        key: key.clone(),
319124×
        short: signer.short(12),
320124×
        scope: entry.map(|d| d.scope.to_string()).unwrap_or_else(|| "changes".into()),
321124×
        expires_ms: entry.and_then(|d| d.expires),
322124×
        revoked: *revoked || entry.is_none(),
323
        certified: false,
324
    })
325419×
}
326
327
/// The certificate a hub gave short-lived key `key`, if the store keeps one.
328130×
fn certificate(repo: &Repo, key: &KeyId) -> Option<oxy_core::cert::Certificate> {
329130×
    let r = repo.store().certificate_of(key).ok()??;
3304×
    repo.store().get_certificate(&r).ok().map(|c| c.cert)
331130×
}
332
333
/// The key that sealed `hash` for its author, when another key did: the
334
/// first seal's signer (read, not verified: the change rows do that).
33527×
fn sealer(repo: &Repo, hash: &ChangeHash, author: &KeyId) -> Option<KeyId> {
33627×
    let seals = repo.store().seals_for(hash).ok()?;
33727×
    seals.iter().find_map(|a| repo.store().signatures(a).ok()?.into_iter().map(|s| s.key).find(|k| k != author))
33827×
}
339
340
/// One change as history rows and change pages show it.
341
#[derive(Clone, Debug)]
342
pub struct ChangeView {
343
    pub hash: String,
344
    pub short: String,
345
    pub title: String,
346
    pub body: String,
347
    pub author: AuthorView,
348
    pub when_ms: u64,
349
    pub deps: Vec<String>,
350
    pub head: bool,
351
    pub resolution: bool,
352
    pub signature: SigView,
353
    /// Signed for the author by a delegate (an agent), when it was.
354
    pub via: Option<ViaView>,
355
    /// Bookmarks and tags whose state has this change as a head.
356
    pub labels: Vec<Label>,
357
}
358
359
#[derive(Clone, Debug, PartialEq, Eq)]
360
pub struct Label {
361
    pub name: String,
362
    pub kind: RefKind,
363
}
364
365419×
pub fn split_message(message: &str) -> (String, String) {
366419×
    let mut lines = message.lines();
367419×
    let title = lines.next().unwrap_or("").trim().to_string();
368419×
    let body = lines.collect::<Vec<_>>().join("\n").trim().to_string();
369419×
    (title, body)
370419×
}
371
372
/// Every bookmark and tag head, for labelling rows.
373373×
pub fn head_labels(repo: &Repo) -> Result<HashMap<ChangeHash, Vec<Label>>> {
374373×
    let mut out: HashMap<ChangeHash, Vec<Label>> = HashMap::new();
375977×
    for b in repo.bookmarks()? {
3762313×
        for h in b.heads {
3772313×
            out.entry(h).or_default().push(Label { name: b.name.clone(), kind: RefKind::Bookmark });
3782313×
        }
379
    }
380373×
    for t in repo.tags()? {
38132×
        for h in t.heads {
38232×
            out.entry(h).or_default().push(Label { name: t.name.clone(), kind: RefKind::Tag });
38332×
        }
384
    }
385373×
    Ok(out)
386373×
}
387
388235×
pub fn change_view_pub(
389235×
    repo: &Repo,
390235×
    hash: &ChangeHash,
391235×
    meta: &oxy_core::state::ChangeMeta,
392235×
    head: bool,
393235×
    labels: &HashMap<ChangeHash, Vec<Label>>,
394235×
) -> ChangeView {
395235×
    change_view(repo, hash, meta, head, labels)
396235×
}
397
398419×
fn change_view(
399419×
    repo: &Repo,
400419×
    hash: &ChangeHash,
401419×
    meta: &oxy_core::state::ChangeMeta,
402419×
    head: bool,
403419×
    labels: &HashMap<ChangeHash, Vec<Label>>,
404419×
) -> ChangeView {
405419×
    let (title, body) = split_message(&meta.message);
406419×
    let signature = signature(repo, hash);
407419×
    let via = via(repo, &meta.author, &signature);
408
    ChangeView {
409419×
        hash: hash.to_hex(),
410419×
        short: hash.short(12),
411419×
        title,
412419×
        body,
413419×
        author: author(repo, &meta.author, &meta.message),
414419×
        when_ms: meta.hlc.wall_ms,
415419×
        deps: meta.deps.iter().map(|d| d.to_hex()).collect(),
416419×
        head,
417419×
        resolution: meta.kind == oxy_core::objects::ChangeKind::Resolution,
418419×
        signature,
419419×
        via,
420419×
        labels: labels.get(hash).cloned().unwrap_or_default(),
421
    }
422419×
}
423
424
// ---------------------------------------------------------------- history
425
426
#[derive(Clone, Debug)]
427
pub struct History {
428
    pub target: RefTarget,
429
    pub entries: Vec<ChangeView>,
430
    pub total: usize,
431
    pub offset: usize,
432
    pub has_more: bool,
433
    pub path: Option<String>,
434
}
435
436
/// A window of the history of a state, newest first.
43722×
pub fn history(repo: &Repo, cache: &StateCache, target: RefTarget, path: Option<&str>, offset: usize, limit: usize) -> Result<History> {
43822×
    let loaded = cache.load(repo, &target.heads)?;
43922×
    let head_set: BTreeSet<ChangeHash> = loaded.state.heads().into_iter().collect();
44022×
    let mut entries: Vec<(ChangeHash, oxy_core::state::ChangeMeta)> = match path {
4417×
        Some(p) => {
4427×
            let Some(node) = loaded.snapshot.files.get(p).map(|f| f.node) else { return Err(fail(format!("{p} is not in that state"))) };
4437×
            loaded
4447×
                .state
4457×
                .changes()
44614×
                .filter(|(h, _)| {
447
                    // The skeleton says which nodes a change touches, without its text.
44814×
                    repo.store()
44914×
                        .get_change_lazy(h)
45014×
                        .map(|(c, _)| c.tree_ops.iter().any(|o| o.node() == node) || c.edits.iter().any(|x| x.node() == node))
45114×
                        .unwrap_or(false)
45214×
                })
4538×
                .map(|(h, m)| (*h, m.clone()))
4547×
                .collect()
455
        }
45653×
        None => loaded.state.changes().map(|(h, m)| (*h, m.clone())).collect(),
457
    };
458103×
    entries.sort_by(|a, b| b.1.hlc.cmp(&a.1.hlc).then(b.0.cmp(&a.0)));
45922×
    let total = entries.len();
46022×
    let labels = head_labels(repo)?;
46122×
    let window: Vec<ChangeView> =
46261×
        entries.iter().skip(offset).take(limit).map(|(h, m)| change_view(repo, h, m, head_set.contains(h), &labels)).collect();
46322×
    Ok(History { target, entries: window, total, offset, has_more: offset + limit < total, path: path.map(String::from) })
46422×
}
465
466
/// A window of the changes a revset selects (Oxygen's
467
/// docs/revset_query_language.md §3), newest first, under a budget
468
/// stricter than the command line's so a query cannot load the server.
469
/// `target` is the reference the request named, for labels and heads.
4704×
pub fn history_revset(repo: &Repo, cache: &StateCache, target: RefTarget, revset: &str, offset: usize, limit: usize) -> Result<History> {
4714×
    let loaded = cache.load(repo, &target.heads)?;
4724×
    let head_set: BTreeSet<ChangeHash> = loaded.state.heads().into_iter().collect();
4734×
    let selected = repo.revset_with_budget(revset, std::time::Duration::from_secs(2))?;
4742×
    let total = selected.hashes.len();
4752×
    let labels = head_labels(repo)?;
4762×
    let window: Vec<ChangeView> = selected
4772×
        .hashes
4782×
        .iter()
4792×
        .skip(offset)
4802×
        .take(limit)
4812×
        .filter_map(|h| selected.metas.get(h).map(|m| change_view(repo, h, m, head_set.contains(h), &labels)))
4822×
        .collect();
4832×
    Ok(History { target, entries: window, total, offset, has_more: offset + limit < total, path: None })
4844×
}
485
486
// ----------------------------------------------------------------- change
487
488
#[derive(Clone, Debug)]
489
pub struct ChangeDetail {
490
    pub view: ChangeView,
491
    pub dependents: Vec<ChangeView>,
492
    pub deps: Vec<ChangeView>,
493
    pub files: Vec<FileDiff>,
494
    /// Base content of each modified file, for context lines.
495
    pub bases: BTreeMap<String, Vec<u8>>,
496
    pub in_bookmarks: Vec<String>,
497
    pub in_tags: Vec<String>,
498
    pub atoms: usize,
499
    pub tree_ops: usize,
500
}
501
50219×
pub fn change(repo: &Repo, cache: &StateCache, hash: &ChangeHash) -> Result<ChangeDetail> {
50319×
    let c: Change = repo.store().get_change(hash)?;
50419×
    let labels = head_labels(repo)?;
50519×
    let heads = all_heads(repo)?;
50619×
    let loaded = cache.load(repo, &heads)?;
50719×
    let state = &loaded.state;
50819×
    let is_head = state.heads().contains(hash);
50919×
    let meta = oxy_core::state::ChangeMeta::of(&c);
51019×
    let view = change_view(repo, hash, &meta, is_head, &labels);
51119×
    let deps = c.deps.iter().filter_map(|d| state.get(d).map(|m| change_view(repo, d, m, state.heads().contains(d), &labels))).collect();
51219×
    let mut dependents: Vec<ChangeView> = state
51319×
        .changes()
51463×
        .filter(|(_, m)| m.deps.contains(hash))
51519×
        .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels))
51619×
        .collect();
51719×
    dependents.sort_by_key(|c| std::cmp::Reverse(c.when_ms));
51819×
    let base = cache.load(repo, &c.deps)?;
51919×
    let files = crate::render::diff::change_files(&c, &base.state, &base.snapshot);
52019×
    let base_snapshot = &base.snapshot;
52119×
    let mut bases = BTreeMap::new();
52220×
    for f in &files {
52320×
        let old = f.from.as_deref().unwrap_or(&f.path);
52420×
        if let Some(FileContent::Text { bytes, .. }) = base_snapshot.files.get(old).map(|s| &s.content) {
5259×
            bases.insert(f.path.clone(), bytes.clone());
52611×
        }
527
    }
52819×
    let mut in_bookmarks = Vec::new();
52936×
    for b in repo.bookmarks()? {
53036×
        if cache.summary(repo, &b.heads)?.changes.contains(hash) {
53119×
            in_bookmarks.push(b.name);
53219×
        }
533
    }
53419×
    let mut in_tags = Vec::new();
53519×
    for t in repo.tags()? {
5362×
        if cache.summary(repo, &t.heads)?.changes.contains(hash) {
5372×
            in_tags.push(t.name);
5382×
        }
539
    }
54019×
    Ok(ChangeDetail { view, dependents, deps, files, bases, in_bookmarks, in_tags, atoms: c.edits.len(), tree_ops: c.tree_ops.len() })
54119×
}
542
543
// ------------------------------------------------------------------- tree
544
545
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
546
pub enum EntryKind {
547
    Dir,
548
    File,
549
    Symlink,
550
    Subrepo,
551
}
552
553
#[derive(Clone, Debug)]
554
pub struct Entry {
555
    pub name: String,
556
    pub path: String,
557
    pub kind: EntryKind,
558
    pub exec: bool,
559
    pub size: Option<u64>,
560
    pub conflicted: bool,
561
}
562
563
#[derive(Clone, Debug)]
564
pub struct Tree {
565
    pub target: RefTarget,
566
    pub path: String,
567
    pub entries: Vec<Entry>,
568
    /// `(name, path)` from the root down to this directory.
569
    pub crumbs: Vec<(String, String)>,
570
    pub readme: Option<Readme>,
571
}
572
573
#[derive(Clone, Debug)]
574
pub struct Readme {
575
    pub path: String,
576
    pub text: String,
577
}
578
57988×
pub fn crumbs(path: &str) -> Vec<(String, String)> {
58088×
    let mut out = Vec::new();
58188×
    let mut so_far = String::new();
58294×
    for part in path.split('/').filter(|p| !p.is_empty()) {
58340×
        if !so_far.is_empty() {
5846×
            so_far.push('/');
58534×
        }
58640×
        so_far.push_str(part);
58740×
        out.push((part.to_string(), so_far.clone()));
588
    }
58988×
    out
59088×
}
591
59264×
fn entry_of(path: &str, name: &str, f: &oxy_core::state::FileState) -> Entry {
59364×
    let kind = match f.kind {
59420×
        NodeKind::Dir => EntryKind::Dir,
59544×
        NodeKind::File => EntryKind::File,
5960×
        NodeKind::Symlink => EntryKind::Symlink,
5970×
        NodeKind::Subrepo => EntryKind::Subrepo,
598
    };
59964×
    let size = match &f.content {
60044×
        FileContent::Text { bytes, .. } => Some(bytes.len() as u64),
60120×
        _ => None,
602
    };
60364×
    Entry { name: name.to_string(), path: path.to_string(), kind, exec: f.mode.exec, size, conflicted: f.is_conflicted() }
60464×
}
605
606
/// The entries directly under `dir` (`""` for the root).
6074×
pub fn tree(repo: &Repo, cache: &StateCache, target: RefTarget, dir: &str) -> Result<Tree> {
6084×
    let loaded = cache.load(repo, &target.heads)?;
6094×
    tree_of(repo, target, &loaded.snapshot, dir)
6104×
}
611
61257×
pub fn tree_of(_repo: &Repo, target: RefTarget, snapshot: &Snapshot, dir: &str) -> Result<Tree> {
61357×
    let dir = dir.trim_matches('/');
61457×
    if !dir.is_empty() {
6153×
        match snapshot.files.get(dir) {
6162×
            Some(f) if f.kind == NodeKind::Dir => {}
6171×
            _ => return Err(RepoError::NotFound),
618
        }
61954×
    }
62056×
    let prefix = if dir.is_empty() { String::new() } else { format!("{dir}/") };
62156×
    let mut entries: Vec<Entry> = snapshot
62256×
        .files
62356×
        .iter()
62492×
        .filter(|(p, _)| p.starts_with(&prefix) && !p[prefix.len()..].contains('/'))
62564×
        .map(|(p, f)| entry_of(p, &p[prefix.len()..], f))
62656×
        .collect();
62756×
    entries.sort_by(|a, b| {
62840×
        (a.kind != EntryKind::Dir).cmp(&(b.kind != EntryKind::Dir)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
62940×
    });
63056×
    let readme = entries
63156×
        .iter()
63261×
        .find(|e| {
63361×
            e.kind == EntryKind::File && matches!(e.name.to_lowercase().as_str(), "readme.md" | "readme" | "readme.txt" | "readme.markdown")
63461×
        })
63556×
        .and_then(|e| match &snapshot.files.get(&e.path)?.content {
63613×
            FileContent::Text { bytes, .. } => Some(Readme { path: e.path.clone(), text: String::from_utf8_lossy(bytes).into_owned() }),
6370×
            _ => None,
63813×
        });
63956×
    Ok(Tree { target, path: dir.to_string(), entries, crumbs: crumbs(dir), readme })
64057×
}
641
642
// ------------------------------------------------------------------- file
643
644
#[derive(Clone, Debug)]
645
pub enum FileBody {
646
    Text(String),
647
    Binary,
648
    TooLarge,
649
}
650
651
/// One row of the file page's tree sidebar (hub spec §6.6).
652
#[derive(Clone, Debug, PartialEq, Eq)]
653
pub struct SideEntry {
654
    pub name: String,
655
    pub path: String,
656
    pub depth: usize,
657
    pub dir: bool,
658
    /// A directory on the way to the file (expanded), or the file itself.
659
    pub on_path: bool,
660
    /// A "more" row standing for the rest of a long directory.
661
    pub more: bool,
662
}
663
664
/// Entries listed per directory in the sidebar before a "more" row.
665
const SIDEBAR_PER_DIR: usize = 200;
666
667
/// The tree down to `path`: each directory on the way expanded, in tree
668
/// order (directories first, then case-insensitive names).
66932×
pub fn sidebar(snapshot: &Snapshot, path: &str) -> Vec<SideEntry> {
67071×
    sidebar_in(&snapshot.files, |f| f.kind == NodeKind::Dir, path)
67132×
}
672
673
/// `sidebar` over any path map, given which values are directories.
67434×
pub fn sidebar_in<V>(files: &BTreeMap<String, V>, is_dir: impl Fn(&V) -> bool + Copy, path: &str) -> Vec<SideEntry> {
67534×
    let mut out = Vec::new();
67634×
    sidebar_dir(files, is_dir, "", path, 0, &mut out);
67734×
    out
67834×
}
679
68043×
fn sidebar_dir<V>(
68143×
    files: &BTreeMap<String, V>,
68243×
    is_dir: impl Fn(&V) -> bool + Copy,
68343×
    dir: &str,
68443×
    target: &str,
68543×
    depth: usize,
68643×
    out: &mut Vec<SideEntry>,
68743×
) {
68843×
    let prefix = if dir.is_empty() { String::new() } else { format!("{dir}/") };
68943×
    let mut children: Vec<(&str, bool)> = files
69043×
        .range(prefix.clone()..)
691615×
        .take_while(|(p, _)| p.starts_with(&prefix))
692614×
        .filter(|(p, _)| !p[prefix.len()..].contains('/'))
693330×
        .map(|(p, f)| (p.as_str(), is_dir(f)))
69443×
        .collect();
695295×
    children.sort_by(|a, b| (!a.1).cmp(&!b.1).then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())));
69643×
    let total = children.len();
697330×
    for (i, (p, dir_entry)) in children.into_iter().enumerate() {
698330×
        let on_path = target == p || target.starts_with(&format!("{p}/"));
699
        // Past the limit, keep only the entry on the way to the file.
700330×
        if i >= SIDEBAR_PER_DIR && !on_path {
70149×
            continue;
702281×
        }
703281×
        out.push(SideEntry { name: p[prefix.len()..].to_string(), path: p.to_string(), depth, dir: dir_entry, on_path, more: false });
704281×
        if dir_entry && on_path && p != target {
7059×
            sidebar_dir(files, is_dir, p, target, depth + 1, out);
706272×
        }
707
    }
70843×
    if total > SIDEBAR_PER_DIR {
7091×
        out.push(SideEntry {
7101×
            name: format!("{} more", total - SIDEBAR_PER_DIR),
7111×
            path: dir.to_string(),
7121×
            depth,
7131×
            dir: true,
7141×
            on_path: false,
7151×
            more: true,
7161×
        });
71742×
    }
71843×
}
719
720
#[derive(Clone, Debug)]
721
pub struct FileView {
722
    pub target: RefTarget,
723
    pub path: String,
724
    pub name: String,
725
    pub crumbs: Vec<(String, String)>,
726
    pub kind: EntryKind,
727
    pub exec: bool,
728
    pub size: u64,
729
    pub body: FileBody,
730
    pub conflicted: bool,
731
    /// Raw bytes, for the `?plain=1` response and images.
732
    pub bytes: Vec<u8>,
733
    pub last_change: Option<ChangeView>,
734
    pub sidebar: Vec<SideEntry>,
735
}
736
73734×
pub fn file(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<FileView> {
73834×
    let loaded = cache.load(repo, &target.heads)?;
73934×
    let snapshot = &loaded.snapshot;
74034×
    let path = path.trim_matches('/');
74134×
    let Some(f) = snapshot.files.get(path) else { return Err(RepoError::NotFound) };
74232×
    let (kind, bytes) = match (&f.kind, &f.content) {
7430×
        (NodeKind::Dir, _) => return Err(RepoError::NotFound),
7440×
        (NodeKind::Symlink, FileContent::Text { bytes, .. }) => (EntryKind::Symlink, bytes.clone()),
7450×
        (NodeKind::Subrepo, FileContent::Text { bytes, .. }) => (EntryKind::Subrepo, bytes.clone()),
74629×
        (_, FileContent::Text { bytes, .. }) => (EntryKind::File, bytes.clone()),
7473×
        (_, FileContent::Opaque { blobs }) => {
7483×
            let bytes = match blobs.first() {
7493×
                Some(b) => repo.read_blob(b)?,
7500×
                None => Vec::new(),
751
            };
7523×
            (EntryKind::File, bytes)
753
        }
7540×
        (_, FileContent::Dir) => return Err(RepoError::NotFound),
755
    };
75632×
    let size = bytes.len() as u64;
75732×
    let body = if size > render_limit {
7580×
        FileBody::TooLarge
75932×
    } else if is_binary(&bytes) {
7603×
        FileBody::Binary
761
    } else {
76229×
        FileBody::Text(String::from_utf8_lossy(&bytes).into_owned())
763
    };
76432×
    let labels = head_labels(repo)?;
76532×
    let last_change = last_change_of(repo, &loaded.state, &target.heads, path)
76632×
        .and_then(|h| loaded.state.get(&h).map(|meta| change_view(repo, &h, meta, loaded.state.heads().contains(&h), &labels)));
76732×
    let name = path.rsplit('/').next().unwrap_or(path).to_string();
76832×
    Ok(FileView {
76932×
        target,
77032×
        path: path.to_string(),
77132×
        name,
77232×
        crumbs: crumbs(path),
77332×
        kind,
77432×
        exec: f.mode.exec,
77532×
        size,
77632×
        body,
77732×
        conflicted: f.is_conflicted(),
77832×
        bytes,
77932×
        last_change,
78032×
        sidebar: sidebar(snapshot, path),
78132×
    })
78234×
}
783
784
/// A file's name and bytes, and nothing else (for raw responses).
78514×
pub fn raw(repo: &Repo, cache: &StateCache, target: &RefTarget, path: &str) -> Result<(String, Vec<u8>)> {
78614×
    let loaded = cache.load(repo, &target.heads)?;
78714×
    let path = path.trim_matches('/');
78814×
    let Some(f) = loaded.snapshot.files.get(path) else { return Err(RepoError::NotFound) };
78914×
    let bytes = match (&f.kind, &f.content) {
7900×
        (NodeKind::Dir, _) | (_, FileContent::Dir) => return Err(RepoError::NotFound),
79112×
        (_, FileContent::Text { bytes, .. }) => bytes.clone(),
7922×
        (_, FileContent::Opaque { blobs }) => match blobs.first() {
7932×
            Some(b) => repo.read_blob(b)?,
7940×
            None => Vec::new(),
795
        },
796
    };
79714×
    Ok((path.rsplit('/').next().unwrap_or(path).to_string(), bytes))
79814×
}
799
800
/// Facts about a path in a state never change, so they are cached by the
801
/// state's heads and the path (hub spec §16.3).
80236×
fn state_path_key(tag: &[u8], heads: &[ChangeHash], path: &str) -> [u8; 32] {
80336×
    let mut sorted: Vec<&ChangeHash> = heads.iter().collect();
80436×
    sorted.sort();
80588×
    let joined: Vec<u8> = sorted.iter().flat_map(|h| h.to_hex().into_bytes()).collect();
80636×
    crate::render::cache::key(&[tag, &joined, path.as_bytes()])
80736×
}
808
809
struct Last(Option<ChangeHash>);
810
811
impl crate::render::cache::Weigh for Last {
81214×
    fn weight(&self) -> usize {
81314×
        64
81414×
    }
815
}
816
817
/// The newest change touching `path` in the state, from a cache of 4 MiB.
81832×
pub fn last_change_of(repo: &Repo, state: &oxy_core::state::State, heads: &[ChangeHash], path: &str) -> Option<ChangeHash> {
819
    static C: std::sync::OnceLock<crate::render::cache::Lru<Last>> = std::sync::OnceLock::new();
82032×
    let cache = C.get_or_init(|| crate::render::cache::Lru::new(4 << 20));
82132×
    let key = state_path_key(b"last1", heads, path);
82232×
    cache.get_or(key, || Last(repo.log_for_path_of(state, path, Some(1)).ok().and_then(|l| l.into_iter().next()).map(|e| e.hash))).0
82332×
}
824
825
struct BlameLines(Vec<BlameLine>);
826
827
impl crate::render::cache::Weigh for BlameLines {
8284×
    fn weight(&self) -> usize {
82913×
        self.0.iter().map(|l| l.text.len() + l.author.len() + 128).sum::<usize>() + 32
8304×
    }
831
}
832
833
/// `Repo::blame_in`, cached by state and path (64 MiB).
8344×
fn blame_cached(repo: &Repo, cache_state: &StateCache, heads: &[ChangeHash], path: &str) -> Result<Vec<BlameLine>> {
835
    static C: std::sync::OnceLock<crate::render::cache::Lru<BlameLines>> = std::sync::OnceLock::new();
8364×
    let cache = C.get_or_init(|| crate::render::cache::Lru::new(64 << 20));
8374×
    let key = state_path_key(b"blame1", heads, path);
8384×
    let mut failure = None;
8394×
    let lines = cache.get_or(key, || {
8404×
        match cache_state
8414×
            .load(repo, heads)
8424×
            .map_err(|e| oxy_core::repo::RepoError::Msg(e.to_string()))
8434×
            .and_then(|loaded| repo.blame_of(&loaded.state, &loaded.snapshot.tree, path))
844
        {
8454×
            Ok(l) => BlameLines(l),
8460×
            Err(e) => {
8470×
                failure = Some(e);
8480×
                BlameLines(Vec::new())
849
            }
850
        }
8514×
    });
8524×
    if let Some(e) = failure {
8530×
        return Err(RepoError::Oxy(e));
8544×
    }
8554×
    Ok(lines.0.clone())
8564×
}
857
858112×
pub fn is_binary(bytes: &[u8]) -> bool {
859112×
    let head = &bytes[..bytes.len().min(8192)];
860112×
    head.contains(&0)
861109×
        || std::str::from_utf8(head).is_err()
8620×
            && head.iter().filter(|b| **b < 0x20 && !matches!(**b, b'\n' | b'\r' | b'\t')).count() > head.len() / 20
863112×
}
864
865
/// Last change per entry of a directory, for tree rows (fetched lazily).
866
/// A directory's last change is the newest change touching anything
867
/// beneath it, as GitHub shows it.
8681×
pub fn last_changes(repo: &Repo, cache: &StateCache, heads: &[ChangeHash], paths: &[String]) -> Result<Vec<(String, ChangeView)>> {
8691×
    let labels = head_labels(repo)?;
8701×
    let loaded = cache.load(repo, heads)?;
8711×
    let state = &loaded.state;
8721×
    let snapshot = &loaded.snapshot;
873
    // Newest change touching each node, from one pass over the log.
8741×
    let mut newest: HashMap<oxy_core::ids::NodeId, (ChangeHash, u64)> = HashMap::new();
8752×
    for e in repo.log_of(state) {
8762×
        let Ok((c, _)) = repo.store().get_change_lazy(&e.hash) else { continue };
8772×
        let when = e.change.hlc.wall_ms;
87811×
        for node in c.tree_ops.iter().map(|o| o.node()).chain(c.edits.iter().map(|x| x.node())) {
87911×
            newest.entry(node).or_insert((e.hash, when));
88011×
        }
881
    }
8821×
    let mut out = Vec::new();
8832×
    for p in paths {
8842×
        let Some(f) = snapshot.files.get(p) else { continue };
8852×
        let best = if f.kind == NodeKind::Dir {
8861×
            let prefix = format!("{p}/");
8871×
            snapshot
8881×
                .files
8891×
                .iter()
8905×
                .filter(|(q, _)| q.starts_with(&prefix))
8911×
                .filter_map(|(_, g)| newest.get(&g.node))
8921×
                .chain(newest.get(&f.node))
8931×
                .max_by_key(|(_, when)| *when)
8941×
                .copied()
895
        } else {
8961×
            newest.get(&f.node).copied()
897
        };
8982×
        if let Some((hash, _)) = best {
8992×
            if let Some(m) = state.get(&hash) {
9002×
                out.push((p.clone(), change_view(repo, &hash, m, state.heads().contains(&hash), &labels)));
9012×
            }
9020×
        }
903
    }
9041×
    Ok(out)
9051×
}
906
907
// ------------------------------------------------------------------ blame
908
909
#[derive(Clone, Debug)]
910
pub struct Blame {
911
    pub file: FileView,
912
    pub lines: Vec<BlameLine>,
913
    /// For each blamed change, the state address of its dependencies:
914
    /// the state its author built on (None without dependencies).
915
    pub before: HashMap<ChangeHash, Option<String>>,
916
}
917
9184×
pub fn blame(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<Blame> {
9194×
    let heads = target.heads.clone();
9204×
    let file = file(repo, cache, target, path, render_limit)?;
9214×
    let lines = blame_cached(repo, cache, &heads, path)?;
9224×
    let mut before = HashMap::new();
92313×
    for l in &lines {
92413×
        before.entry(l.hash).or_insert_with(|| repo.store().get_change_meta(&l.hash).ok().and_then(|c| state_spec(&c.deps)));
925
    }
9264×
    Ok(Blame { file, lines, before })
9274×
}
928
929
// -------------------------------------------------------------- overview
930
931
#[derive(Clone, Debug)]
932
pub struct BookmarkView {
933
    pub name: String,
934
    pub heads: Vec<String>,
935
    pub changes: usize,
936
    pub default: bool,
937
    pub conflicted: bool,
938
    pub latest_ms: u64,
939
}
940
941
#[derive(Clone, Debug)]
942
pub struct TagView {
943
    pub name: String,
944
    pub heads: Vec<String>,
945
    pub message: String,
946
    pub signer: Option<String>,
947
}
948
949
/// A tag withdrawn here (#301): the state it named, why, by whom and when.
950
#[derive(Clone, Debug)]
951
pub struct WithdrawnView {
952
    pub name: String,
953
    pub heads: Vec<String>,
954
    pub reason: String,
955
    pub signer: Option<String>,
956
    pub when_ms: i64,
957
}
958
959
#[derive(Clone, Debug)]
960
pub struct Overview {
961
    pub tree: Tree,
962
    pub changes: usize,
963
    pub heads: usize,
964
    pub bookmarks: Vec<BookmarkView>,
965
    pub tags: Vec<TagView>,
966
    pub latest: Option<ChangeView>,
967
    pub conflicted: bool,
968
    pub license: Option<String>,
969
    pub policy: PolicyBadge,
970
    pub contributors: Vec<Contributor>,
971
    pub languages: Vec<LanguageShare>,
972
    /// The bookmark shown in the default's place when the repository has
973
    /// no bookmark of the default's name (a default set before its first
974
    /// push, or one deleted since).
975
    pub standing_in: Option<String>,
976
}
977
978
impl Overview {
979
    /// The sidebar's bookmarks (#152): the default first, then the most
980
    /// recently moved, ten in all.
98153×
    pub fn recent_bookmarks(&self) -> Vec<&BookmarkView> {
98253×
        let mut list: Vec<&BookmarkView> = self.bookmarks.iter().collect();
98353×
        list.sort_by(|a, b| b.default.cmp(&a.default).then(b.latest_ms.cmp(&a.latest_ms)).then_with(|| a.name.cmp(&b.name)));
98453×
        list.truncate(10);
98553×
        list
98653×
    }
987
}
988
989
/// Someone who authored changes in a state (hub spec §6.4).
990
#[derive(Clone, Debug, PartialEq, Eq)]
991
pub struct Contributor {
992
    pub name: String,
993
    /// The identity key (hex), empty for authors imported from Git.
994
    pub key: String,
995
    pub changes: usize,
996
    /// The hub account the key is linked to, filled by the web layer.
997
    pub account: Option<String>,
998
    /// Of `changes`, how many a delegate signed for this author, by the
999
    /// delegate's name (an agent's work stays the author's, and is shown).
1000
    pub via: Vec<(String, usize)>,
1001
    /// The hub's own delegate key, filled by the web layer (#152).
1002
    pub hub: bool,
1003
}
1004
1005
/// One language's share of a state's code, by bytes.
1006
#[derive(Clone, Debug, PartialEq)]
1007
pub struct LanguageShare {
1008
    pub name: String,
1009
    pub bytes: u64,
1010
    pub percent: f64,
1011
    pub colour: String,
1012
}
1013
1014
/// Authors of the changes of a state, most changes first. A change a
1015
/// delegate signed counts for its author, and the delegate is named under
1016
/// them: the signing key is read, not verified (the change rows do that).
101753×
pub fn contributors(repo: &Repo, state: &oxy_core::state::State) -> Vec<Contributor> {
101853×
    let mut by: HashMap<String, Contributor> = HashMap::new();
101953×
    let mut names: HashMap<KeyId, String> = HashMap::new();
102058×
    for (hash, meta) in state.changes() {
102158×
        let a = author(repo, &meta.author, &meta.message);
102258×
        let id = if a.key.is_empty() { format!("git:{}", a.email.to_lowercase()) } else { a.key.clone() };
1023
        // Unsigned changes with no Git author carry no name at all.
102458×
        let name =
102558×
            if a.key.is_empty() && a.email.is_empty() && a.name == "unknown" { "Unattributed changes".to_string() } else { a.name.clone() };
102658×
        let entry = by.entry(id).or_insert_with(|| Contributor {
102733×
            name,
102833×
            key: a.key.clone(),
1029
            changes: 0,
103033×
            account: None,
103133×
            via: Vec::new(),
1032
            hub: false,
103333×
        });
103458×
        entry.changes += 1;
103558×
        if !meta.author.is_zero() {
1036
            // A delegate signs the change; a keyless agent seals it (#352).
103738×
            let signer = repo
103838×
                .store()
103938×
                .signatures(hash)
104038×
                .ok()
104138×
                .and_then(|s| s.first().map(|s| s.key))
104238×
                .filter(|k| *k != meta.author)
104338×
                .or_else(|| sealer(repo, hash, &meta.author));
104438×
            if let Some(k) = signer {
104512×
                let who = names
104612×
                    .entry(k)
104712×
                    .or_insert_with(|| match certificate(repo, &k) {
10481×
                        Some(c) => c.subject,
10492×
                        None => repo.store().get_identity(&k).map(|i| i.name).unwrap_or_else(|_| k.short(12)),
10503×
                    })
105112×
                    .clone();
105212×
                match entry.via.iter_mut().find(|(n, _)| *n == who) {
10539×
                    Some((_, n)) => *n += 1,
10543×
                    None => entry.via.push((who, 1)),
1055
                }
105626×
            }
105720×
        }
1058
    }
105953×
    let mut out: Vec<Contributor> = by.into_values().collect();
106053×
    for c in &mut out {
106133×
        c.via.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
1062
    }
106353×
    out.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.name.cmp(&b.name)));
106453×
    out
106553×
}
1066
1067
/// The language of a path for the languages bar: an extension table for
1068
/// what syntect lacks, then syntect; `None` for prose, data, lock files and
1069
/// vendored or generated paths, which Linguist leaves out too.
107068×
pub fn language_of(path: &str) -> Option<&'static str> {
107168×
    let lower = path.to_ascii_lowercase();
107268×
    let name = lower.rsplit('/').next().unwrap_or(&lower);
107368×
    let skip_dirs = ["vendor/", "node_modules/", "target/", "dist/", "third_party/", ".git/"];
1074399×
    if skip_dirs.iter().any(|d| lower.starts_with(d) || lower.contains(&format!("/{d}"))) {
10752×
        return None;
107666×
    }
107766×
    if name.ends_with(".lock")
107865×
        || name == "package-lock.json"
107965×
        || name.ends_with(".min.js")
108065×
        || name.ends_with(".min.css")
108165×
        || name.ends_with(".d.ts")
1082
    {
10831×
        return None;
108465×
    }
108565×
    let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
108665×
    let by_ext = match ext {
108765×
        "s" | "asm" | "a65" | "a80" | "z80" | "inc" | "65s" => Some("Assembly"),
108864×
        "toml" => Some("TOML"),
108963×
        "ts" | "tsx" | "mts" | "cts" => Some("TypeScript"),
109063×
        "kdl" => Some("KDL"),
109158×
        "sh" | "bash" | "zsh" => Some("Shell"),
109252×
        "h" => Some("C"),
109350×
        "hpp" | "hh" | "cc" | "cxx" => Some("C++"),
109450×
        "vue" => Some("Vue"),
109550×
        "svelte" => Some("Svelte"),
109650×
        "zig" => Some("Zig"),
109750×
        "nix" => Some("Nix"),
109850×
        "md" | "markdown" | "txt" | "rst" | "adoc" | "json" | "csv" | "svg" | "xml" => return None,
109915×
        _ => None,
1100
    };
110130×
    if by_ext.is_some() {
110215×
        return by_ext;
110315×
    }
110415×
    let lang = crate::render::highlight::language(path)?;
110512×
    Some(match lang {
110612×
        "Bourne Again Shell (bash)" | "Shell-Unix-Generic" => "Shell",
110712×
        "Plain Text" | "Markdown" | "MultiMarkdown" | "JSON" | "Git Ignore" | "Git Attributes" | "Git Config" => return None,
110812×
        l => l,
1109
    })
111068×
}
1111
1112
/// Linguist's colours for common languages; a stable colour otherwise.
111323×
pub fn language_colour(name: &str) -> String {
111423×
    let known = match name {
111523×
        "Rust" => "#dea584",
111615×
        "Python" => "#3572a5",
111715×
        "Shell" => "#89e051",
111810×
        "Makefile" => "#427819",
111910×
        "C" => "#555555",
11207×
        "C++" => "#f34b7d",
11217×
        "C#" => "#178600",
11227×
        "Assembly" => "#6e4c13",
11237×
        "JavaScript" => "#f1e05a",
11247×
        "TypeScript" => "#3178c6",
11257×
        "HTML" => "#e34c26",
11267×
        "CSS" => "#663399",
11277×
        "Go" => "#00add8",
11287×
        "Java" => "#b07219",
11297×
        "TOML" => "#9c4221",
11307×
        "YAML" => "#cb171e",
11317×
        "SQL" => "#e38c00",
11327×
        "Lua" => "#000080",
11337×
        "Ruby" => "#701516",
11347×
        "PHP" => "#4f5d95",
11357×
        "Haskell" => "#5e5086",
11367×
        "KDL" => "#ffb3b3",
11372×
        "Dockerfile" => "#384d54",
11382×
        "Vue" => "#41b883",
11392×
        "Zig" => "#ec915c",
11402×
        "Nix" => "#7e7eff",
11412×
        _ => "",
1142
    };
114323×
    if !known.is_empty() {
114421×
        return known.to_string();
11452×
    }
11462×
    let h = blake3::hash(name.as_bytes());
11472×
    let b = h.as_bytes();
11482×
    format!("#{:02x}{:02x}{:02x}", 64 + b[0] / 2, 64 + b[1] / 2, 64 + b[2] / 2)
114923×
}
1150
1151
/// Languages of a state's files by bytes, largest first; under 1% folds into "Other".
115253×
pub fn languages(snapshot: &Snapshot) -> Vec<LanguageShare> {
115353×
    let mut by: HashMap<&'static str, u64> = HashMap::new();
115481×
    for (path, f) in &snapshot.files {
115581×
        if let FileContent::Text { bytes, .. } = &f.content {
115659×
            if let Some(l) = language_of(path) {
115722×
                *by.entry(l).or_default() += bytes.len() as u64;
115837×
            }
115922×
        }
1160
    }
116153×
    let total: u64 = by.values().sum();
116253×
    if total == 0 {
116340×
        return vec![];
116413×
    }
116513×
    let mut list: Vec<(&str, u64)> = by.into_iter().collect();
116613×
    list.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
116713×
    let mut out: Vec<LanguageShare> = Vec::new();
116813×
    let mut other = 0u64;
116920×
    for (name, bytes) in list {
117020×
        if (bytes as f64) * 100.0 / (total as f64) < 1.0 || out.len() >= 8 {
11710×
            other += bytes;
117220×
        } else {
117320×
            out.push(LanguageShare {
117420×
                name: name.to_string(),
117520×
                bytes,
117620×
                percent: bytes as f64 * 100.0 / total as f64,
117720×
                colour: language_colour(name),
117820×
            });
117920×
        }
1180
    }
118113×
    if other > 0 {
11820×
        out.push(LanguageShare {
11830×
            name: "Other".into(),
11840×
            bytes: other,
11850×
            percent: other as f64 * 100.0 / total as f64,
11860×
            colour: "#8592ad".into(),
11870×
        });
118813×
    }
118913×
    out
119053×
}
1191
1192
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1193
pub enum PolicyBadge {
1194
    None,
1195
    Active,
1196
    Expired,
1197
}
1198
119973×
pub fn bookmarks(repo: &Repo, cache: &StateCache, default: &str) -> Result<Vec<BookmarkView>> {
120073×
    let list = repo.bookmarks()?;
120198×
    let briefs = cache.briefs(repo, &list.iter().map(|b| b.heads.clone()).collect::<Vec<_>>())?;
120273×
    let mut out = Vec::new();
120398×
    for (b, s) in list.iter().zip(briefs) {
120498×
        out.push(BookmarkView {
120598×
            name: b.name.clone(),
1206105×
            heads: b.heads.iter().map(|h| h.to_hex()).collect(),
120798×
            changes: s.changes,
120898×
            default: b.name == default,
120998×
            conflicted: s.conflicted,
121098×
            latest_ms: s.latest_ms,
1211
        });
1212
    }
121373×
    out.sort_by(|a, b| b.default.cmp(&a.default).then_with(|| a.name.cmp(&b.name)));
121473×
    Ok(out)
121573×
}
1216
121785×
pub fn tags(repo: &Repo) -> Result<Vec<TagView>> {
121885×
    let mut out: Vec<TagView> = repo
121985×
        .tags()?
122085×
        .into_iter()
122185×
        .map(|t| TagView { name: t.name, heads: t.heads.iter().map(|h| h.to_hex()).collect(), message: t.message, signer: t.signer })
122285×
        .collect();
122385×
    out.sort_by(|a, b| b.name.cmp(&a.name));
122485×
    Ok(out)
122585×
}
1226
1227
/// The tags withdrawn here, newest first.
12289×
pub fn withdrawn_tags(repo: &Repo) -> Result<Vec<WithdrawnView>> {
12299×
    let mut out: Vec<WithdrawnView> = repo
12309×
        .remote_refs()?
1231
        .withdrawn
12329×
        .into_iter()
12339×
        .filter_map(|t| {
12344×
            let sa = t.attestation?;
12354×
            let oxy_core::attest::AttestBody::Untag { reason, .. } = &sa.attest.body else { return None };
1236
            Some(WithdrawnView {
12374×
                name: t.name,
12384×
                heads: t.heads.iter().map(|h| h.to_hex()).collect(),
12394×
                reason: reason.clone(),
12404×
                signer: sa.sigs.first().and_then(|s| repo.store().get_identity(&s.key).ok()).map(|i| i.name),
12414×
                when_ms: sa.attest.hlc.wall_ms as i64,
1242
            })
12434×
        })
12449×
        .collect();
12459×
    out.sort_by_key(|w| std::cmp::Reverse(w.when_ms));
12469×
    Ok(out)
12479×
}
1248
124953×
pub fn policy_badge(repo: &Repo) -> PolicyBadge {
125053×
    match repo.current_policy() {
12511×
        Ok(Some((_, sp))) => {
12521×
            if sp.policy.expires != 0 && (sp.policy.expires as i64) < crate::db::now_ms() {
12530×
                PolicyBadge::Expired
1254
            } else {
12551×
                PolicyBadge::Active
1256
            }
1257
        }
125852×
        _ => PolicyBadge::None,
1259
    }
126053×
}
1261
1262
/// The overview of `shown` (a bookmark or tag; the default bookmark
1263
/// unless the viewer chose another, oxygenhub#191).
126453×
pub fn overview(repo: &Repo, cache: &StateCache, default_bookmark: &str, shown: &str) -> Result<Overview> {
126553×
    let missing = || RefTarget { label: shown.into(), kind: RefKind::Bookmark, heads: vec![] };
126653×
    let (target, standing_in) = match resolve(repo, cache, shown) {
126752×
        Ok(t) => (t, None),
1268
        // No bookmark of the default's name: show main, or else the first
1269
        // bookmark, and say so, rather than an empty repository.
12701×
        Err(_) if shown == default_bookmark => match stand_in(repo)? {
12711×
            Some(b) => (resolve(repo, cache, &b)?, Some(b)),
12720×
            None => (missing(), None),
1273
        },
12740×
        Err(_) => (missing(), None),
1275
    };
127653×
    let heads = target.heads.len();
1277
    // One load of the default state serves the counts, the latest change
1278
    // and the tree (hub spec §16.3).
127953×
    let loaded = cache.load(repo, &target.heads)?;
128053×
    let state = &loaded.state;
128153×
    let snapshot = &loaded.snapshot;
128253×
    let changes = state.len();
128353×
    let conflicted = snapshot.is_conflicted();
128453×
    let labels = head_labels(repo)?;
128553×
    let latest = state
128653×
        .changes()
128753×
        .max_by(|(ha, a), (hb, b)| a.hlc.cmp(&b.hlc).then(ha.cmp(hb)))
128853×
        .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels));
128953×
    let tree = tree_of(repo, target, snapshot, "")?;
129053×
    let license = tree
129153×
        .entries
129253×
        .iter()
129361×
        .find(|e| matches!(e.name.to_lowercase().as_str(), "license" | "license.md" | "license.txt" | "licence" | "copying"))
129453×
        .map(|e| e.path.clone());
1295
    Ok(Overview {
129653×
        tree,
129753×
        changes,
129853×
        heads,
129953×
        bookmarks: bookmarks(repo, cache, default_bookmark)?,
130053×
        tags: tags(repo)?,
130153×
        latest,
130253×
        conflicted,
130353×
        license,
130453×
        policy: policy_badge(repo),
130553×
        contributors: contributors(repo, state),
130653×
        languages: languages(snapshot),
130753×
        standing_in,
1308
    })
130953×
}
1310
1311
// -------------------------------------------------------------- compare
1312
1313
#[derive(Clone, Debug)]
1314
pub struct Compare {
1315
    pub base: RefTarget,
1316
    pub head: RefTarget,
1317
    /// Changes in `head`'s state not in `base`'s.
1318
    pub added: Vec<ChangeView>,
1319
    /// Changes in `base`'s state not in `head`'s.
1320
    pub missing: usize,
1321
    pub files: Vec<FileDiff>,
1322
    pub bases: BTreeMap<String, Vec<u8>>,
1323
}
1324
13255×
pub fn compare(repo: &Repo, cache: &StateCache, base: RefTarget, head: RefTarget) -> Result<Compare> {
13265×
    let base_loaded = cache.load(repo, &base.heads)?;
13275×
    let head_loaded = cache.load(repo, &head.heads)?;
13285×
    let base_state = &base_loaded.state;
13295×
    let head_state = &head_loaded.state;
13305×
    let labels = head_labels(repo)?;
13315×
    let mut added: Vec<ChangeView> = head_state
13325×
        .changes()
13339×
        .filter(|(h, _)| !base_state.contains(h))
13345×
        .map(|(h, m)| change_view(repo, h, m, head_state.heads().contains(h), &labels))
13355×
        .collect();
13365×
    added.sort_by_key(|c| std::cmp::Reverse(c.when_ms));
13376×
    let missing = base_state.changes().filter(|(h, _)| !head_state.contains(h)).count();
13385×
    let (files, bases) = crate::render::diff::snapshot_diff(&base_loaded.snapshot, &head_loaded.snapshot);
13395×
    Ok(Compare { base, head, added, missing, files, bases })
13405×
}
1341
1342
// ------------------------------------------------------------ conflicts
1343
1344
#[derive(Clone, Debug)]
1345
pub struct ConflictView {
1346
    pub bookmark: String,
1347
    pub path: String,
1348
    pub kind: String,
1349
    /// The file rendered with `oxy` conflict markers, when a text file.
1350
    pub marked: Option<String>,
1351
    pub contributors: Vec<ChangeView>,
1352
}
1353
13549×
pub fn conflicts(repo: &Repo, cache: &StateCache) -> Result<Vec<ConflictView>> {
13559×
    let list = repo.bookmarks()?;
135651×
    let briefs = cache.briefs(repo, &list.iter().map(|b| b.heads.clone()).collect::<Vec<_>>())?;
13579×
    let mut out = Vec::new();
135851×
    for (b, brief) in list.iter().zip(briefs) {
1359
        // Only a conflicted state is loaded whole.
136051×
        if !brief.conflicted {
136147×
            continue;
13624×
        }
13634×
        let loaded = cache.load(repo, &b.heads)?;
13644×
        out.extend(conflicts_in(repo, &loaded, &b.name));
1365
    }
13669×
    Ok(out)
13679×
}
1368
1369
/// The conflicts of one loaded state, labelled with `bookmark`.
137018×
pub fn conflicts_in(repo: &Repo, loaded: &super::cache::Loaded, bookmark: &str) -> Vec<ConflictView> {
137118×
    let labels = head_labels(repo).unwrap_or_default();
137218×
    let mut out = Vec::new();
1373
    {
137418×
        let b_name = bookmark.to_string();
137518×
        let state = &loaded.state;
137618×
        let snapshot = &loaded.snapshot;
137718×
        for c in &snapshot.tree.conflicts {
13784×
            let (path, kind) = describe_tree_conflict(snapshot, c);
13794×
            out.push(ConflictView { bookmark: b_name.clone(), path, kind, marked: None, contributors: vec![] });
13804×
        }
138170×
        for (path, f) in &snapshot.files {
138270×
            if !f.is_conflicted() {
138356×
                continue;
138414×
            }
138514×
            let (kind, marked, contributors) = match &f.content {
1386
                FileContent::Text { .. } => {
138710×
                    let (bytes, _) = state.render_file(&f.node);
138810×
                    let regions = state.conflict_regions(&f.node);
138910×
                    let contributors: Vec<ChangeView> = regions
139010×
                        .contributors
139110×
                        .iter()
139220×
                        .filter_map(|h| state.get(h).map(|m| change_view(repo, h, m, state.heads().contains(h), &labels)))
139310×
                        .collect();
139410×
                    ("lines".to_string(), Some(String::from_utf8_lossy(&bytes).into_owned()), contributors)
1395
                }
13964×
                FileContent::Opaque { blobs } => (format!("replace ({} candidates)", blobs.len()), None, vec![]),
13970×
                FileContent::Dir => continue,
1398
            };
139914×
            out.push(ConflictView { bookmark: b_name.clone(), path: path.clone(), kind, marked, contributors });
1400
        }
1401
    }
140218×
    out
140318×
}
1404
14054×
fn describe_tree_conflict(snapshot: &Snapshot, c: &TreeConflict) -> (String, String) {
14064×
    let by_node = snapshot.tree.by_node();
14074×
    let path_of = |n: &oxy_core::ids::NodeId| by_node.get(n).map(|e| e.path.clone()).unwrap_or_else(|| n.short(8));
14084×
    match c {
14094×
        TreeConflict::Name { name, nodes, parent } => {
14104×
            let path = if parent.is_root() { name.clone() } else { format!("{}/{name}", path_of(parent)) };
14114×
            (path, format!("name: {} entries want this name", nodes.len()))
1412
        }
14130×
        TreeConflict::MultiPlacement { node, placements } => (path_of(node), format!("placement: {} places", placements.len())),
14140×
        TreeConflict::Orphan { node, .. } => (path_of(node), "orphan: parent directory was removed".into()),
14150×
        TreeConflict::Cycle { node } => (path_of(node), "cycle: directory moved into itself".into()),
14160×
        other => (String::new(), format!("{other:?}")),
1417
    }
14184×
}
1419
1420
// ------------------------------------------------------------------ trust
1421
1422
#[derive(Clone, Debug)]
1423
pub struct KeyView {
1424
    pub key: String,
1425
    pub short: String,
1426
    pub name: String,
1427
    pub email: String,
1428
}
1429
1430
#[derive(Clone, Debug)]
1431
pub struct RuleView {
1432
    pub pattern: String,
1433
    pub keys: Vec<KeyView>,
1434
    pub threshold: u32,
1435
    pub require: String,
1436
    pub allow_conflicts: bool,
1437
}
1438
1439
#[derive(Clone, Debug)]
1440
pub struct PolicyView {
1441
    pub serial: u64,
1442
    pub expires_ms: u64,
1443
    pub expired: bool,
1444
    pub roots: Vec<KeyView>,
1445
    pub root_threshold: u32,
1446
    pub rules: Vec<RuleView>,
1447
    pub revoked: Vec<KeyView>,
1448
    pub signatures: usize,
1449
    pub history: usize,
1450
}
1451
1452
#[derive(Clone, Debug, Default)]
1453
pub struct Verification {
1454
    pub total: usize,
1455
    pub signed: usize,
1456
    pub sealed: usize,
1457
    pub unsigned: usize,
1458
    pub revoked: usize,
1459
    pub invalid: Vec<(String, String)>,
1460
}
1461
1462
#[derive(Clone, Debug)]
1463
pub struct Trust {
1464
    pub policy: Option<PolicyView>,
1465
    pub verification: Verification,
1466
    pub hub_key: String,
1467
    pub hub_delegated_by: Vec<KeyView>,
1468
}
1469
14702×
fn key_view(repo: &Repo, ids: &[oxy_core::identity::Identity], key: &KeyId) -> KeyView {
14714×
    let found = ids.iter().find(|i| i.hash() == *key).cloned().or_else(|| repo.store().get_identity(key).ok());
14722×
    match found {
14732×
        Some(i) => KeyView { key: key.to_hex(), short: key.short(12), name: i.name, email: i.email },
14740×
        None => KeyView { key: key.to_hex(), short: key.short(12), name: String::new(), email: String::new() },
1475
    }
14762×
}
1477
14784×
pub fn verification(repo: &Repo, cache: &StateCache) -> Result<Verification> {
14794×
    let heads = all_heads(repo)?;
14804×
    let loaded = cache.load(repo, &heads)?;
14814×
    let state = &loaded.state;
14824×
    let mut v = Verification::default();
14837×
    for (h, _) in state.changes() {
14847×
        v.total += 1;
14857×
        match signature(repo, h) {
14861×
            SigView::Unsigned => v.unsigned += 1,
14876×
            SigView::Valid { sealed, revoked, .. } => {
14886×
                if revoked {
14890×
                    v.revoked += 1;
14906×
                }
14916×
                if sealed {
14920×
                    v.sealed += 1
1493
                } else {
14946×
                    v.signed += 1
1495
                }
1496
            }
14970×
            SigView::Invalid(why) => v.invalid.push((h.to_hex(), why)),
1498
        }
1499
    }
15004×
    Ok(v)
15014×
}
1502
15033×
pub fn trust(repo: &Repo, cache: &StateCache, hub_key: &KeyId) -> Result<Trust> {
15043×
    let policy = match repo.current_policy()? {
15051×
        Some((_, sp)) => {
15061×
            let p = &sp.policy;
15071×
            let ids = &p.identities;
15081×
            let require = |r: &oxy_core::policy::Require| match r {
15090×
                oxy_core::policy::Require::Signed => "signed",
15100×
                oxy_core::policy::Require::Sealed => "sealed",
15111×
                oxy_core::policy::Require::Any => "any",
15121×
            };
1513
            Some(PolicyView {
15141×
                serial: p.serial,
15151×
                expires_ms: p.expires,
15161×
                expired: p.expires != 0 && (p.expires as i64) < crate::db::now_ms(),
15171×
                roots: p.roots.iter().map(|k| key_view(repo, ids, k)).collect(),
15181×
                root_threshold: p.root_threshold,
15191×
                rules: p
15201×
                    .rules
15211×
                    .iter()
15221×
                    .map(|r| RuleView {
15231×
                        pattern: r.pattern.clone(),
15241×
                        keys: r.keys.iter().map(|k| key_view(repo, ids, k)).collect(),
15251×
                        threshold: r.threshold,
15261×
                        require: require(&r.require).into(),
15271×
                        allow_conflicts: r.allow_conflicts,
15281×
                    })
15291×
                    .collect(),
15301×
                revoked: p.revoked.iter().map(|k| key_view(repo, ids, k)).collect(),
15311×
                signatures: sp.sigs.len(),
15321×
                history: repo.policy_history().map(|h| h.len()).unwrap_or(0),
1533
            })
1534
        }
15352×
        None => None,
1536
    };
1537
    // Who has delegated to the hub: every identity in the policy or store
1538
    // that lists the hub key.
15393×
    let mut delegated = Vec::new();
15403×
    if let Some(p) = repo.current_policy()?.map(|(_, sp)| sp.policy) {
15412×
        for i in &p.identities {
15422×
            if i.delegate(hub_key).is_some() {
15431×
                delegated.push(KeyView { key: i.hash().to_hex(), short: i.hash().short(12), name: i.name.clone(), email: i.email.clone() });
15441×
            }
1545
        }
15462×
    }
15473×
    Ok(Trust { policy, verification: verification(repo, cache)?, hub_key: hub_key.to_hex(), hub_delegated_by: delegated })
15483×
}
1549
1550
#[cfg(test)]
1551
mod language_tests {
1552
    use super::*;
1553
1554
    #[test]
15551×
    fn languages_are_detected_and_prose_is_left_out() {
15561×
        assert_eq!(language_of("src/main.rs"), Some("Rust"));
15571×
        assert_eq!(language_of("boot/start.s"), Some("Assembly"));
15581×
        assert_eq!(language_of("build.sh"), Some("Shell"));
15591×
        assert_eq!(language_of("Makefile"), Some("Makefile"));
15601×
        assert_eq!(language_of("Cargo.toml"), Some("TOML"));
15611×
        assert_eq!(language_of("README.md"), None);
15621×
        assert_eq!(language_of("Cargo.lock"), None);
15631×
        assert_eq!(language_of("web/node_modules/x/index.js"), None);
15641×
        assert_eq!(language_of("vendor/lib.c"), None);
15651×
        assert_eq!(language_colour("Rust"), "#dea584");
15661×
        assert_eq!(language_colour("Befunge"), language_colour("Befunge"), "stable");
15671×
    }
1568
}
1569
1570
#[cfg(test)]
1571
mod tests {
1572
    use super::*;
1573
15742×
    fn tree(paths: &[&str]) -> BTreeMap<String, bool> {
15752×
        let mut m = BTreeMap::new();
1576256×
        for p in paths {
1577256×
            let mut end = 0;
1578256×
            let parts: Vec<&str> = p.split('/').collect();
1579512×
            for (i, part) in parts.iter().enumerate() {
1580512×
                end += part.len();
1581512×
                m.insert(p[..end].to_string(), i + 1 < parts.len());
1582512×
                end += 1;
1583512×
            }
1584
        }
15852×
        m
15862×
    }
1587
1588
    #[test]
15891×
    fn sidebar_expands_the_path_only() {
15901×
        let files = tree(&["README.md", "src/main.rs", "src/lib/a.rs", "src/lib/b.rs", "docs/guide.md", "Zeta.txt"]);
15911×
        let rows: Vec<(String, usize, bool, bool)> =
15928×
            sidebar_in(&files, |d| *d, "src/lib/b.rs").into_iter().map(|e| (e.path, e.depth, e.dir, e.on_path)).collect();
15931×
        let want = [
15941×
            ("docs", 0, true, false),
15951×
            ("src", 0, true, true),
15961×
            ("src/lib", 1, true, true),
15971×
            ("src/lib/a.rs", 2, false, false),
15981×
            ("src/lib/b.rs", 2, false, true),
15991×
            ("src/main.rs", 1, false, false),
16001×
            ("README.md", 0, false, false),
16011×
            ("Zeta.txt", 0, false, false),
16021×
        ];
16038×
        assert_eq!(rows, want.iter().map(|(p, d, dir, on)| (p.to_string(), *d, *dir, *on)).collect::<Vec<_>>());
16041×
    }
1605
1606
    #[test]
16071×
    fn sidebar_cuts_long_directories_but_keeps_the_file() {
1608250×
        let names: Vec<String> = (0..250).map(|i| format!("big/f{i:03}.txt")).collect();
16091×
        let refs: Vec<&str> = names.iter().map(String::as_str).collect();
16101×
        let files = tree(&refs);
16111×
        let rows = sidebar_in(&files, |d| *d, "big/f240.txt");
1612203×
        let listed: Vec<&SideEntry> = rows.iter().filter(|e| e.depth == 1 && !e.more).collect();
16131×
        assert_eq!(listed.len(), SIDEBAR_PER_DIR + 1, "the limit plus the file itself");
16141×
        assert!(listed.last().unwrap().on_path);
16151×
        let more = rows.iter().find(|e| e.more).unwrap();
16161×
        assert_eq!((more.name.as_str(), more.path.as_str()), ("50 more", "big"));
16171×
    }
1618
1619
    #[test]
16201×
    fn state_addresses_are_sorted_and_bounded() {
16211×
        let a = ChangeHash::from_hex(&"a".repeat(64)).unwrap();
16221×
        let b = ChangeHash::from_hex(&"b".repeat(64)).unwrap();
16231×
        assert_eq!(state_spec(&[b, a, b]).unwrap(), format!("{}+{}", "a".repeat(64), "b".repeat(64)));
16241×
        assert_eq!(state_spec(&[]), None);
16251×
        assert_eq!(state_spec(&vec![a; MAX_STATE_HEADS + 1]), None);
16261×
        let t = RefTarget { label: state_spec(&[a, b]).unwrap(), kind: RefKind::Change, heads: vec![a, b] };
16271×
        assert_eq!(t.short_label(), format!("{} +1", "a".repeat(12)));
16281×
        assert_eq!(RefTarget { label: "main".into(), kind: RefKind::Bookmark, heads: vec![a] }.short_label(), "main");
16291×
    }
1630
}