OXYGENHUB
# 19c71c828f86 oxygenhub / crates / hub / src / repos / view.rs
30.5 KiB · Rust · last changed Milestone 0.1: browse 1 month ago source blame history raw
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;
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, State};
10
use oxy_core::tree::TreeConflict;
11
use std::collections::{BTreeMap, BTreeSet, HashMap};
12
13
type Result<T> = std::result::Result<T, RepoError>;
14
15
fn fail(msg: impl Into<String>) -> RepoError {
16
    RepoError::Oxy(oxy_core::repo::RepoError::Msg(msg.into()))
17
}
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
/// Resolve a bookmark name, a tag name, or a change hash prefix.
38
pub fn resolve(repo: &Repo, spec: &str) -> Result<RefTarget> {
39
    let view = repo.view()?;
40
    if let Some(heads) = view.bookmarks.get(spec).map(|st| repo.store().get_state(st).map(|s| s.heads)).transpose()? {
41
        return Ok(RefTarget { label: spec.into(), kind: RefKind::Bookmark, heads });
42
    }
43
    if let Some(heads) = repo.tag_heads(spec)? {
44
        return Ok(RefTarget { label: spec.into(), kind: RefKind::Tag, heads });
45
    }
46
    let hash = resolve_change(repo, spec)?;
47
    Ok(RefTarget { label: hash.to_hex(), kind: RefKind::Change, heads: vec![hash] })
48
}
49
50
/// A change by full hash or unique hex prefix, searched over every
51
/// bookmark's state.
52
pub fn resolve_change(repo: &Repo, spec: &str) -> Result<ChangeHash> {
53
    let spec = spec.trim_start_matches('#');
54
    if spec.len() < 4 || !spec.chars().all(|c| c.is_ascii_hexdigit()) {
55
        return Err(fail(format!("{spec:?} is not a change")));
56
    }
57
    if let Some(h) = ChangeHash::from_hex(spec) {
58
        if repo.store().has(&h) {
59
            return Ok(h);
60
        }
61
    }
62
    let heads = all_heads(repo)?;
63
    let state = repo.load_state(&heads)?;
64
    let matches: Vec<ChangeHash> = state.changes().map(|(h, _)| *h).filter(|h| h.starts_with_hex(spec)).collect();
65
    match matches.len() {
66
        1 => Ok(matches[0]),
67
        0 => Err(fail(format!("no change matches {spec:?}"))),
68
        _ => Err(fail(format!("{spec:?} is ambiguous"))),
69
    }
70
}
71
72
/// The heads of every bookmark, deduplicated.
73
pub fn all_heads(repo: &Repo) -> Result<Vec<ChangeHash>> {
74
    let mut set = BTreeSet::new();
75
    for b in repo.bookmarks()? {
76
        set.extend(b.heads);
77
    }
78
    Ok(set.into_iter().collect())
79
}
80
81
// ---------------------------------------------------------------- authors
82
83
#[derive(Clone, Debug, PartialEq, Eq)]
84
pub struct AuthorView {
85
    pub name: String,
86
    pub email: String,
87
    pub key: String,
88
    /// True when the key is not an identity the store knows.
89
    pub unknown: bool,
90
}
91
92
pub fn author(repo: &Repo, key: &KeyId, message: &str) -> AuthorView {
93
    if key.is_zero() {
94
        // Imported from Git: the trailer carries the author.
95
        let line = message.lines().rev().find_map(|l| l.strip_prefix("Git-Author: ")).unwrap_or("unknown");
96
        let (name, email) = match line.split_once('<') {
97
            Some((n, e)) => (n.trim().to_string(), e.trim_end_matches('>').trim().to_string()),
98
            None => (line.trim().to_string(), String::new()),
99
        };
100
        return AuthorView { name, email, key: String::new(), unknown: true };
101
    }
102
    match repo.store().get_identity(key) {
103
        Ok(id) => AuthorView { name: id.name, email: id.email, key: key.to_hex(), unknown: false },
104
        Err(_) => AuthorView { name: key.short(12), email: String::new(), key: key.to_hex(), unknown: true },
105
    }
106
}
107
108
// ---------------------------------------------------------------- changes
109
110
#[derive(Clone, Debug, PartialEq, Eq)]
111
pub enum SigView {
112
    Unsigned,
113
    Valid { key: String, name: String, sealed: bool, revoked: bool },
114
    Invalid(String),
115
}
116
117
impl SigView {
118
    pub fn glyph(&self) -> &'static str {
119
        match self {
120
            SigView::Unsigned => "○",
121
            SigView::Valid { revoked: true, .. } => "⊘",
122
            SigView::Valid { sealed: true, .. } => "◈",
123
            SigView::Valid { .. } => "✔",
124
            SigView::Invalid(_) => "✘",
125
        }
126
    }
127
128
    pub fn class(&self) -> &'static str {
129
        match self {
130
            SigView::Unsigned => "sig-unsigned",
131
            SigView::Valid { revoked: true, .. } => "sig-revoked",
132
            SigView::Valid { .. } => "sig-valid",
133
            SigView::Invalid(_) => "sig-invalid",
134
        }
135
    }
136
137
    pub fn title(&self) -> String {
138
        match self {
139
            SigView::Unsigned => "unsigned".into(),
140
            SigView::Valid { name, sealed: true, revoked: false, .. } => format!("sealed by {name}"),
141
            SigView::Valid { name, revoked: true, .. } => format!("signed by {name}, key revoked"),
142
            SigView::Valid { name, .. } => format!("signed by {name}"),
143
            SigView::Invalid(why) => format!("INVALID: {why}"),
144
        }
145
    }
146
}
147
148
pub fn signature(repo: &Repo, hash: &ChangeHash) -> SigView {
149
    match repo.verify_change(hash) {
150
        Ok(SignatureStatus::Unsigned) => SigView::Unsigned,
151
        Ok(SignatureStatus::Valid { key, name, sealed, revoked, .. }) => SigView::Valid { key: key.to_hex(), name, sealed, revoked },
152
        Ok(SignatureStatus::Invalid(why)) => SigView::Invalid(why),
153
        Err(e) => SigView::Invalid(e.to_string()),
154
    }
155
}
156
157
/// One change as history rows and change pages show it.
158
#[derive(Clone, Debug)]
159
pub struct ChangeView {
160
    pub hash: String,
161
    pub short: String,
162
    pub title: String,
163
    pub body: String,
164
    pub author: AuthorView,
165
    pub when_ms: u64,
166
    pub deps: Vec<String>,
167
    pub head: bool,
168
    pub resolution: bool,
169
    pub signature: SigView,
170
    /// Bookmarks and tags whose state has this change as a head.
171
    pub labels: Vec<Label>,
172
}
173
174
#[derive(Clone, Debug, PartialEq, Eq)]
175
pub struct Label {
176
    pub name: String,
177
    pub kind: RefKind,
178
}
179
180
pub fn split_message(message: &str) -> (String, String) {
181
    let mut lines = message.lines();
182
    let title = lines.next().unwrap_or("").trim().to_string();
183
    let body = lines.collect::<Vec<_>>().join("\n").trim().to_string();
184
    (title, body)
185
}
186
187
/// Every bookmark and tag head, for labelling rows.
188
pub fn head_labels(repo: &Repo) -> Result<HashMap<ChangeHash, Vec<Label>>> {
189
    let mut out: HashMap<ChangeHash, Vec<Label>> = HashMap::new();
190
    for b in repo.bookmarks()? {
191
        for h in b.heads {
192
            out.entry(h).or_default().push(Label { name: b.name.clone(), kind: RefKind::Bookmark });
193
        }
194
    }
195
    for t in repo.tags()? {
196
        for h in t.heads {
197
            out.entry(h).or_default().push(Label { name: t.name.clone(), kind: RefKind::Tag });
198
        }
199
    }
200
    Ok(out)
201
}
202
203
fn change_view(
204
    repo: &Repo,
205
    hash: &ChangeHash,
206
    meta: &oxy_core::state::ChangeMeta,
207
    head: bool,
208
    labels: &HashMap<ChangeHash, Vec<Label>>,
209
) -> ChangeView {
210
    let (title, body) = split_message(&meta.message);
211
    ChangeView {
212
        hash: hash.to_hex(),
213
        short: hash.short(12),
214
        title,
215
        body,
216
        author: author(repo, &meta.author, &meta.message),
217
        when_ms: meta.hlc.wall_ms,
218
        deps: meta.deps.iter().map(|d| d.to_hex()).collect(),
219
        head,
220
        resolution: meta.kind == oxy_core::objects::ChangeKind::Resolution,
221
        signature: signature(repo, hash),
222
        labels: labels.get(hash).cloned().unwrap_or_default(),
223
    }
224
}
225
226
// ---------------------------------------------------------------- history
227
228
#[derive(Clone, Debug)]
229
pub struct History {
230
    pub target: RefTarget,
231
    pub entries: Vec<ChangeView>,
232
    pub total: usize,
233
    pub offset: usize,
234
    pub has_more: bool,
235
    pub path: Option<String>,
236
}
237
238
/// A window of the history of a state, newest first.
239
pub fn history(repo: &Repo, target: RefTarget, path: Option<&str>, offset: usize, limit: usize) -> Result<History> {
240
    let entries = match path {
241
        Some(p) => repo.log_for_path_in(p, &target.heads)?,
242
        None => repo.log_state(&target.heads)?,
243
    };
244
    let total = entries.len();
245
    let labels = head_labels(repo)?;
246
    let window: Vec<ChangeView> =
247
        entries.iter().skip(offset).take(limit).map(|e| change_view(repo, &e.hash, &e.change, e.is_head, &labels)).collect();
248
    Ok(History { target, entries: window, total, offset, has_more: offset + limit < total, path: path.map(String::from) })
249
}
250
251
// ----------------------------------------------------------------- change
252
253
#[derive(Clone, Debug)]
254
pub struct ChangeDetail {
255
    pub view: ChangeView,
256
    pub dependents: Vec<ChangeView>,
257
    pub deps: Vec<ChangeView>,
258
    pub files: Vec<FileDiff>,
259
    /// Base content of each modified file, for context lines.
260
    pub bases: BTreeMap<String, Vec<u8>>,
261
    pub in_bookmarks: Vec<String>,
262
    pub in_tags: Vec<String>,
263
    pub atoms: usize,
264
    pub tree_ops: usize,
265
}
266
267
pub fn change(repo: &Repo, hash: &ChangeHash) -> Result<ChangeDetail> {
268
    let c: Change = repo.store().get_change(hash)?;
269
    let labels = head_labels(repo)?;
270
    let heads = all_heads(repo)?;
271
    let state = repo.load_state(&heads)?;
272
    let is_head = state.heads().contains(hash);
273
    let meta = oxy_core::state::ChangeMeta::of(&c);
274
    let view = change_view(repo, hash, &meta, is_head, &labels);
275
    let deps = c.deps.iter().filter_map(|d| state.get(d).map(|m| change_view(repo, d, m, state.heads().contains(d), &labels))).collect();
276
    let mut dependents: Vec<ChangeView> = state
277
        .changes()
278
        .filter(|(_, m)| m.deps.contains(hash))
279
        .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels))
280
        .collect();
281
    dependents.sort_by_key(|c| std::cmp::Reverse(c.when_ms));
282
    let files = repo.diff_files(Some(hash))?;
283
    let base_state = repo.load_state(&c.deps)?;
284
    let base_snapshot = base_state.materialize();
285
    let mut bases = BTreeMap::new();
286
    for f in &files {
287
        let old = f.from.as_deref().unwrap_or(&f.path);
288
        if let Some(FileContent::Text { bytes, .. }) = base_snapshot.files.get(old).map(|s| &s.content) {
289
            bases.insert(f.path.clone(), bytes.clone());
290
        }
291
    }
292
    let mut in_bookmarks = Vec::new();
293
    for b in repo.bookmarks()? {
294
        if repo.load_state(&b.heads)?.contains(hash) {
295
            in_bookmarks.push(b.name);
296
        }
297
    }
298
    let mut in_tags = Vec::new();
299
    for t in repo.tags()? {
300
        if repo.load_state(&t.heads)?.contains(hash) {
301
            in_tags.push(t.name);
302
        }
303
    }
304
    Ok(ChangeDetail { view, dependents, deps, files, bases, in_bookmarks, in_tags, atoms: c.edits.len(), tree_ops: c.tree_ops.len() })
305
}
306
307
// ------------------------------------------------------------------- tree
308
309
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
310
pub enum EntryKind {
311
    Dir,
312
    File,
313
    Symlink,
314
    Subrepo,
315
}
316
317
#[derive(Clone, Debug)]
318
pub struct Entry {
319
    pub name: String,
320
    pub path: String,
321
    pub kind: EntryKind,
322
    pub exec: bool,
323
    pub size: Option<u64>,
324
    pub conflicted: bool,
325
}
326
327
#[derive(Clone, Debug)]
328
pub struct Tree {
329
    pub target: RefTarget,
330
    pub path: String,
331
    pub entries: Vec<Entry>,
332
    /// `(name, path)` from the root down to this directory.
333
    pub crumbs: Vec<(String, String)>,
334
    pub readme: Option<Readme>,
335
}
336
337
#[derive(Clone, Debug)]
338
pub struct Readme {
339
    pub path: String,
340
    pub text: String,
341
}
342
343
pub fn crumbs(path: &str) -> Vec<(String, String)> {
344
    let mut out = Vec::new();
345
    let mut so_far = String::new();
346
    for part in path.split('/').filter(|p| !p.is_empty()) {
347
        if !so_far.is_empty() {
348
            so_far.push('/');
349
        }
350
        so_far.push_str(part);
351
        out.push((part.to_string(), so_far.clone()));
352
    }
353
    out
354
}
355
356
fn entry_of(path: &str, name: &str, f: &oxy_core::state::FileState) -> Entry {
357
    let kind = match f.kind {
358
        NodeKind::Dir => EntryKind::Dir,
359
        NodeKind::File => EntryKind::File,
360
        NodeKind::Symlink => EntryKind::Symlink,
361
        NodeKind::Subrepo => EntryKind::Subrepo,
362
    };
363
    let size = match &f.content {
364
        FileContent::Text { bytes, .. } => Some(bytes.len() as u64),
365
        _ => None,
366
    };
367
    Entry { name: name.to_string(), path: path.to_string(), kind, exec: f.mode.exec, size, conflicted: f.is_conflicted() }
368
}
369
370
/// The entries directly under `dir` (`""` for the root).
371
pub fn tree(repo: &Repo, target: RefTarget, dir: &str) -> Result<Tree> {
372
    let state = repo.load_state(&target.heads)?;
373
    let snapshot = state.materialize();
374
    tree_of(repo, target, &snapshot, dir)
375
}
376
377
pub fn tree_of(_repo: &Repo, target: RefTarget, snapshot: &Snapshot, dir: &str) -> Result<Tree> {
378
    let dir = dir.trim_matches('/');
379
    if !dir.is_empty() {
380
        match snapshot.files.get(dir) {
381
            Some(f) if f.kind == NodeKind::Dir => {}
382
            _ => return Err(RepoError::NotFound),
383
        }
384
    }
385
    let prefix = if dir.is_empty() { String::new() } else { format!("{dir}/") };
386
    let mut entries: Vec<Entry> = snapshot
387
        .files
388
        .iter()
389
        .filter(|(p, _)| p.starts_with(&prefix) && !p[prefix.len()..].contains('/'))
390
        .map(|(p, f)| entry_of(p, &p[prefix.len()..], f))
391
        .collect();
392
    entries.sort_by(|a, b| {
393
        (a.kind != EntryKind::Dir).cmp(&(b.kind != EntryKind::Dir)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
394
    });
395
    let readme = entries
396
        .iter()
397
        .find(|e| {
398
            e.kind == EntryKind::File && matches!(e.name.to_lowercase().as_str(), "readme.md" | "readme" | "readme.txt" | "readme.markdown")
399
        })
400
        .and_then(|e| match &snapshot.files.get(&e.path)?.content {
401
            FileContent::Text { bytes, .. } => Some(Readme { path: e.path.clone(), text: String::from_utf8_lossy(bytes).into_owned() }),
402
            _ => None,
403
        });
404
    Ok(Tree { target, path: dir.to_string(), entries, crumbs: crumbs(dir), readme })
405
}
406
407
// ------------------------------------------------------------------- file
408
409
#[derive(Clone, Debug)]
410
pub enum FileBody {
411
    Text(String),
412
    Binary,
413
    TooLarge,
414
}
415
416
#[derive(Clone, Debug)]
417
pub struct FileView {
418
    pub target: RefTarget,
419
    pub path: String,
420
    pub name: String,
421
    pub crumbs: Vec<(String, String)>,
422
    pub kind: EntryKind,
423
    pub exec: bool,
424
    pub size: u64,
425
    pub body: FileBody,
426
    pub conflicted: bool,
427
    /// Raw bytes, for the `?plain=1` response and images.
428
    pub bytes: Vec<u8>,
429
    pub last_change: Option<ChangeView>,
430
}
431
432
pub fn file(repo: &Repo, target: RefTarget, path: &str, render_limit: u64) -> Result<FileView> {
433
    let state = repo.load_state(&target.heads)?;
434
    let snapshot = state.materialize();
435
    let path = path.trim_matches('/');
436
    let Some(f) = snapshot.files.get(path) else { return Err(RepoError::NotFound) };
437
    let (kind, bytes) = match (&f.kind, &f.content) {
438
        (NodeKind::Dir, _) => return Err(RepoError::NotFound),
439
        (NodeKind::Symlink, FileContent::Text { bytes, .. }) => (EntryKind::Symlink, bytes.clone()),
440
        (NodeKind::Subrepo, FileContent::Text { bytes, .. }) => (EntryKind::Subrepo, bytes.clone()),
441
        (_, FileContent::Text { bytes, .. }) => (EntryKind::File, bytes.clone()),
442
        (_, FileContent::Opaque { blobs }) => {
443
            let bytes = match blobs.first() {
444
                Some(b) => repo.read_blob(b)?,
445
                None => Vec::new(),
446
            };
447
            (EntryKind::File, bytes)
448
        }
449
        (_, FileContent::Dir) => return Err(RepoError::NotFound),
450
    };
451
    let size = bytes.len() as u64;
452
    let body = if size > render_limit {
453
        FileBody::TooLarge
454
    } else if is_binary(&bytes) {
455
        FileBody::Binary
456
    } else {
457
        FileBody::Text(String::from_utf8_lossy(&bytes).into_owned())
458
    };
459
    let labels = head_labels(repo)?;
460
    let last_change = repo
461
        .log_for_path_in(path, &target.heads)
462
        .ok()
463
        .and_then(|l| l.into_iter().next())
464
        .map(|e| change_view(repo, &e.hash, &e.change, e.is_head, &labels));
465
    let name = path.rsplit('/').next().unwrap_or(path).to_string();
466
    Ok(FileView {
467
        target,
468
        path: path.to_string(),
469
        name,
470
        crumbs: crumbs(path),
471
        kind,
472
        exec: f.mode.exec,
473
        size,
474
        body,
475
        conflicted: f.is_conflicted(),
476
        bytes,
477
        last_change,
478
    })
479
}
480
481
pub fn is_binary(bytes: &[u8]) -> bool {
482
    let head = &bytes[..bytes.len().min(8192)];
483
    head.contains(&0)
484
        || std::str::from_utf8(head).is_err()
485
            && head.iter().filter(|b| **b < 0x20 && !matches!(**b, b'\n' | b'\r' | b'\t')).count() > head.len() / 20
486
}
487
488
/// Last change per entry of a directory, for tree rows (fetched lazily).
489
/// A directory's last change is the newest change touching anything
490
/// beneath it, as GitHub shows it.
491
pub fn last_changes(repo: &Repo, heads: &[ChangeHash], paths: &[String]) -> Result<Vec<(String, ChangeView)>> {
492
    let labels = head_labels(repo)?;
493
    let state = repo.load_state(heads)?;
494
    let snapshot = state.materialize();
495
    // Newest change touching each node, from one pass over the log.
496
    let mut newest: HashMap<oxy_core::ids::NodeId, (ChangeHash, u64)> = HashMap::new();
497
    for e in repo.log_state(heads)? {
498
        let Ok(c) = repo.store().get_change(&e.hash) else { continue };
499
        let when = e.change.hlc.wall_ms;
500
        for node in c.tree_ops.iter().map(|o| o.node()).chain(c.edits.iter().map(|x| x.node())) {
501
            newest.entry(node).or_insert((e.hash, when));
502
        }
503
    }
504
    let mut out = Vec::new();
505
    for p in paths {
506
        let Some(f) = snapshot.files.get(p) else { continue };
507
        let best = if f.kind == NodeKind::Dir {
508
            let prefix = format!("{p}/");
509
            snapshot
510
                .files
511
                .iter()
512
                .filter(|(q, _)| q.starts_with(&prefix))
513
                .filter_map(|(_, g)| newest.get(&g.node))
514
                .chain(newest.get(&f.node))
515
                .max_by_key(|(_, when)| *when)
516
                .copied()
517
        } else {
518
            newest.get(&f.node).copied()
519
        };
520
        if let Some((hash, _)) = best {
521
            if let Some(m) = state.get(&hash) {
522
                out.push((p.clone(), change_view(repo, &hash, m, state.heads().contains(&hash), &labels)));
523
            }
524
        }
525
    }
526
    Ok(out)
527
}
528
529
// ------------------------------------------------------------------ blame
530
531
#[derive(Clone, Debug)]
532
pub struct Blame {
533
    pub file: FileView,
534
    pub lines: Vec<BlameLine>,
535
}
536
537
pub fn blame(repo: &Repo, target: RefTarget, path: &str, render_limit: u64) -> Result<Blame> {
538
    let heads = target.heads.clone();
539
    let file = file(repo, target, path, render_limit)?;
540
    let lines = repo.blame_in(path, &heads)?;
541
    Ok(Blame { file, lines })
542
}
543
544
// -------------------------------------------------------------- overview
545
546
#[derive(Clone, Debug)]
547
pub struct BookmarkView {
548
    pub name: String,
549
    pub heads: Vec<String>,
550
    pub changes: usize,
551
    pub default: bool,
552
    pub conflicted: bool,
553
    pub latest_ms: u64,
554
}
555
556
#[derive(Clone, Debug)]
557
pub struct TagView {
558
    pub name: String,
559
    pub heads: Vec<String>,
560
    pub message: String,
561
    pub signer: Option<String>,
562
}
563
564
#[derive(Clone, Debug)]
565
pub struct Overview {
566
    pub tree: Tree,
567
    pub changes: usize,
568
    pub heads: usize,
569
    pub bookmarks: Vec<BookmarkView>,
570
    pub tags: Vec<TagView>,
571
    pub latest: Option<ChangeView>,
572
    pub conflicted: bool,
573
    pub license: Option<String>,
574
    pub policy: PolicyBadge,
575
}
576
577
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
578
pub enum PolicyBadge {
579
    None,
580
    Active,
581
    Expired,
582
}
583
584
pub fn bookmarks(repo: &Repo, default: &str) -> Result<Vec<BookmarkView>> {
585
    let mut out = Vec::new();
586
    for b in repo.bookmarks()? {
587
        let state = repo.load_state(&b.heads)?;
588
        let latest_ms = state.changes().map(|(_, m)| m.hlc.wall_ms).max().unwrap_or(0);
589
        let conflicted = state.materialize().is_conflicted();
590
        out.push(BookmarkView {
591
            name: b.name.clone(),
592
            heads: b.heads.iter().map(|h| h.to_hex()).collect(),
593
            changes: state.len(),
594
            default: b.name == default,
595
            conflicted,
596
            latest_ms,
597
        });
598
    }
599
    out.sort_by(|a, b| b.default.cmp(&a.default).then_with(|| a.name.cmp(&b.name)));
600
    Ok(out)
601
}
602
603
pub fn tags(repo: &Repo) -> Result<Vec<TagView>> {
604
    let mut out: Vec<TagView> = repo
605
        .tags()?
606
        .into_iter()
607
        .map(|t| TagView { name: t.name, heads: t.heads.iter().map(|h| h.to_hex()).collect(), message: t.message, signer: t.signer })
608
        .collect();
609
    out.sort_by(|a, b| b.name.cmp(&a.name));
610
    Ok(out)
611
}
612
613
pub fn policy_badge(repo: &Repo) -> PolicyBadge {
614
    match repo.current_policy() {
615
        Ok(Some((_, sp))) => {
616
            if sp.policy.expires != 0 && (sp.policy.expires as i64) < crate::db::now_ms() {
617
                PolicyBadge::Expired
618
            } else {
619
                PolicyBadge::Active
620
            }
621
        }
622
        _ => PolicyBadge::None,
623
    }
624
}
625
626
pub fn overview(repo: &Repo, default_bookmark: &str) -> Result<Overview> {
627
    let target = match resolve(repo, default_bookmark) {
628
        Ok(t) => t,
629
        Err(_) => RefTarget { label: default_bookmark.into(), kind: RefKind::Bookmark, heads: vec![] },
630
    };
631
    let heads = target.heads.len();
632
    // One load of the default state serves the counts, the latest change
633
    // and the tree (hub spec §16.3).
634
    let state = repo.load_state(&target.heads)?;
635
    let snapshot = state.materialize();
636
    let changes = state.len();
637
    let conflicted = snapshot.is_conflicted();
638
    let labels = head_labels(repo)?;
639
    let latest = state
640
        .changes()
641
        .max_by(|(ha, a), (hb, b)| a.hlc.cmp(&b.hlc).then(ha.cmp(hb)))
642
        .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels));
643
    let tree = tree_of(repo, target, &snapshot, "")?;
644
    let license = tree
645
        .entries
646
        .iter()
647
        .find(|e| matches!(e.name.to_lowercase().as_str(), "license" | "license.md" | "license.txt" | "licence" | "copying"))
648
        .map(|e| e.path.clone());
649
    Ok(Overview {
650
        tree,
651
        changes,
652
        heads,
653
        bookmarks: bookmarks(repo, default_bookmark)?,
654
        tags: tags(repo)?,
655
        latest,
656
        conflicted,
657
        license,
658
        policy: policy_badge(repo),
659
    })
660
}
661
662
// -------------------------------------------------------------- compare
663
664
#[derive(Clone, Debug)]
665
pub struct Compare {
666
    pub base: RefTarget,
667
    pub head: RefTarget,
668
    /// Changes in `head`'s state not in `base`'s.
669
    pub added: Vec<ChangeView>,
670
    /// Changes in `base`'s state not in `head`'s.
671
    pub missing: usize,
672
    pub files: Vec<FileDiff>,
673
    pub bases: BTreeMap<String, Vec<u8>>,
674
}
675
676
pub fn compare(repo: &Repo, base: RefTarget, head: RefTarget) -> Result<Compare> {
677
    let base_state = repo.load_state(&base.heads)?;
678
    let head_state = repo.load_state(&head.heads)?;
679
    let labels = head_labels(repo)?;
680
    let mut added: Vec<ChangeView> = head_state
681
        .changes()
682
        .filter(|(h, _)| !base_state.contains(h))
683
        .map(|(h, m)| change_view(repo, h, m, head_state.heads().contains(h), &labels))
684
        .collect();
685
    added.sort_by_key(|c| std::cmp::Reverse(c.when_ms));
686
    let missing = base_state.changes().filter(|(h, _)| !head_state.contains(h)).count();
687
    let base_snapshot = base_state.materialize();
688
    let head_snapshot = head_state.materialize();
689
    let (files, bases) = crate::render::diff::snapshot_diff(&base_snapshot, &head_snapshot);
690
    Ok(Compare { base, head, added, missing, files, bases })
691
}
692
693
// ------------------------------------------------------------ conflicts
694
695
#[derive(Clone, Debug)]
696
pub struct ConflictView {
697
    pub bookmark: String,
698
    pub path: String,
699
    pub kind: String,
700
    /// The file rendered with `oxy` conflict markers, when a text file.
701
    pub marked: Option<String>,
702
    pub contributors: Vec<ChangeView>,
703
}
704
705
pub fn conflicts(repo: &Repo) -> Result<Vec<ConflictView>> {
706
    let labels = head_labels(repo)?;
707
    let mut out = Vec::new();
708
    for b in repo.bookmarks()? {
709
        let state = repo.load_state(&b.heads)?;
710
        let snapshot = state.materialize();
711
        for c in &snapshot.tree.conflicts {
712
            let (path, kind) = describe_tree_conflict(&snapshot, c);
713
            out.push(ConflictView { bookmark: b.name.clone(), path, kind, marked: None, contributors: vec![] });
714
        }
715
        for (path, f) in &snapshot.files {
716
            if !f.is_conflicted() {
717
                continue;
718
            }
719
            let (kind, marked, contributors) = match &f.content {
720
                FileContent::Text { .. } => {
721
                    let (bytes, _) = state.render_file(&f.node);
722
                    let regions = state.conflict_regions(&f.node);
723
                    let contributors: Vec<ChangeView> = regions
724
                        .contributors
725
                        .iter()
726
                        .filter_map(|h| state.get(h).map(|m| change_view(repo, h, m, state.heads().contains(h), &labels)))
727
                        .collect();
728
                    ("lines".to_string(), Some(String::from_utf8_lossy(&bytes).into_owned()), contributors)
729
                }
730
                FileContent::Opaque { blobs } => (format!("replace ({} candidates)", blobs.len()), None, vec![]),
731
                FileContent::Dir => continue,
732
            };
733
            out.push(ConflictView { bookmark: b.name.clone(), path: path.clone(), kind, marked, contributors });
734
        }
735
    }
736
    Ok(out)
737
}
738
739
fn describe_tree_conflict(snapshot: &Snapshot, c: &TreeConflict) -> (String, String) {
740
    let by_node = snapshot.tree.by_node();
741
    let path_of = |n: &oxy_core::ids::NodeId| by_node.get(n).map(|e| e.path.clone()).unwrap_or_else(|| n.short(8));
742
    match c {
743
        TreeConflict::Name { name, nodes, .. } => (name.clone(), format!("name: {} entries want this name", nodes.len())),
744
        TreeConflict::MultiPlacement { node, placements } => (path_of(node), format!("placement: {} places", placements.len())),
745
        TreeConflict::Orphan { node, .. } => (path_of(node), "orphan: parent directory was removed".into()),
746
        TreeConflict::Cycle { node } => (path_of(node), "cycle: directory moved into itself".into()),
747
        other => (String::new(), format!("{other:?}")),
748
    }
749
}
750
751
// ------------------------------------------------------------------ trust
752
753
#[derive(Clone, Debug)]
754
pub struct KeyView {
755
    pub key: String,
756
    pub short: String,
757
    pub name: String,
758
    pub email: String,
759
}
760
761
#[derive(Clone, Debug)]
762
pub struct RuleView {
763
    pub pattern: String,
764
    pub keys: Vec<KeyView>,
765
    pub threshold: u32,
766
    pub require: String,
767
    pub allow_conflicts: bool,
768
}
769
770
#[derive(Clone, Debug)]
771
pub struct PolicyView {
772
    pub serial: u64,
773
    pub expires_ms: u64,
774
    pub expired: bool,
775
    pub roots: Vec<KeyView>,
776
    pub root_threshold: u32,
777
    pub rules: Vec<RuleView>,
778
    pub revoked: Vec<KeyView>,
779
    pub signatures: usize,
780
    pub history: usize,
781
}
782
783
#[derive(Clone, Debug, Default)]
784
pub struct Verification {
785
    pub total: usize,
786
    pub signed: usize,
787
    pub sealed: usize,
788
    pub unsigned: usize,
789
    pub revoked: usize,
790
    pub invalid: Vec<(String, String)>,
791
}
792
793
#[derive(Clone, Debug)]
794
pub struct Trust {
795
    pub policy: Option<PolicyView>,
796
    pub verification: Verification,
797
    pub hub_key: String,
798
    pub hub_delegated_by: Vec<KeyView>,
799
}
800
801
fn key_view(repo: &Repo, ids: &[oxy_core::identity::Identity], key: &KeyId) -> KeyView {
802
    let found = ids.iter().find(|i| i.hash() == *key).cloned().or_else(|| repo.store().get_identity(key).ok());
803
    match found {
804
        Some(i) => KeyView { key: key.to_hex(), short: key.short(12), name: i.name, email: i.email },
805
        None => KeyView { key: key.to_hex(), short: key.short(12), name: String::new(), email: String::new() },
806
    }
807
}
808
809
pub fn verification(repo: &Repo) -> Result<Verification> {
810
    let heads = all_heads(repo)?;
811
    let state = repo.load_state(&heads)?;
812
    let mut v = Verification::default();
813
    for (h, _) in state.changes() {
814
        v.total += 1;
815
        match signature(repo, h) {
816
            SigView::Unsigned => v.unsigned += 1,
817
            SigView::Valid { sealed, revoked, .. } => {
818
                if revoked {
819
                    v.revoked += 1;
820
                }
821
                if sealed {
822
                    v.sealed += 1
823
                } else {
824
                    v.signed += 1
825
                }
826
            }
827
            SigView::Invalid(why) => v.invalid.push((h.to_hex(), why)),
828
        }
829
    }
830
    Ok(v)
831
}
832
833
pub fn trust(repo: &Repo, hub_key: &KeyId) -> Result<Trust> {
834
    let policy = match repo.current_policy()? {
835
        Some((_, sp)) => {
836
            let p = &sp.policy;
837
            let ids = &p.identities;
838
            let require = |r: &oxy_core::policy::Require| match r {
839
                oxy_core::policy::Require::Signed => "signed",
840
                oxy_core::policy::Require::Sealed => "sealed",
841
                oxy_core::policy::Require::Any => "any",
842
            };
843
            Some(PolicyView {
844
                serial: p.serial,
845
                expires_ms: p.expires,
846
                expired: p.expires != 0 && (p.expires as i64) < crate::db::now_ms(),
847
                roots: p.roots.iter().map(|k| key_view(repo, ids, k)).collect(),
848
                root_threshold: p.root_threshold,
849
                rules: p
850
                    .rules
851
                    .iter()
852
                    .map(|r| RuleView {
853
                        pattern: r.pattern.clone(),
854
                        keys: r.keys.iter().map(|k| key_view(repo, ids, k)).collect(),
855
                        threshold: r.threshold,
856
                        require: require(&r.require).into(),
857
                        allow_conflicts: r.allow_conflicts,
858
                    })
859
                    .collect(),
860
                revoked: p.revoked.iter().map(|k| key_view(repo, ids, k)).collect(),
861
                signatures: sp.sigs.len(),
862
                history: repo.policy_history().map(|h| h.len()).unwrap_or(0),
863
            })
864
        }
865
        None => None,
866
    };
867
    // Who has delegated to the hub: every identity in the policy or store
868
    // that lists the hub key.
869
    let mut delegated = Vec::new();
870
    if let Some(p) = repo.current_policy()?.map(|(_, sp)| sp.policy) {
871
        for i in &p.identities {
872
            if i.delegates.contains(hub_key) {
873
                delegated.push(KeyView { key: i.hash().to_hex(), short: i.hash().short(12), name: i.name.clone(), email: i.email.clone() });
874
            }
875
        }
876
    }
877
    Ok(Trust { policy, verification: verification(repo)?, hub_key: hub_key.to_hex(), hub_delegated_by: delegated })
878
}
879
880
// ------------------------------------------------------------- helpers
881
882
/// The state of a bookmark or any heads, for callers that need it raw.
883
pub fn state_of(repo: &Repo, heads: &[ChangeHash]) -> Result<State> {
884
    Ok(repo.load_state(heads)?)
885
}