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