Skip to main content

onevcs/
ops.rs

1//! The typed operations the command line renders.
2//!
3//! Every command of this crate answers a *value* first and a rendering second.
4//! `app.rs` calls one of these and turns what it hands back into stdout, stderr
5//! and an exit code; it decides nothing else. That is what keeps the two surfaces
6//! one decision — a consumer embedding the crate and a user reading the command
7//! are told the same thing by the same code, rather than by two readers of one
8//! store that drift a rewording at a time.
9//!
10//! What lives here is the operations that had no home of their own: the ones whose
11//! body used to be inside a command handler, or which composed two private modules
12//! and a rendering. An operation that already belongs to a module — a publication,
13//! a session, a pool, a release — stays there and is re-exported from `lib.rs`
14//! beside these.
15
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use serde::Serialize;
20use url::Url;
21
22use crate::error::{Error, Result};
23use crate::import::Imported;
24use crate::integrate::Outcome as Integration;
25use crate::providers::Providers;
26use crate::publish::{PublishOutcome, Subject};
27use crate::registry::Registry;
28use crate::rules::{Approvals, MergePolicy, RuleMatch};
29pub use crate::store::Coverage as MergePathCoverage;
30use crate::store::{self, Resolution};
31use crate::stream::Stream;
32use crate::sweep::Report as SweepReport;
33use crate::workspace::Ref;
34use crate::{git, policy, provenance, status, stream};
35
36/// The publication policy a repository resolves to, and where each half of it came
37/// from.
38///
39/// The two fields travel with their provenance because every rendering of a policy
40/// in this crate states it: a policy read without knowing whether a rule or the
41/// built-in default decided it is one nobody can act on — the edit that changes it
42/// is in a different file in each case.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ResolvedPolicy {
45    /// How this repository publishes.
46    pub publication: MergePolicy,
47    /// What decided `publication`: a numbered rule of the rules file, or the default.
48    pub publication_from: String,
49    /// Whether a change of this repository needs an approval.
50    pub approvals: Approvals,
51    /// What decided `approvals`, in the same words `publication_from` uses.
52    pub approvals_from: String,
53    /// Whether the draft lifecycle is off, so a change request opens ready.
54    pub drafts_disabled: bool,
55    /// What decided `drafts_disabled`: a numbered rule, the default, or the shipped
56    /// default.
57    pub drafts_disabled_from: String,
58    /// Whether an early lift of a draft prints its warning line.
59    pub warn_on_early_lift: bool,
60    /// What decided `warn_on_early_lift`, in the same words.
61    pub warn_on_early_lift_from: String,
62}
63
64impl ResolvedPolicy {
65    fn of(resolved: &policy::Resolved) -> Self {
66        Self {
67            publication: resolved.policy.publication,
68            publication_from: resolved.publication_from.clone(),
69            approvals: resolved.policy.approvals,
70            approvals_from: resolved.approvals_from.clone(),
71            drafts_disabled: resolved.drafts.disabled,
72            drafts_disabled_from: resolved.drafts.disabled_from.clone(),
73            warn_on_early_lift: resolved.drafts.warn_on_early_lift,
74            warn_on_early_lift_from: resolved.drafts.warn_on_early_lift_from.clone(),
75        }
76    }
77}
78
79/// What `onevcs register` recorded, and what the identity it recorded publishes
80/// under.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Registration {
83    /// The identity key the checkout's origin normalized to.
84    pub identity: String,
85    /// The alias the checkout is addressed by.
86    pub alias: String,
87    /// The publication checkout the identity resolves to.
88    pub checkout: PathBuf,
89    /// The command this repository states as its complete bar.
90    pub gate: String,
91    /// The policy the identity publishes under, as its rules resolve it.
92    pub policy: ResolvedPolicy,
93    /// What runs a gate on the identity's merge path — and
94    /// [`MergePathCoverage::None`] for one where nothing does, which is the state a
95    /// caller warns about.
96    pub coverage: MergePathCoverage,
97}
98
99/// Register a checkout, resolving its origin to a repository identity.
100///
101/// The library form of `onevcs register`. `origin` is the URL to resolve the
102/// identity from where the checkout's own remote is not the one to use.
103pub fn register_checkout(path: &Path, origin: Option<&Url>) -> Result<Registration> {
104    let origin = origin.map(Url::to_string);
105    let resolution = store::register(path, origin.as_deref())?;
106    // What was registered is the identity and its checkout; how it publishes is the
107    // rules file's answer, resolved here so the coverage below is about the merge
108    // path the identity will actually take.
109    let registry = store::load()?;
110    let (file, source) = policy::load(&registry)?;
111    let resolved = policy::resolve_for(&file, &source, &resolution);
112    let coverage = store::merge_path_coverage(
113        &resolution,
114        &resolution.publication,
115        resolved.policy.publication,
116    );
117    Ok(Registration {
118        identity: resolution.key,
119        alias: resolution.alias,
120        checkout: resolution.publication,
121        gate: resolution.identity.gate,
122        policy: ResolvedPolicy::of(&resolved),
123        coverage,
124    })
125}
126
127/// One registered repository identity, as `onevcs repos` lists it.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct RegisteredRepository {
130    /// The identity key: the normalized origin every checkout of it shares.
131    pub identity: String,
132    /// The command this repository states as its complete bar.
133    pub gate: String,
134    /// Its registered checkouts, in alias order.
135    pub checkouts: Vec<RegisteredCheckout>,
136    /// What the identity's host requires on its base. `None` is a question that was
137    /// not asked — the audit was not requested, or the identity has no registered
138    /// checkout to ask through — because a listing that did not ask must not read as
139    /// one that asked and was told nothing.
140    pub required_checks: Option<RequiredChecksAnswer>,
141}
142
143/// One registered checkout of an identity.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct RegisteredCheckout {
146    /// The alias it is addressed by.
147    pub alias: String,
148    /// Where it is.
149    pub path: PathBuf,
150    /// The policy it publishes under and what covers its merge path, asked only
151    /// when the audit was.
152    pub audit: Option<CheckoutAudit>,
153}
154
155/// What the gate audit found about one checkout.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct CheckoutAudit {
158    /// The policy this checkout publishes under.
159    pub policy: ResolvedPolicy,
160    /// What runs a gate on its merge path, decided from that policy.
161    pub coverage: MergePathCoverage,
162}
163
164/// What an identity's host says it requires before a merge.
165///
166/// Three answers, and none collapses into another for the reason a release probe's
167/// do not: a consumer that reads "nothing required" stops waiting on a check, and
168/// one that reads "unreadable" knows it has not been told. Whether an *answered*
169/// list is complete is [`crate::RequiredChecks`]'s own question — a host protects a
170/// branch from more than one source, and a credential may be refused one of them.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum RequiredChecksAnswer {
173    /// The identity is not hosted anywhere this build answers for, so no host
174    /// answers for it at all.
175    NotHosted,
176    /// The host was asked and refused, or the base could not be read.
177    Unreadable {
178        /// What refused, in its own words.
179        reason: String,
180    },
181    /// The host answered about this base.
182    Answered {
183        /// The base the checks were read for.
184        base: String,
185        /// What it said, and which of its sources went unconsulted.
186        checks: crate::host::RequiredChecks,
187    },
188}
189
190/// Whether a listing carries the gate audit, which is what `--audit-gates` asks for.
191///
192/// Named rather than a flag, because the two answers are different documents: one is
193/// the registry as it stands, and the other reaches a host and reads this host's
194/// rules — a distinction a caller should have to spell at the call site.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum GateAudit {
197    /// The registry as it stands: no rules read, no host asked.
198    Skipped,
199    /// Each identity's required checks, and each checkout's policy and what covers
200    /// its merge path.
201    Asked,
202}
203
204/// Every registered repository, in the order `onevcs repos` lists them.
205///
206/// With [`GateAudit::Asked`], each identity carries what its host requires and each
207/// checkout the policy it publishes under and what covers its merge path. With
208/// [`GateAudit::Skipped`] nothing reads the rules file and no host is asked — the
209/// answer is the registry as it stands.
210pub fn repositories(
211    providers: &Providers<'_>,
212    audit: GateAudit,
213) -> Result<Vec<RegisteredRepository>> {
214    let registry = store::load()?;
215    let mut listed = listing(&registry);
216    if audit == GateAudit::Skipped {
217        return Ok(listed);
218    }
219    // The audit is about each identity's merge path, and the merge path is the
220    // resolved policy's: which verifier covers it follows from how it publishes, so
221    // the rules are read once here and resolved per checkout below.
222    let (file, source) = policy::load(&registry)?;
223    // Zipped rather than looked up again: the listing is this document's identities
224    // in this document's order, so the pairing is exact and there is no second read
225    // of the registry to answer from.
226    for (repository, (_, identity)) in listed.iter_mut().zip(registry.identities.iter()) {
227        // The base is a fact about the origin, so the first registered checkout of it
228        // answers; an identity with none is left unasked rather than answered.
229        repository.required_checks = repository
230            .checkouts
231            .first()
232            .map(|first| required_checks(&repository.identity, &first.path, providers));
233        for checkout in &mut repository.checkouts {
234            let resolution = Resolution {
235                key: repository.identity.clone(),
236                identity: identity.clone(),
237                alias: checkout.alias.clone(),
238                publication: checkout.path.clone(),
239            };
240            let resolved = policy::resolve_for(&file, &source, &resolution);
241            let coverage = store::merge_path_coverage(
242                &resolution,
243                &checkout.path,
244                resolved.policy.publication,
245            );
246            checkout.audit = Some(CheckoutAudit {
247                policy: ResolvedPolicy::of(&resolved),
248                coverage,
249            });
250        }
251    }
252    Ok(listed)
253}
254
255/// Every repository identity this host has registered, in the order `onevcs repos`
256/// lists them.
257///
258/// The library form of the unindented lines `onevcs repos` prints: one normalized
259/// origin per registered identity — `host/owner/name` for a hosted one, the origin
260/// path for a local one — which is the key every other repository-taking operation
261/// here accepts.
262///
263/// It exists because the enumeration had no library form at all. The migration-aware
264/// loader is private, so a consumer that wanted the registered identities — to
265/// maintain each of them, to sweep across them, to ask what each one releases — had
266/// to spawn the binary and parse the prose `repos` prints, which couples it to a
267/// display line nobody promised to keep.
268///
269/// The order is the registry's own, which is the order the command prints, so the
270/// two readings of one document cannot come apart. A registry an older build wrote
271/// is migrated as this reads it, exactly as the command migrates it, and a document
272/// this build cannot read is an `Err` naming what could not be read rather than an
273/// empty list — "this host has registered nothing" and "this host's registry could
274/// not be read" are opposite facts, and a caller acts on only one of them.
275///
276/// It takes no [`Providers`] for the reason [`crate::session_holders`] does not: the
277/// registry is this host's own document, and there is nothing here for an
278/// implementation of either interface to answer.
279pub fn registered_identities() -> Result<Vec<String>> {
280    Ok(listing(&store::load()?)
281        .into_iter()
282        .map(|repository| repository.identity)
283        .collect())
284}
285
286/// The registry as a listing: the one enumeration both reads above answer from, so
287/// the identities a caller asks for and the rows the command prints cannot come to
288/// be two different sets in two different orders.
289fn listing(registry: &Registry) -> Vec<RegisteredRepository> {
290    registry
291        .identities
292        .iter()
293        .map(|(key, identity)| RegisteredRepository {
294            identity: key.clone(),
295            gate: identity.gate.clone(),
296            checkouts: registry
297                .checkouts
298                .iter()
299                .filter(|(_, checkout)| checkout.identity == *key)
300                .map(|(alias, checkout)| RegisteredCheckout {
301                    alias: alias.clone(),
302                    path: checkout.path.clone(),
303                    audit: None,
304                })
305                .collect(),
306            required_checks: None,
307        })
308        .collect()
309}
310
311/// What one identity's host requires on the base its first checkout tracks.
312fn required_checks(key: &str, checkout: &Path, providers: &Providers<'_>) -> RequiredChecksAnswer {
313    let Some(slug) = crate::gh::slug(key) else {
314        return RequiredChecksAnswer::NotHosted;
315    };
316    let asked = git::default_branch(checkout, "origin").and_then(|base| {
317        providers
318            .hosting
319            .for_repo(&slug)?
320            .required_checks_on(&base)
321            .map(|checks| (base, checks))
322    });
323    match asked {
324        Ok((base, checks)) => RequiredChecksAnswer::Answered { base, checks },
325        Err(error) => RequiredChecksAnswer::Unreadable {
326            reason: error.to_string(),
327        },
328    }
329}
330
331/// What one repository argument resolves to.
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct ResolvedRepository {
334    /// The identity key.
335    pub identity: String,
336    /// The alias of the checkout the argument selected.
337    pub alias: String,
338    /// The origin the identity was derived from.
339    pub origin: String,
340    /// The command this repository states as its complete bar.
341    pub gate: String,
342    /// The publication checkout: never worked in, only ever fast-forwarded.
343    pub publication_checkout: PathBuf,
344    /// The policy it publishes under, which is what every verb that routes on
345    /// "local or through the host" reads.
346    pub policy: ResolvedPolicy,
347}
348
349/// Resolve a repository argument — an identity key, a registered alias, an origin
350/// URL, or a path — to the identity it selects.
351///
352/// The library form of `onevcs resolve`. It asks the repository side through the
353/// seam, so a supplied [`crate::Vcs`] answers it as readily as this build's git.
354pub fn resolve_repository(providers: &Providers<'_>, repo: &str) -> Result<ResolvedRepository> {
355    let registry = store::load()?;
356    let resolution = store::resolve(&registry, repo)?;
357    // Through the trait, which is the seam a second implementation replaces.
358    let identity = providers.vcs.resolve_identity(repo)?;
359    debug_assert_eq!(identity, resolution.identity);
360    let (file, source) = policy::load(&registry)?;
361    let resolved = policy::resolve_for(&file, &source, &resolution);
362    Ok(ResolvedRepository {
363        identity: resolution.key,
364        alias: resolution.alias,
365        origin: resolution.identity.origin,
366        gate: resolution.identity.gate,
367        publication_checkout: resolution.publication,
368        policy: ResolvedPolicy::of(&resolved),
369    })
370}
371
372/// What to publish, for the branch-keyed verb that takes completed work.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct BranchPublishRequest {
375    /// The repository the branch belongs to: an identity key, a registered alias,
376    /// an origin URL, or a path, exactly as `--repo` takes one.
377    pub repo: PathBuf,
378    /// The branch to publish.
379    pub branch: String,
380    /// The subject to land it under, or none to compose one from its commits.
381    pub title: Option<Subject>,
382    /// The body of the change request, where one is opened.
383    pub body: Option<String>,
384    /// A policy to narrow to; it may only ask for more review than the rules do.
385    pub policy: Option<MergePolicy>,
386}
387
388/// Verify and publish a completed branch no session holds.
389///
390/// The library form of `onevcs publish-branch`. It refuses a branch carrying an
391/// unattested incomplete-step marker, naming [`recover`] — the two verbs are one
392/// path, and provenance is the whole of what separates them.
393pub fn publish_branch(
394    providers: &Providers<'_>,
395    request: &BranchPublishRequest,
396) -> Result<PublishOutcome> {
397    let registry = store::load()?;
398    let token = format!("publish-branch-{}", policy::branch_slug(&request.branch));
399    let mut stream = Stream::open(&token)?;
400    crate::publish_branch::run(
401        &registry,
402        &request.repo,
403        &request.branch,
404        request.title.clone(),
405        request.body.clone(),
406        request.policy,
407        providers.hosting,
408        &mut stream,
409    )
410}
411
412/// What to recover, for the branch-keyed verb that takes work a step left behind.
413#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct RecoverRequest {
415    /// The repository the branch belongs to, as `--repo` takes one.
416    pub repo: PathBuf,
417    /// The branch to recover.
418    pub branch: String,
419    /// The subject to land it under, or none to compose one from its commits.
420    pub title: Option<Subject>,
421    /// The body of the change request, where one is opened.
422    pub body: Option<String>,
423}
424
425/// Verify and publish a preserved branch that a step left behind, attesting it.
426///
427/// The library form of `onevcs recover`. It requires an unattested incomplete-step
428/// marker and writes the attestation that clears it, so a branch with none is
429/// refused naming [`publish_branch`].
430pub fn recover(providers: &Providers<'_>, request: &RecoverRequest) -> Result<PublishOutcome> {
431    let registry = store::load()?;
432    let token = format!("recover-{}", policy::branch_slug(&request.branch));
433    let mut stream = Stream::open(&token)?;
434    crate::recover::run(
435        &registry,
436        &request.repo,
437        &request.branch,
438        request.title.clone(),
439        request.body.clone(),
440        providers.hosting,
441        &mut stream,
442    )
443}
444
445/// Which branch to make reachable from an identity's registered checkouts.
446#[derive(Debug, Clone, PartialEq, Eq)]
447pub struct ImportRequest {
448    /// The repository to import into, as `--repo` takes one.
449    pub repo: PathBuf,
450    /// The branch to import.
451    pub branch: String,
452    /// Where to take it from, or none to search the identity's own locations.
453    pub from: Option<String>,
454    /// The name to write it under, or none to keep its own.
455    pub under: Option<String>,
456}
457
458/// Make one branch reachable from an identity's registered checkouts.
459///
460/// The library form of `onevcs import`. It writes refs and nothing else: a name the
461/// destination has checked out is refused rather than written, and a
462/// non-fast-forward is refused naming the commits that would go.
463pub fn import_branch(request: &ImportRequest) -> Result<Imported> {
464    let registry = store::load()?;
465    crate::import::run(
466        &registry,
467        &request.repo,
468        &request.branch,
469        request.from.as_deref(),
470        request.under.as_deref(),
471    )
472}
473
474/// What the train does with the base once it has advanced it.
475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
476pub enum BasePush {
477    /// Push it, which is what the repository's own `pre-push` hook rules on —
478    /// `onevcs integrate --push`.
479    Push,
480    /// Leave it in the checkout, unpushed and unverified.
481    Keep,
482}
483
484/// Which branches to merge into their base, in order.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct IntegrateRequest {
487    /// The branches to take, in the order they are to be taken.
488    pub branches: Vec<String>,
489    /// What becomes of the base the train advanced.
490    pub push: BasePush,
491}
492
493/// Merge finished branches into their base, in order.
494///
495/// The library form of `onevcs integrate`, and it answers for **the repository the
496/// current directory is in**, exactly as the command does: the train reads its
497/// candidates out of one publication checkout, and it is refused for an identity
498/// whose rules do not publish `local-direct`.
499pub fn integrate(request: &IntegrateRequest) -> Result<Integration> {
500    let registry = store::load()?;
501    let resolution = store::resolve_here(&registry)?;
502    let token = format!("integrate-{}", policy::branch_slug(&resolution.alias));
503    let mut stream = Stream::open(&token)?;
504    crate::integrate::run(
505        &resolution,
506        &request.branches,
507        request.push == BasePush::Push,
508        &mut stream,
509    )
510}
511
512/// What one fast-forward of a publication checkout did.
513#[derive(Debug, Clone, PartialEq, Eq)]
514pub struct Synced {
515    /// The identity whose checkout was synced.
516    pub identity: String,
517    /// The branch it is on, which is the only branch a sync ever moves.
518    pub branch: String,
519    /// The checkout itself.
520    pub checkout: PathBuf,
521    /// The commit it was on before.
522    pub before: String,
523    /// The commit it is on now.
524    pub after: String,
525}
526
527impl Synced {
528    /// Whether the fast-forward moved the checkout, or found it already level.
529    pub fn moved(&self) -> bool {
530        self.before != self.after
531    }
532}
533
534/// Fast-forward a publication checkout to its origin.
535///
536/// The library form of `onevcs sync`, and like the train it answers for the
537/// repository the current directory is in. `branch` is the branch to sync, and
538/// `None` is the origin's default one; a checkout that does not have that branch
539/// checked out is refused, because a sync only ever fast-forwards the branch a
540/// checkout is already on.
541pub fn sync(branch: Option<&str>) -> Result<Synced> {
542    let registry = store::load()?;
543    let resolution = store::resolve_here(&registry)?;
544    let checkout = &resolution.publication;
545    // The name goes on to spell a ref, so an unusable one is refused here rather
546    // than by whichever git command met it first.
547    let branch = match branch {
548        Some(branch) => Ref::try_from(branch.to_owned()).map_err(|reason| Error::Invalid {
549            reason: format!("{reason}: it is not a valid branch name"),
550        })?,
551        None => Ref::from_git(git::default_branch(checkout, "origin")?),
552    };
553    if git::current_branch(checkout)? != *branch {
554        return Err(Error::Invalid {
555            reason: format!(
556                "{} does not have {branch:?} checked out; sync only ever fast-forwards the \
557                 branch a checkout is already on",
558                checkout.display()
559            ),
560        });
561    }
562    let before = git::head_sha(checkout)?;
563    git::fetch(checkout, "origin")?;
564    git::merge_ff_only(checkout, &format!("origin/{branch}"))?;
565    let after = git::head_sha(checkout)?;
566    Ok(Synced {
567        identity: resolution.key,
568        branch: branch.to_string(),
569        checkout: checkout.clone(),
570        before,
571        after,
572    })
573}
574
575/// Whether a sweep performs what it decided, which is what `--dry-run` asks.
576///
577/// Named rather than a flag, because the difference between the two is whether
578/// directories are removed, and a call site that spells it `true` says nothing about
579/// which way round that is.
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
581pub enum Sweeping {
582    /// Decide everything, remove nothing.
583    Rehearse,
584    /// Decide, and reclaim what was decided.
585    Reclaim,
586}
587
588/// Reclaim the publication workspaces this host has finished with.
589///
590/// The library form of `onevcs sweep`. `min_age` is how long evidence outlives the
591/// failure that produced it. Every outcome it reports is a decision — a directory
592/// somebody else owns is an expected outcome of a shared state root rather than a
593/// failure — so an `Err` here means the sweep could not run at all.
594pub fn sweep(sweeping: Sweeping, min_age: Duration) -> Result<SweepReport> {
595    crate::sweep::run(sweeping == Sweeping::Rehearse, min_age)
596}
597
598/// Read one stored artifact.
599///
600/// The library form of `onevcs artifact cat`. The id is checked before it names a
601/// file, so an id that is not one is refused rather than reaching the state root.
602pub fn read_artifact(id: &crate::event::ArtifactId) -> Result<String> {
603    stream::read_artifact(&id.0)
604}
605
606/// Everything this host knows about one piece of work, as `onevcs status` answers it.
607///
608/// Its two renderings are its surface: [`render`](Self::render) is the report a
609/// person reads, and its serialization is the versioned document `--json` prints,
610/// byte for byte. The sections behind them are deliberately **not** public — they
611/// are a document versioned by `status`'s own report version and held to checked-in
612/// goldens, so a consumer reads the JSON it already reads rather than a dozen Rust
613/// types this crate would then owe semver on.
614#[derive(Debug, Clone, Serialize)]
615#[serde(transparent)]
616pub struct StatusReport(status::Report);
617
618impl StatusReport {
619    /// The report as a human reads it, which is what `onevcs status` prints.
620    pub fn render(&self) -> String {
621        self.0.render()
622    }
623}
624
625/// Report everything this host knows about one piece of work.
626///
627/// The library form of `onevcs status`. `reference` is the four-spelling reference
628/// `onevcs status` takes: a change request's URL, a session token, a branch name, or
629/// a commit.
630///
631/// The host is reached through the seam like every other operation that touches one,
632/// and a host that could not be reached leaves a section of the report unavailable
633/// rather than failing — which is the whole reason this answers at all where `gh pr
634/// checks` would not. Whether one piece of work *landed* is
635/// [`crate::landing_status`]: that is the decision on its own, over every repository
636/// and with no host asked.
637pub fn work_status(providers: &Providers<'_>, reference: &str) -> Result<StatusReport> {
638    let registry = store::load()?;
639    Ok(StatusReport(status::run(
640        &registry,
641        reference,
642        providers.hosting,
643    )?))
644}
645
646/// The rule a repository matched, and what it matched on.
647#[derive(Debug, Clone, PartialEq, Eq)]
648pub struct MatchedRule {
649    /// Its one-based position in the rules file, which is the order that decided it.
650    pub index: usize,
651    /// What it matches on.
652    pub criteria: RuleMatch,
653}
654
655/// How one repository resolves against this host's rules.
656#[derive(Debug, Clone, PartialEq, Eq)]
657pub struct RulesCheck {
658    /// The identity key the argument selected.
659    pub identity: String,
660    /// The publication checkout a `path:` rule is matched against.
661    pub checkout: PathBuf,
662    /// Where the rules came from: the file that was read, or the built-in default.
663    pub rules: String,
664    /// The rule that decided it, where one did; none means the default applies.
665    pub matched: Option<MatchedRule>,
666    /// What it resolved to.
667    pub policy: ResolvedPolicy,
668    /// The git trailer key provenance is written and read under.
669    pub trailer_prefix: String,
670    /// What set that prefix. It is not part of the matched policy: one vocabulary
671    /// reads and writes every repository's provenance, so it is resolved once, from
672    /// the file or the default.
673    pub trailer_prefix_source: TrailerPrefixSource,
674}
675
676/// What decided the provenance trailer prefix.
677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
678pub enum TrailerPrefixSource {
679    /// This host's rules file names it.
680    RulesFile,
681    /// Nothing names one, so the built-in default applies.
682    BuiltIn,
683}
684
685/// Explain how one repository resolves against this host's rules.
686///
687/// The library form of `onevcs rules check`, and the question asked *before* a
688/// publication rather than deduced from one afterwards.
689pub fn rules_check(repo: &str) -> Result<RulesCheck> {
690    let registry = store::load()?;
691    let resolution = store::resolve(&registry, repo)?;
692    let (file, source) = policy::load(&registry)?;
693    let normalized = store::normalize(&resolution.identity.origin);
694    let resolved = policy::resolve(&file, &source, &normalized, &resolution.publication);
695    Ok(RulesCheck {
696        identity: resolution.key,
697        checkout: resolution.publication,
698        rules: resolved.source.clone(),
699        matched: resolved.matched.as_ref().map(|matched| MatchedRule {
700            index: matched.index,
701            criteria: matched.criteria.clone(),
702        }),
703        policy: ResolvedPolicy::of(&resolved),
704        trailer_prefix: provenance::from_rules(&file).prefix().to_string(),
705        trailer_prefix_source: match file.trailer_prefix.is_some() {
706            true => TrailerPrefixSource::RulesFile,
707            false => TrailerPrefixSource::BuiltIn,
708        },
709    })
710}