OXYGENHUB
# 1b6536f06132 oxygenhub / crates / hub / src / diffusion.rs
25.5 KiB · Rust · last changed Diffusion: a readable title 24 days ago source blame history raw
1
//! Diffusion (#148, docs/diffusion.md): an upstream's changes reach the
2
//! repositories that pin it as a subrepo.
3
//!
4
//! D1 starts with the reverse pin index (§4.1): when a push moves a
5
//! repository's default bookmark, or the bookmark its work lands on, each
6
//! Oxygen subrepo pin in the new state is recorded. That answers "who
7
//! consumes this repository, at which state, and how far behind", which
8
//! an upstream's page shows as Used by.
9
10
use crate::auth::roles;
11
use crate::db::{self, accounts::Account, repos::RepoRow, Pool};
12
use crate::repos::RepoName;
13
use crate::Shared;
14
use oxy_core::hash::ChangeHash;
15
use serde::Serialize;
16
17
/// How far back landed proposals teach where a repository's work lands.
18
pub const LEARN_DAYS: i64 = 90;
19
20
/// The bookmark a repository's work lands on (§4.3): the most common
21
/// target of its proposals landed in the last `LEARN_DAYS`, else its
22
/// default bookmark.
23
pub async fn target(pool: &Pool, row: &RepoRow) -> String {
24
    let since = db::now_ms() - LEARN_DAYS * 24 * 3600 * 1000;
25
    let learned: Option<String> = sqlx::query_scalar(
26
        "SELECT target FROM proposals WHERE repo_id = ? AND state = 'landed' AND landed_ms >= ?
27
         GROUP BY target ORDER BY COUNT(*) DESC, MAX(landed_ms) DESC LIMIT 1",
28
    )
29
    .bind(row.id)
30
    .bind(since)
31
    .fetch_optional(pool)
32
    .await
33
    .ok()
34
    .flatten();
35
    learned.filter(|t| !t.is_empty()).unwrap_or_else(|| row.default_bookmark.clone())
36
}
37
38
/// The bookmarks whose pins are indexed: the default and the learned
39
/// target (one when they are the same).
40
pub async fn followed_bookmarks(pool: &Pool, row: &RepoRow) -> Vec<String> {
41
    let mut out = vec![row.default_bookmark.clone()];
42
    let t = target(pool, row).await;
43
    if !out.contains(&t) {
44
        out.push(t);
45
    }
46
    out
47
}
48
49
/// Record `row`'s pins on `bookmark` at `heads`, replacing what was there.
50
pub async fn record(app: &Shared, row: &RepoRow, bookmark: &str, heads: &[ChangeHash]) {
51
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return };
52
    let pins = if heads.is_empty() { Vec::new() } else { crate::flow::pins_in(app, name, heads.to_vec()).await };
53
    let now = db::now_ms();
54
    let Ok(mut tx) = app.db.begin().await else { return };
55
    let mut ok = sqlx::query("DELETE FROM diffusion_pins WHERE consumer_id = ? AND bookmark = ?")
56
        .bind(row.id)
57
        .bind(bookmark)
58
        .execute(&mut *tx)
59
        .await
60
        .is_ok();
61
    for (path, id, pinned) in &pins {
62
        let heads: Vec<String> = pinned.iter().map(|h| h.to_hex()).collect();
63
        ok &=
64
            sqlx::query("INSERT INTO diffusion_pins (consumer_id, bookmark, path, upstream, heads, updated_ms) VALUES (?, ?, ?, ?, ?, ?)")
65
                .bind(row.id)
66
                .bind(bookmark)
67
                .bind(path)
68
                .bind(hex::encode(id))
69
                .bind(heads.join("+"))
70
                .bind(now)
71
                .execute(&mut *tx)
72
                .await
73
                .is_ok();
74
    }
75
    if ok {
76
        tx.commit().await.ok();
77
    }
78
}
79
80
/// After a push: index the pins of each moved bookmark that is followed.
81
pub async fn after_push(app: &Shared, row: &RepoRow, moved: &[(String, String, Vec<ChangeHash>)]) {
82
    let followed = followed_bookmarks(&app.db, row).await;
83
    for (bookmark, _, heads) in moved {
84
        if followed.contains(bookmark) {
85
            record(app, row, bookmark, heads).await;
86
        }
87
    }
88
}
89
90
/// Index every repository's followed bookmarks, for a hub that had no
91
/// index before (run once at start, in the background).
92
pub async fn backfill(app: &Shared) {
93
    let Ok(rows) = db::repos::list(&app.db).await else { return };
94
    for row in rows {
95
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
96
        for bookmark in followed_bookmarks(&app.db, &row).await {
97
            let b = bookmark.clone();
98
            let heads = app.repos.read(&name, move |r, _| heads_of(r, &b)).await;
99
            if let Ok(heads) = heads {
100
                record(app, &row, &bookmark, &heads).await;
101
            }
102
        }
103
    }
104
}
105
106
/// A bookmark's heads as the repository has them now (not a cached
107
/// list, which a push refreshes a moment later); empty when it has none.
108
fn heads_of(r: &oxy_core::repo::Repo, bookmark: &str) -> Result<Vec<ChangeHash>, crate::repos::RepoError> {
109
    Ok(r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default())
110
}
111
112
/// One consumer of an upstream, as an upstream's page and API show it.
113
#[derive(Clone, Debug, Serialize)]
114
pub struct Consumer {
115
    pub owner: String,
116
    pub name: String,
117
    pub bookmark: String,
118
    pub path: String,
119
    /// The upstream heads it pins, hex.
120
    pub pinned: Vec<String>,
121
    /// Upstream changes on its default bookmark the pin lacks; `None` when
122
    /// the hub cannot tell (the pinned changes are not in the upstream).
123
    pub behind: Option<usize>,
124
}
125
126
/// The consumers of `row` that `account` may read, with how far behind
127
/// the upstream's default bookmark each pin is.
128
pub async fn consumers(app: &Shared, row: &RepoRow, account: Option<&Account>) -> Result<Vec<Consumer>, crate::repos::RepoError> {
129
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return Ok(Vec::new()) };
130
    let id = app.repos.read(&name, |r, _| Ok(r.id()?)).await?;
131
    let found: Vec<(i64, String, String, String)> = sqlx::query_as(
132
        "SELECT consumer_id, bookmark, path, heads FROM diffusion_pins WHERE upstream = ? ORDER BY consumer_id, bookmark, path",
133
    )
134
    .bind(hex::encode(id))
135
    .fetch_all(&app.db)
136
    .await
137
    .map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e)))?;
138
    let mut out = Vec::new();
139
    let mut pinned_sets = Vec::new();
140
    for (consumer_id, bookmark, path, heads) in found {
141
        let Ok(Some(c)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
142
        if !roles::allows(roles::role_for(&app.db, account, &c).await.ok().flatten(), crate::auth::Access::Read) {
143
            continue;
144
        }
145
        let pinned: Vec<String> = heads.split('+').filter(|h| !h.is_empty()).map(String::from).collect();
146
        pinned_sets.push(pinned.iter().filter_map(|h| ChangeHash::from_hex(h)).collect::<Vec<_>>());
147
        out.push(Consumer { owner: c.owner, name: c.name, bookmark, path, pinned, behind: None });
148
    }
149
    if out.is_empty() {
150
        return Ok(out);
151
    }
152
    let default = row.default_bookmark.clone();
153
    let behind = app
154
        .repos
155
        .read(&name, move |r, cache| {
156
            let tip = heads_of(r, &default)?;
157
            let tip = cache.summary(r, &tip)?;
158
            Ok(pinned_sets
159
                .iter()
160
                .map(|p| cache.summary(r, p).ok().map(|s| tip.changes.iter().filter(|c| !s.changes.contains(*c)).count()))
161
                .collect::<Vec<_>>())
162
        })
163
        .await?;
164
    for (c, b) in out.iter_mut().zip(behind) {
165
        c.behind = b;
166
    }
167
    Ok(out)
168
}
169
170
/// The bookmark a consumer's bump for the subrepo at `path` lives on.
171
pub fn bump_bookmark(path: &str) -> String {
172
    format!("diffusion/{}", path.trim_matches('/'))
173
}
174
175
/// What a bump did.
176
#[derive(Clone, Debug, PartialEq, Eq)]
177
pub enum Bumped {
178
    /// The bump bookmark now holds the target plus one change moving the pin.
179
    Moved { bookmark: String, change: ChangeHash, heads: Vec<ChangeHash>, from: Vec<ChangeHash> },
180
    /// The pin already holds the upstream state.
181
    UpToDate,
182
    /// The bump bookmark already moves the pin to that state.
183
    Already,
184
    /// The upstream state does not hold everything the pin does (upstream
185
    /// moved backwards or was rewritten): consumers only move forward.
186
    Backwards,
187
    /// The consumer has no Oxygen pin of that upstream at `path` on its
188
    /// target bookmark.
189
    NoPin,
190
}
191
192
/// How the upstream's state `new` relates to a consumer's pin `old`, read
193
/// in the upstream: `Some(n)` new changes when `new` holds all of `old`
194
/// and more, `Some(0)` when it adds nothing, `None` when it lacks some.
195
pub async fn ahead_of(
196
    app: &Shared,
197
    upstream: &RepoName,
198
    old: Vec<ChangeHash>,
199
    new: Vec<ChangeHash>,
200
) -> Result<Option<usize>, crate::repos::RepoError> {
201
    app.repos
202
        .read(upstream, move |r, cache| {
203
            let (o, n) = (cache.summary(r, &old)?, cache.summary(r, &new)?);
204
            if !o.changes.iter().all(|c| n.changes.contains(c)) {
205
                return Ok(None);
206
            }
207
            Ok(Some(n.changes.len() - o.changes.len()))
208
        })
209
        .await
210
}
211
212
/// Move `consumer`'s pin of `upstream` at `path` to `new_heads`, on a bump
213
/// bookmark built from `target` (§4.3): one change, signed with the hub's
214
/// key, on the target's heads. The upstream decides forwardness.
215
pub async fn bump(
216
    app: &Shared,
217
    consumer: &RepoRow,
218
    target: &str,
219
    path: &str,
220
    upstream: &RepoRow,
221
    new_heads: Vec<ChangeHash>,
222
) -> Result<Bumped, crate::repos::RepoError> {
223
    use oxy_core::identity::Domain;
224
    use oxy_core::subrepo::Pin;
225
    let (Some(cname), Some(uname)) = (RepoName::new(&consumer.owner, &consumer.name), RepoName::new(&upstream.owner, &upstream.name))
226
    else {
227
        return Ok(Bumped::NoPin);
228
    };
229
    let upstream_id = app.repos.read(&uname, |r, _| Ok(r.id()?)).await?;
230
    // The consumer's current pin at `path` on its target, and on its bump
231
    // bookmark if it has one.
232
    let (t, p) = (target.to_string(), path.trim_matches('/').to_string());
233
    let (current, bumped) = app
234
        .repos
235
        .read(&cname, move |r, cache| {
236
            let pin_on = |bookmark: &str| -> Result<Option<Pin>, crate::repos::RepoError> {
237
                let heads = heads_of(r, bookmark)?;
238
                if heads.is_empty() {
239
                    return Ok(None);
240
                }
241
                let loaded = cache.load(r, &heads)?;
242
                let Some(f) = loaded.snapshot.files.get(&p) else { return Ok(None) };
243
                let oxy_core::state::FileContent::Opaque { blobs } = &f.content else { return Ok(None) };
244
                let Some(b) = blobs.first() else { return Ok(None) };
245
                Ok(Pin::decode(&r.read_blob(b)?).ok())
246
            };
247
            Ok((pin_on(&t)?, pin_on(&bump_bookmark(&p))?))
248
        })
249
        .await?;
250
    let Some(Pin::Oxy { repo, heads: old, bookmark }) = current else { return Ok(Bumped::NoPin) };
251
    if let Some(Pin::Oxy { heads: b, .. }) = &bumped {
252
        let (mut b, mut n) = (b.clone(), new_heads.clone());
253
        b.sort();
254
        n.sort();
255
        if b == n {
256
            return Ok(Bumped::Already);
257
        }
258
    }
259
    if repo != upstream_id {
260
        return Ok(Bumped::NoPin);
261
    }
262
    match ahead_of(app, &uname, old.clone(), new_heads.clone()).await? {
263
        None => return Ok(Bumped::Backwards),
264
        Some(0) => return Ok(Bumped::UpToDate),
265
        Some(_) => {}
266
    }
267
    let (key, secret) =
268
        crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e.to_string())))?;
269
    let identity = app.hub_key.identity.clone();
270
    let upstream_label = format!("{}/{}", upstream.owner, upstream.name);
271
    let (t, p) = (target.to_string(), path.trim_matches('/').to_string());
272
    let pin = Pin::Oxy { repo, heads: new_heads.clone(), bookmark };
273
    let old = old.clone();
274
    let short: Vec<String> = new_heads.iter().map(|h| h.to_hex()[..12].to_string()).collect();
275
    let message = format!("Diffusion: move {p} to {upstream_label} {}", short.join("+"));
276
    app.repos
277
        .write(&cname, move |r, _| {
278
            let base = heads_of(r, &t)?;
279
            let change = r.build_pin_change(&base, &p, &pin, &message, key)?;
280
            let hash = change.hash();
281
            let sig = secret.sign(Domain::Change, &key, &hash);
282
            let mut state = r.load_state(&base)?;
283
            state.add(change.clone()).map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
284
            let mut candidates = base.clone();
285
            candidates.push(hash);
286
            let heads = state.maximal(&candidates);
287
            let bookmark = bump_bookmark(&p);
288
            // The bump bookmark is the hub's own and is rebuilt on the
289
            // target each time, so the bump it replaces may go.
290
            let mut update = r.host_ref_update(&bookmark, &heads, Some((&key, &secret)))?;
291
            update.force = true;
292
            let report = r.apply_hosted(Some((change, vec![sig])), &[identity], update)?;
293
            if let Some(refused) = report.results.iter().find(|x| !x.ok) {
294
                return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
295
                    "the bump was refused: {}",
296
                    refused.reason
297
                ))));
298
            }
299
            Ok(Bumped::Moved { bookmark, change: hash, heads, from: old })
300
        })
301
        .await
302
}
303
304
/// The account Diffusion's proposals are opened as.
305
pub const ACCOUNT: &str = "diffusion";
306
307
/// The `diffusion` account, made on first use with no way to sign in.
308
pub async fn account(pool: &Pool) -> Option<Account> {
309
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
310
        return Some(a);
311
    }
312
    db::accounts::create_account(pool, ACCOUNT, "diffusion@localhost", "!", "", false).await.ok()
313
}
314
315
/// The upstream changes a bump brings: in `new` and not in `old`, newest
316
/// first, as Markdown lines linking to the upstream's change pages.
317
pub async fn changelog(app: &Shared, upstream: &RepoRow, old: Vec<ChangeHash>, new: Vec<ChangeHash>) -> Vec<String> {
318
    let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { return Vec::new() };
319
    let linked: std::collections::HashMap<String, String> =
320
        db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect();
321
    let base = format!("/{}/{}", upstream.owner, upstream.name);
322
    app.repos
323
        .read(&name, move |r, cache| {
324
            let (o, n) = (cache.summary(r, &old)?, cache.summary(r, &new)?);
325
            let mut added: Vec<(u64, ChangeHash, String, String)> = Vec::new();
326
            for h in n.changes.iter().filter(|c| !o.changes.contains(*c)) {
327
                let Ok(c) = r.store().get_change(h) else { continue };
328
                let key = c.author.to_hex();
329
                let who = linked.get(&key).cloned().unwrap_or_else(|| key[..12].to_string());
330
                let first = c.message.lines().next().unwrap_or("").trim().to_string();
331
                added.push((c.hlc.wall_ms, *h, first, who));
332
            }
333
            added.sort_by_key(|a| std::cmp::Reverse(a.0));
334
            Ok(added
335
                .into_iter()
336
                .map(|(_, h, msg, who)| {
337
                    format!("- [`{}`]({base}/change/{}) {} ({who})", &h.to_hex()[..12], h.to_hex(), msg.replace('\n', " "))
338
                })
339
                .collect())
340
        })
341
        .await
342
        .unwrap_or_default()
343
}
344
345
/// Open the bump proposal for `bookmark`, or bring the open one up to
346
/// date: from `bookmark` into `target`, by the `diffusion` account,
347
/// labelled `dependencies`, its body the changelog. Its number.
348
pub async fn propose(
349
    app: &Shared,
350
    consumer: &RepoRow,
351
    target: &str,
352
    path: &str,
353
    upstream: &RepoRow,
354
    from: Vec<ChangeHash>,
355
    to: Vec<ChangeHash>,
356
) -> Option<i64> {
357
    use crate::db::proposals as pdb;
358
    let bookmark = bump_bookmark(path);
359
    let bookmark = bookmark.as_str();
360
    let me = account(&app.db).await?;
361
    let lines = changelog(app, upstream, from, to.clone()).await;
362
    let slug = format!("{}/{}", upstream.owner, upstream.name);
363
    let short: Vec<String> = to.iter().map(|h| h.to_hex()[..12].to_string()).collect();
364
    let ub = &upstream.default_bookmark;
365
    let count = if lines.len() == 1 { "1 change".to_string() } else { format!("{} changes", lines.len()) };
366
    let title = format!("Diffusion: {path} to {slug} {ub} ({count})");
367
    let when =
368
        if Follow::parse(&consumer.diffusion) == Some(Follow::Any) { "as it is now" } else { "at its newest state whose checks passed" };
369
    let body = format!(
370
        "Moves the subrepo `{path}` to [{slug}](/{slug})'s `{ub}`, {when} (`{}`). Only the pin changes; this repository's own checks test it.\n\n**What it brings** ({count}):\n\n{}\n\nOpened and kept up to date by Diffusion (docs/diffusion.md). It never lands by itself.",
371
        short.join("+"),
372
        lines.join("\n")
373
    );
374
    let open = pdb::list(&app.db, consumer.id, &["open", "draft"]).await.ok()?.into_iter().find(|p| p.source_bookmark == bookmark);
375
    let number = match open {
376
        Some(p) => {
377
            pdb::edit(&app.db, p.id, &title, &body, target).await.ok()?;
378
            pdb::add_event(&app.db, p.id, ACCOUNT, "updated", &short.join("+")).await.ok();
379
            p.number
380
        }
381
        None => {
382
            let p = pdb::create(
383
                &app.db,
384
                pdb::NewProposal {
385
                    repo_id: consumer.id,
386
                    title: &title,
387
                    body: &body,
388
                    author_id: Some(me.id),
389
                    author: ACCOUNT,
390
                    draft: false,
391
                    target,
392
                    source_kind: "bookmark",
393
                    source_repo_id: None,
394
                    source_bookmark: bookmark,
395
                    source_changes: "",
396
                },
397
            )
398
            .await
399
            .ok()?;
400
            pdb::add_event(&app.db, p.id, ACCOUNT, "opened", "").await.ok();
401
            db::notify::index(&app.db, "proposal", consumer.id, p.id, p.number, &p.title, &p.body).await.ok();
402
            if let Ok(label) =
403
                db::issues::upsert_label(&app.db, consumer.id, "dependencies", "#0072ff", "A subrepo moves (Diffusion)").await
404
            {
405
                db::issues::set_proposal_label(&app.db, p.id, label.id, true).await.ok();
406
            }
407
            p.number
408
        }
409
    };
410
    Some(number)
411
}
412
413
/// The text of `.oxymodules` on `bookmark` of `name`, if any.
414
async fn oxymodules(app: &Shared, name: &RepoName, bookmark: &str) -> Option<String> {
415
    let b = bookmark.to_string();
416
    app.repos
417
        .read(name, move |r, cache| {
418
            let heads = heads_of(r, &b)?;
419
            if heads.is_empty() {
420
                return Ok(None);
421
            }
422
            let loaded = cache.load(r, &heads)?;
423
            Ok(match loaded.snapshot.files.get(".oxymodules").map(|f| &f.content) {
424
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
425
                _ => None,
426
            })
427
        })
428
        .await
429
        .ok()
430
        .flatten()
431
}
432
433
/// The upstream `upstream` reached `heads` on its default bookmark: by a
434
/// push (`green` false) or a passing run of that state (`green` true).
435
/// Bump and propose in every consumer that follows it that way (§4.3).
436
pub async fn propagate(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool) {
437
    let Some(uname) = RepoName::new(&upstream.owner, &upstream.name) else { return };
438
    let Ok(id) = app.repos.read(&uname, |r, _| Ok(r.id()?)).await else { return };
439
    let rows: Vec<(i64, String, String)> = sqlx::query_as("SELECT consumer_id, bookmark, path FROM diffusion_pins WHERE upstream = ?")
440
        .bind(hex::encode(id))
441
        .fetch_all(&app.db)
442
        .await
443
        .unwrap_or_default();
444
    for (consumer_id, indexed, path) in rows {
445
        let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
446
        let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue };
447
        let learned = target(&app.db, &consumer).await;
448
        let modules = oxymodules(app, &cname, &learned).await;
449
        let r = rule(&app.config.diffusion.follow, &consumer, modules.as_deref(), &path);
450
        let target = r.target.clone().unwrap_or(learned);
451
        if indexed != target {
452
            continue;
453
        }
454
        let go = match r.follow {
455
            Follow::Off => false,
456
            Follow::Green => green,
457
            Follow::Any => !green,
458
        };
459
        if !go {
460
            continue;
461
        }
462
        match bump(app, &consumer, &target, &path, upstream, heads.clone()).await {
463
            Ok(Bumped::Moved { from, .. }) => {
464
                let n = propose(app, &consumer, &target, &path, upstream, from, heads.clone()).await;
465
                tracing::info!(
466
                    "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})",
467
                    consumer.owner,
468
                    consumer.name,
469
                    upstream.owner,
470
                    upstream.name
471
                );
472
            }
473
            Ok(_) => {}
474
            Err(e) => tracing::warn!("diffusion: bump {}/{} {path}: {e}", consumer.owner, consumer.name),
475
        }
476
    }
477
}
478
479
/// When a consumer takes an upstream's moves (§4.2).
480
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
481
#[serde(rename_all = "lowercase")]
482
pub enum Follow {
483
    Off,
484
    /// When the upstream state's checks pass.
485
    Green,
486
    /// On every move.
487
    Any,
488
}
489
490
impl Follow {
491
    pub fn parse(s: &str) -> Option<Follow> {
492
        match s.trim() {
493
            "off" => Some(Follow::Off),
494
            "green" => Some(Follow::Green),
495
            "any" => Some(Follow::Any),
496
            _ => None,
497
        }
498
    }
499
}
500
501
/// How one consumer follows one subrepo, from its `.oxymodules` entry, its
502
/// repository setting and the site's default, in that order.
503
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
504
pub struct Rule {
505
    pub follow: Follow,
506
    /// Land a green bump itself (D5); D1 only proposes.
507
    pub land_auto: bool,
508
    /// The bookmark to bump, when `.oxymodules` names one (else learned).
509
    pub target: Option<String>,
510
    /// Paths the consumer watches: upstream changes touching them are
511
    /// flagged in a bump's changelog.
512
    pub watch: Vec<String>,
513
    /// Where `follow` came from: `.oxymodules`, `setting` or `site`.
514
    pub from: &'static str,
515
}
516
517
/// The Diffusion keys of `path`'s entry in `.oxymodules` text: `follow`,
518
/// `land`, `target` and `watch` (comma-separated, or a TOML list).
519
pub fn module_keys(text: &str, path: &str) -> (Option<Follow>, Option<bool>, Option<String>, Vec<String>) {
520
    let (mut follow, mut land, mut target, mut watch) = (None, None, None, Vec::new());
521
    let mut here = false;
522
    for line in text.lines() {
523
        let line = line.trim();
524
        if line.is_empty() || line.starts_with('#') {
525
            continue;
526
        }
527
        if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
528
            here = inner.trim().trim_matches('"').trim_end_matches('/') == path.trim_end_matches('/');
529
            continue;
530
        }
531
        let (true, Some((k, v))) = (here, line.split_once('=')) else { continue };
532
        let v = v.trim();
533
        let unquoted = v.trim_matches('"').trim();
534
        match k.trim() {
535
            "follow" => follow = Follow::parse(unquoted),
536
            "land" => land = Some(unquoted == "auto"),
537
            "target" if !unquoted.is_empty() => target = Some(unquoted.to_string()),
538
            "watch" => {
539
                watch = v
540
                    .trim_start_matches('[')
541
                    .trim_end_matches(']')
542
                    .split(',')
543
                    .map(|w| w.trim().trim_matches('"').trim().to_string())
544
                    .filter(|w| !w.is_empty())
545
                    .collect()
546
            }
547
            _ => {}
548
        }
549
    }
550
    (follow, land, target, watch)
551
}
552
553
/// How `row` follows its subrepo at `path`, given its `.oxymodules` text.
554
pub fn rule(site_default: &str, row: &RepoRow, oxymodules: Option<&str>, path: &str) -> Rule {
555
    let (follow, land, target, watch) = oxymodules.map(|t| module_keys(t, path)).unwrap_or_default();
556
    let (follow, from) = match follow {
557
        Some(f) => (f, ".oxymodules"),
558
        None => match Follow::parse(&row.diffusion) {
559
            Some(f) => (f, "setting"),
560
            None => (Follow::parse(site_default).unwrap_or(Follow::Off), "site"),
561
        },
562
    };
563
    Rule { follow, land_auto: land.unwrap_or(false), target, watch, from }
564
}
565
566
#[cfg(test)]
567
mod tests {
568
    use super::*;
569
570
    const MODULES: &str = r#"
571
[rstar-api]
572
url = https://oxygenhub.io/doomsdayone/rstar-api
573
follow = green
574
land = "propose"
575
target = develop
576
watch = ["src/x16/irq*", "src/x16/frame*"]
577
578
[other]
579
url = https://example/other
580
follow = any
581
"#;
582
583
    fn row(diffusion: &str) -> RepoRow {
584
        RepoRow {
585
            id: 1,
586
            owner: "o".into(),
587
            name: "n".into(),
588
            description: String::new(),
589
            default_bookmark: "main".into(),
590
            visibility: "public".into(),
591
            created_ms: 0,
592
            updated_ms: 0,
593
            git_mirror: false,
594
            git_mirror_state: String::new(),
595
            forked_from: None,
596
            topics: String::new(),
597
            flow: String::new(),
598
            diffusion: diffusion.into(),
599
            ..Default::default()
600
        }
601
    }
602
603
    #[test]
604
    fn oxymodules_keys_are_read_per_entry() {
605
        let (f, land, target, watch) = module_keys(MODULES, "rstar-api");
606
        assert_eq!((f, land, target.as_deref()), (Some(Follow::Green), Some(false), Some("develop")));
607
        assert_eq!(watch, vec!["src/x16/irq*", "src/x16/frame*"]);
608
        assert_eq!(module_keys(MODULES, "other").0, Some(Follow::Any));
609
        assert_eq!(module_keys(MODULES, "missing"), (None, None, None, vec![]));
610
        assert_eq!(module_keys("[a]\nwatch = x, y\n", "a").3, vec!["x", "y"]);
611
    }
612
613
    #[test]
614
    fn oxymodules_wins_then_the_setting_then_the_site() {
615
        let r = rule("off", &row("any"), Some(MODULES), "rstar-api");
616
        assert_eq!((r.follow, r.from), (Follow::Green, ".oxymodules"));
617
        let r = rule("off", &row("any"), Some("[rstar-api]\nurl = x\n"), "rstar-api");
618
        assert_eq!((r.follow, r.from), (Follow::Any, "setting"));
619
        let r = rule("green", &row(""), None, "rstar-api");
620
        assert_eq!((r.follow, r.from), (Follow::Green, "site"));
621
        assert_eq!(rule("nonsense", &row(""), None, "x").follow, Follow::Off);
622
    }
623
}