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