Skip to main content

onevcs_testing/
repository.rs

1//! The repository side: one implementation of [`Vcs`] over either store.
2//!
3//! What it is not: git. It answers the questions the interface asks, records what
4//! it was asked, and emits the events the real implementation emits. What it
5//! cannot do is tell you whether a tree is dirty or whether a merge conflicts,
6//! because there is no tree — a journey that needs those drives the real `Git`.
7//!
8//! Publishing is the one operation that reaches past the repository: the host side
9//! of it is *performed*, against the [`Hosting`] the publication was handed, and
10//! the repository side of it is neither performed nor claimed. What that leaves
11//! out is written where each piece is left out.
12
13use std::path::{Path, PathBuf};
14
15use serde_json::{json, Map, Value};
16
17use onevcs::{
18    ChangeSpec, DraftReason, Error, EventKind, FailureKind, HeldBy, Holding, Hosting, Identity,
19    Landed, Lifecycle, MergeOutcome, MergePolicy, PreservedBranch, Provenance, Publication,
20    PublishOutcome, PublishRequest, Recoverable, Result, Scope, Session, SessionRecord,
21    SessionRequest, SessionToken, Sha, Vcs,
22};
23
24use crate::events::{self, Emission};
25use crate::remote::DEFAULT_HOST;
26use crate::state::{self, VcsState};
27use crate::store::{FileStore, MemoryStore, Store};
28
29/// The base a session is cut from when the request names none.
30///
31/// The real implementation asks the origin for its default branch, and this
32/// provider has no origin to ask.
33pub const DEFAULT_BASE: &str = "main";
34
35/// The policy a publication takes when nothing was seeded and nothing requested.
36///
37/// The policy the contract's own `default:` names, which is what the real
38/// implementation resolves to for a registry with no rules file.
39pub const DEFAULT_PUBLICATION: MergePolicy = MergePolicy::ChangeOpen;
40
41/// The repository side of a run, over whichever store holds its state.
42///
43/// The two flavours below are this one behaviour with a different store under it,
44/// so neither can learn something the other does not know.
45#[derive(Debug)]
46pub struct Repository<T> {
47    store: T,
48    root: PathBuf,
49    trees: Trees,
50}
51
52/// What a session's worktree path means.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum Trees {
55    /// The path is named and nothing is created there: the in-memory provider
56    /// touches no filesystem beyond the event stream.
57    Named,
58    /// The directory is created, so a journey has somewhere to write work.
59    Created,
60}
61
62/// A repository provider that keeps its state in this process: no disk, no
63/// visibility to a second process, and the fastest of the two.
64///
65/// Its sessions name a worktree under the system temporary directory and **do not
66/// create it** — nothing here touches the filesystem except the event stream, which
67/// is the record a journey reads.
68pub type MemoryVcs = Repository<MemoryStore<VcsState>>;
69
70/// A repository provider that keeps its state in one JSON document, so several
71/// `onevcs` invocations see one another's effects.
72///
73/// Its sessions name a worktree beside that document **and create it**, so a
74/// journey that writes a file into a session's tree has somewhere to write it.
75pub type FileVcs = Repository<FileStore<VcsState>>;
76
77impl MemoryVcs {
78    /// A repository provider knowing nothing.
79    pub fn new() -> Self {
80        Self::seeded(VcsState::default())
81    }
82
83    /// A repository provider that starts from a scenario.
84    pub fn seeded(state: VcsState) -> Self {
85        Self {
86            store: MemoryStore::new(state),
87            root: std::env::temp_dir().join("onevcs-testing-memory"),
88            trees: Trees::Named,
89        }
90    }
91
92    /// Everything it knows.
93    pub fn state(&self) -> VcsState {
94        self.store
95            .snapshot()
96            .expect("an in-memory store always answers")
97    }
98}
99
100impl Default for MemoryVcs {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl FileVcs {
107    /// A repository provider keeping its state at `path`: whatever is already
108    /// there, or nothing.
109    ///
110    /// Attaching rather than replacing, so a second provider over the same path
111    /// picks up what the first one left — which is what a journey driving several
112    /// invocations reaches for this flavour to get.
113    pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
114        Self::over(FileStore::attach(path, &VcsState::default())?)
115    }
116
117    /// A repository provider that starts from a scenario, keeping its state at
118    /// `path` and replacing whatever was there.
119    pub fn seeded(path: impl Into<PathBuf>, state: VcsState) -> Result<Self> {
120        Self::over(FileStore::replace(path, &state)?)
121    }
122
123    fn over(store: FileStore<VcsState>) -> Result<Self> {
124        let root = store
125            .path()
126            .parent()
127            .filter(|parent| !parent.as_os_str().is_empty())
128            .unwrap_or_else(|| Path::new("."))
129            .join("worktrees");
130        Ok(Self {
131            store,
132            root,
133            trees: Trees::Created,
134        })
135    }
136
137    /// Everything it knows, read back out of its document.
138    pub fn state(&self) -> Result<VcsState> {
139        self.store.snapshot()
140    }
141}
142
143impl<T: Store<VcsState>> Vcs for Repository<T> {
144    fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity> {
145        self.store.with(|state| {
146            state::identity_of(state, origin_or_path)
147                .cloned()
148                .ok_or_else(|| Error::Invalid {
149                    reason: format!(
150                        "{origin_or_path:?} does not name a repository this provider knows; {}",
151                        state::known(state)
152                    ),
153                })
154        })
155    }
156
157    fn open_session(&self, req: SessionRequest) -> Result<Session> {
158        let root = self.root.clone();
159        let (session, emission) = self.store.with(|state| {
160            let identity = state::identity_of(state, &req.repo)
161                .cloned()
162                .ok_or_else(|| Error::Invalid {
163                    reason: format!(
164                        "{:?} does not name a repository this provider knows; {}",
165                        req.repo,
166                        state::known(state)
167                    ),
168                })?;
169            // Consecutive and predictable, so a journey can name the session it is
170            // about to open — the one thing a digest-shaped token takes away.
171            let token = SessionToken(format!("s-testing-{}", state.sessions.len() + 1));
172            let run_root = root.join(&token.0);
173            // Both names are checked before they are recorded, because both go on to
174            // spell a ref for whoever holds the session — and a provider that
175            // accepted a name git refuses would let a journey pass where the real
176            // run stops.
177            let base = req.base.clone().unwrap_or_else(|| DEFAULT_BASE.to_owned());
178            state::named_branch(&base, "the base")?;
179            for (key, value) in &req.labels {
180                state::label_pair(key, value)?;
181            }
182            let session = Session {
183                worktree: run_root.join("worktree"),
184                branch: state::requested_branch(&req, &token)?,
185                base,
186                token: token.clone(),
187            };
188            state.sessions.push(session.clone());
189            state
190                .session_identities
191                .insert(token.clone(), identity.origin.clone());
192            if !req.labels.is_empty() {
193                state
194                    .session_labels
195                    .insert(token.clone(), req.labels.clone());
196            }
197            let emission = Emission {
198                stream: token.0.clone(),
199                identity: Some(identity.origin.clone()),
200                kind: EventKind::SessionOpened,
201                payload: object(json!({
202                    "token": token.0,
203                    "identity": identity.origin,
204                    "branch": session.branch,
205                    "base": session.base,
206                    "worktree": session.worktree.display().to_string(),
207                    // Synthetic, and named anyway: a consumer reading this event
208                    // reads the same keys whichever implementation produced it.
209                    "clone": run_root.join("clone").display().to_string(),
210                    "execution_checkout": run_root.join("checkout").display().to_string(),
211                    "publication_checkout": run_root.join("checkout").display().to_string(),
212                    // A run root of its own, which is the one placement a provider
213                    // that keeps no pool can make — and the one every real session
214                    // gets on a host that configures none.
215                    "placement": {"kind": "run-root"},
216                })),
217            };
218            Ok((session, emission))
219        })?;
220        if self.trees == Trees::Created {
221            std::fs::create_dir_all(&session.worktree).map_err(|e| Error::Invalid {
222                reason: format!("cannot create {}: {e}", session.worktree.display()),
223            })?;
224        }
225        events::emit(&emission);
226        Ok(session)
227    }
228
229    fn adopt_session(&self, token: SessionToken) -> Result<Session> {
230        self.store.with(|state| {
231            state::session_of(state, &token)
232                .cloned()
233                .ok_or_else(|| Error::Invalid {
234                    reason: format!(
235                        "no session {:?} is open; `onevcs session open` prints a token",
236                        token.0
237                    ),
238                })
239        })
240    }
241
242    fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch> {
243        let (branch, emission) = self.store.with(|state| {
244            let identity = state::identity_for(state, &s.token)?;
245            let branch = PreservedBranch {
246                branch: s.branch.clone(),
247                base: s.base.clone(),
248                provenance,
249                change_url: None,
250                change_base: None,
251            };
252            let row = Recoverable {
253                identity: identity.clone(),
254                branch: branch.clone(),
255                checkout: s.worktree.clone(),
256                stopped_because: format!("session {} was left open", s.token.0),
257                recover_command: recover_command(&s.branch, &s.worktree, provenance),
258                // Preserved work this provider was handed is work nobody published:
259                // there is no base here whose history could record a landing, and no
260                // tree to compare content against, so the one answer it can give is
261                // the one it knows.
262                landed: Landed::No,
263                // Both answered where the row is *read* rather than frozen in here:
264                // whether a session still holds its branch is a fact about the session
265                // now, and there is no tree here to count a diff's lines against.
266                held_by: None,
267                net_negative: None,
268                // The session that preserved it is the session that answers for it,
269                // and what it was opened with is what the row carries.
270                session: Some(s.token.clone()),
271                labels: state
272                    .session_labels
273                    .get(&s.token)
274                    .cloned()
275                    .unwrap_or_default(),
276                // A preservation onto an origin is a thing only a real repository can
277                // do — there is no git here and no remote to push to — so this provider
278                // answers what a scenario wrote down and nothing else. A hand-written
279                // state may still seed one, exactly as it seeds a hold.
280                on_origin: None,
281                // Whether a branch may be retired is decided from every copy of it this
282                // host holds, its base on the origin, and this host's records — none of
283                // which a provider has — so this provider classifies nothing, and a row
284                // it answers carries no classification rather than an invented one.
285                retirement: None,
286            };
287            // Preserving the same branch twice replaces its row rather than listing
288            // it twice, which is what `recoverable` does across the checkouts a
289            // branch is reachable from.
290            state.preserved.retain(|kept| {
291                kept.identity != row.identity || kept.branch.branch != row.branch.branch
292            });
293            state.preserved.push(row);
294            let emission = Emission {
295                stream: s.token.0.clone(),
296                // No identity label, because the real implementation carries none
297                // here: the label is stamped where a session is opened, and work is
298                // preserved against a stream a later process opened fresh. Claiming
299                // it would be drift in the direction that looks like more
300                // information.
301                identity: None,
302                kind: EventKind::CommitPreserved,
303                payload: object(json!({
304                    "branch": s.branch,
305                    "sha": events::stable_sha(&[&s.token.0, &s.branch, spell(provenance)]),
306                    "provenance": spell(provenance),
307                })),
308            };
309            Ok((branch, emission))
310        })?;
311        events::emit(&emission);
312        Ok(branch)
313    }
314
315    fn session(&self, token: &SessionToken) -> Result<SessionRecord> {
316        self.store.with(|state| {
317            let session = state::session_of(state, token)
318                .cloned()
319                .ok_or_else(|| unknown_session(token))?;
320            let identity = state::identity_for(state, token)?;
321            // Read off what was preserved rather than remembered separately: the
322            // real implementation reads the branch, so a session whose work was
323            // preserved behind an incomplete-step marker answers the same here.
324            let provenance = state
325                .preserved
326                .iter()
327                .find(|row| row.identity == identity && row.branch.branch == session.branch)
328                .map_or(Provenance::Complete, |row| row.branch.provenance);
329            Ok(SessionRecord {
330                lifecycle: if state.closed_sessions.contains(token) {
331                    Lifecycle::Closed
332                } else {
333                    Lifecycle::Open
334                },
335                session,
336                identity,
337                provenance,
338                // A retry link is a fact about run clones under a state root, and
339                // this provider keeps none: its sessions are records in a store, so
340                // there is no second copy of a branch for one to tell apart.
341                retried_by: None,
342            })
343        })
344    }
345
346    fn close_session(&self, token: &SessionToken) -> Result<Session> {
347        self.store.with(|state| {
348            let session = state::session_of(state, token)
349                .cloned()
350                .ok_or_else(|| unknown_session(token))?;
351            let emission = Emission {
352                stream: token.0.clone(),
353                // No identity label, because the real implementation carries none
354                // here: closing opens the stream fresh, and the label is stamped
355                // where a session is opened.
356                identity: None,
357                kind: EventKind::SessionClosed,
358                payload: object(json!({"token": token.0, "branch": session.branch})),
359            };
360            // Match the real provider's observable ordering: a follower reads the
361            // stream before consulting this state, so the terminator must exist
362            // before `Closed` can be returned to that concurrent reader.
363            events::emit(&emission);
364            state.closed_sessions.insert(token.clone());
365            Ok(session)
366        })
367    }
368
369    /// Publish a session's branch, as far as a provider honestly can.
370    ///
371    /// The host side is performed rather than described: a change request is really
372    /// opened against the [`Hosting`] this was handed, really adopted when one is
373    /// already open, and really merged under the policy — so the six host methods
374    /// are exercised and what the host recorded is what a journey reads back.
375    ///
376    /// The repository side is not, and none of it is claimed. There is no origin to
377    /// fetch from, no tree to build in, no push, and no lock to queue behind, so no
378    /// `fetch`, `push`, `lock-wait`, `lock-acquired`, or `merge-queued` event is
379    /// emitted. What is emitted is what was decided: the change that was opened, and
380    /// the merge that landed.
381    fn publish(
382        &self,
383        token: &SessionToken,
384        request: &PublishRequest,
385        hosting: &dyn Hosting,
386    ) -> Result<Publication> {
387        let (publication, emissions) = self.store.with(|state| {
388            let session = state::session_of(state, token)
389                .cloned()
390                .ok_or_else(|| unknown_session(token))?;
391            let identity = state::identity_for(state, token)?;
392            // The publication rule the crate next door states, applied rather than
393            // restated: a reason that would not render as itself is refused here
394            // exactly where the real implementation refuses it, before any host is
395            // asked and before anything is recorded.
396            if let Some(reason) = &request.draft {
397                reason.checked()?;
398            }
399            let resolved = state.policy.unwrap_or(DEFAULT_PUBLICATION);
400            let policy = match request.policy {
401                Some(requested) => resolved.narrow(requested)?,
402                None => resolved,
403            };
404            let published = |outcome, emissions| {
405                (
406                    Publication {
407                        session: token.clone(),
408                        branch: session.branch.clone(),
409                        policy,
410                        outcome,
411                    },
412                    emissions,
413                )
414            };
415            // A session that has already landed has nothing the base does not carry,
416            // which is what the real implementation reports for the same reason. One
417            // whose change request is merely open or queued has *not* landed, and
418            // publishing it again adopts that change rather than opening a second —
419            // so it falls through to the host, as it does there.
420            if state.publications.iter().any(|earlier| {
421                earlier.session == *token && matches!(earlier.outcome, PublishOutcome::Merged(_))
422            }) {
423                let (publication, emissions) =
424                    published(PublishOutcome::NothingToPublish, Vec::new());
425                state.publications.push(publication.clone());
426                return Ok((publication, emissions));
427            }
428
429            let (outcome, emissions) = if policy == MergePolicy::LocalDirect {
430                // The same refusal the real publication makes at the same boundary: a
431                // local-direct publication opens no change request, so there is
432                // nothing to draft and the work would land carrying the very pin the
433                // draft exists to hold back.
434                match &request.draft {
435                    Some(reason) => (
436                        failed(&Error::Invalid {
437                            reason: format!(
438                                "{branch:?} was asked to publish as {asked}, and this identity \
439                                 publishes with local-direct, which opens no change request at \
440                                 all",
441                                branch = session.branch,
442                                asked = asked_for(reason),
443                            ),
444                        }),
445                        Vec::new(),
446                    ),
447                    None => record_local_landing(&identity, &session, token),
448                }
449            } else {
450                match slug(&identity) {
451                    Some(slug) => match publish_as_change(
452                        hosting, &slug, &identity, &session, policy, request, token,
453                    ) {
454                        Ok(published) => published,
455                        // Once a publication has started, what stops it is an outcome
456                        // rather than a refusal — the same split the real
457                        // implementation keeps, so a caller reads one shape.
458                        Err(error) => (failed(&error), Vec::new()),
459                    },
460                    None => (refusal(&identity), Vec::new()),
461                }
462            };
463            let (publication, emissions) = published(outcome, emissions);
464            state.publications.push(publication.clone());
465            Ok((publication, emissions))
466        })?;
467        for emission in &emissions {
468            events::emit(emission);
469        }
470        Ok(publication)
471    }
472
473    /// The same rows [`Vcs::recoverable`] answers with.
474    ///
475    /// This provider is *handed* its preserved work, and what it is handed is work
476    /// nobody published: there is no base here whose history could record a landing
477    /// and no tree to compare content against, so it withholds nothing and the wider
478    /// question has the same answer as the narrower one.
479    fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>> {
480        self.recoverable(scope)
481    }
482
483    fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
484        self.store.with(|state| {
485            let wanted = match &scope {
486                Scope::All => None,
487                Scope::Repo(repo) => Some(
488                    state::identity_of(state, repo)
489                        .map(|identity| identity.origin.clone())
490                        .ok_or_else(|| Error::Invalid {
491                            reason: format!(
492                                "{repo:?} does not name a repository this provider knows; {}",
493                                state::known(state)
494                            ),
495                        })?,
496                ),
497            };
498            // Newest first, as the real implementation reports them.
499            Ok(state
500                .preserved
501                .iter()
502                .rev()
503                .filter(|row| wanted.as_ref().is_none_or(|key| *key == row.identity))
504                .cloned()
505                .map(|row| held(state, row))
506                .collect())
507        })
508    }
509}
510
511/// Say which live session still holds a row's branch, when one of this provider's
512/// does.
513///
514/// Read here rather than written when the work was preserved, because closing the
515/// session is what ends the hold and a row that had frozen the answer would go on
516/// saying somebody is in there. What makes it *live* is the same thing that makes it
517/// live for the real implementation in-process: the session is open, and the process
518/// that opened it is this one. A row a scenario seeded the answer into keeps it — a
519/// hand-written state says what it means to say.
520fn held(state: &VcsState, row: Recoverable) -> Recoverable {
521    if row.held_by.is_some() {
522        return row;
523    }
524    let holder = state.sessions.iter().find(|session| {
525        session.branch == row.branch.branch
526            && state.session_identities.get(&session.token) == Some(&row.identity)
527            && !state.closed_sessions.contains(&session.token)
528    });
529    Recoverable {
530        held_by: holder.map(|session| HeldBy {
531            token: session.token.clone(),
532            worktree: session.worktree.clone(),
533            holding: Holding::OwnerRunning,
534        }),
535        ..row
536    }
537}
538
539/// The refusal a session this provider never opened meets.
540fn unknown_session(token: &SessionToken) -> Error {
541    Error::Invalid {
542        reason: format!(
543            "no session {:?} is open; `onevcs session open` prints a token",
544            token.0
545        ),
546    }
547}
548
549/// The `owner/name` slug an identity key spells, when it is a GitHub one.
550///
551/// The host is checked rather than assumed, exactly as the real implementation
552/// checks it: a GitLab origin has the same three segments, and a provider that
553/// published one anyway would let a journey pass where the real run answers that
554/// nobody has implemented that host.
555fn slug(identity: &str) -> Option<String> {
556    let mut parts = identity.split('/');
557    let (host, owner, name) = (parts.next()?, parts.next()?, parts.next()?);
558    if parts.next().is_some() || host != DEFAULT_HOST || owner.is_empty() || name.is_empty() {
559        return None;
560    }
561    Some(format!("{owner}/{name}"))
562}
563
564/// Record that a `local-direct` publication landed — record, and nothing more.
565///
566/// Landing one is entirely repository-side work, a squash built detached and
567/// pushed, which this provider does not perform and must not be named as if it
568/// did. What it does is decide the outcome and emit the completion that says so.
569fn record_local_landing(
570    identity: &str,
571    session: &Session,
572    token: &SessionToken,
573) -> (PublishOutcome, Vec<Emission>) {
574    let sha = events::stable_sha(&["publish", &token.0, &session.branch]);
575    let emission = Emission {
576        stream: token.0.clone(),
577        identity: Some(identity.to_owned()),
578        kind: EventKind::MergeCompleted,
579        payload: object(json!({"identity": identity, "sha": sha, "base": session.base})),
580    };
581    (PublishOutcome::Merged(Sha(sha)), vec![emission])
582}
583
584/// Publish as a change request: open the session's change on the host, or adopt
585/// the one it already holds, and then do with it what the policy asks — which for
586/// `change-open` is to leave it open and ask the host for nothing more.
587fn publish_as_change(
588    hosting: &dyn Hosting,
589    slug: &str,
590    identity: &str,
591    session: &Session,
592    policy: MergePolicy,
593    request: &PublishRequest,
594    token: &SessionToken,
595) -> Result<(PublishOutcome, Vec<Emission>)> {
596    let host = hosting.for_repo(slug)?;
597    // Who the host believes is calling travels with the change, as it does in the
598    // real publication and for the same reason.
599    let author = host.authenticated_user()?;
600    let existing = host.find_changes(&session.branch, &session.base)?;
601    // Whether the host already held it, which is what decides whether there can be a
602    // draft to lift — and, as next door, keeps a publication that opens its own change
603    // request from asking this host anything extra.
604    let adopted = !existing.is_empty();
605    let change = match existing.into_iter().next() {
606        Some(change) => change,
607        None => host.open_change(ChangeSpec {
608            head: session.branch.clone(),
609            base: session.base.clone(),
610            // A requested title has been checked by the conversion that built it, so
611            // this provider cannot accept one the real publication would refuse. The
612            // real implementation takes the subject from the branch's commits when no
613            // title was requested, and a provider has no commits to read — so an
614            // unrequested title names the branch instead.
615            title: request
616                .title
617                .as_deref()
618                .map_or_else(|| format!("Publish {}", session.branch), str::to_owned),
619            // Verbatim, and nothing when there is none — the real publication
620            // composes no body either, so a provider that composed one would be a
621            // consumer's suite proving a change request nobody opens.
622            body: request.body.clone(),
623            // The reason travels to the host and no further, as it does there: what
624            // the host does with it is open the change as a draft.
625            draft: request.draft.clone(),
626        })?,
627    };
628    let mut emissions = vec![Emission {
629        stream: token.0.clone(),
630        identity: Some(identity.to_owned()),
631        kind: EventKind::ChangeOpened,
632        payload: object(json!({
633            "url": change.url.to_string(),
634            "host": "github",
635            "id": change.id.0,
636            "base": change.base,
637            "author": author,
638        })),
639    }];
640    if let Some(reason) = &request.draft {
641        // The same question the real publication asks, for the same reason: a host
642        // that ignored the request, or a change request already open for review,
643        // would otherwise be reported as held back while it can land.
644        if !host.is_draft(&change)? {
645            return Err(Error::Invalid {
646                reason: format!(
647                    "{url} is open for review on the host, and this publication asked for \
648                     {asked}. A change that is open can land, so reporting it as a draft would \
649                     say the work is held back when nothing is holding it",
650                    url = change.url,
651                    asked = asked_for(reason),
652                ),
653            });
654        }
655        // The publication record, and the only place the reason is written: the real
656        // implementation writes nothing of it into the change request, and a provider
657        // that did would be a consumer's suite proving a body nobody renders. The
658        // payload is the reason's own serialized form — its `kind` and every field of
659        // that kind — under the change request it holds, exactly as next door.
660        let mut drafted = object(json!({
661            "url": change.url.to_string(),
662            "id": change.id.0,
663            "base": change.base,
664        }));
665        if let serde_json::Value::Object(fields) =
666            serde_json::to_value(reason).expect("a draft reason serializes")
667        {
668            drafted.extend(fields);
669        }
670        emissions.push(Emission {
671            stream: token.0.clone(),
672            identity: Some(identity.to_owned()),
673            kind: EventKind::ChangeDrafted,
674            payload: drafted,
675        });
676        // Under every policy: a draft is unmergeable in that state, so nothing below
677        // asks this host to merge it.
678        return Ok((PublishOutcome::ChangeDraft(change.url.clone()), emissions));
679    }
680    // Publishing without a reason is what lifts a draft, and a change that is not one
681    // is asked for nothing — which is what makes a second publication idempotent.
682    // Asked only of a change the host already held, as next door: one opened moments
683    // ago without a reason is one nobody drafted. Three answers, told apart the same
684    // way — a host that was never taught to draft one has nothing to lift and is
685    // passed over, and a host that *could not say* is a refusal rather than a change
686    // nobody is holding.
687    match if adopted {
688        host.is_draft(&change)
689    } else {
690        Ok(false)
691    } {
692        Ok(true) => {
693            host.ready_for_review(&change)?;
694            emissions.push(Emission {
695                stream: token.0.clone(),
696                identity: Some(identity.to_owned()),
697                kind: EventKind::DraftLifted,
698                payload: object(json!({"url": change.url.to_string(), "id": change.id.0})),
699            });
700        }
701        Ok(false) => {}
702        Err(Error::NotImplemented { .. }) => {}
703        Err(unreadable) => return Err(unreadable),
704    }
705
706    if policy == MergePolicy::ChangeOpen {
707        return Ok((PublishOutcome::ChangeOpen(change.url.clone()), emissions));
708    }
709    Ok(match host.merge(&change, policy)? {
710        MergeOutcome::Merged(sha) => {
711            emissions.push(Emission {
712                stream: token.0.clone(),
713                identity: Some(identity.to_owned()),
714                kind: EventKind::ChangeMerged,
715                payload: object(json!({"url": change.url.to_string(), "sha": sha.0})),
716            });
717            emissions.push(Emission {
718                stream: token.0.clone(),
719                identity: Some(identity.to_owned()),
720                kind: EventKind::MergeCompleted,
721                payload: object(json!({"identity": identity, "sha": sha.0})),
722            });
723            (PublishOutcome::Merged(sha), emissions)
724        }
725        MergeOutcome::Queued => (PublishOutcome::Queued(change.url.clone()), emissions),
726        MergeOutcome::Open => (PublishOutcome::ChangeOpen(change.url.clone()), emissions),
727    })
728}
729
730/// The draft a publication asked for, as the two refusals next door spell it: "a
731/// draft awaiting <repository> <target>", or "a draft held by the session that opened
732/// it (<why>)". A restatement, so the refusals here read as the real ones do.
733fn asked_for(reason: &DraftReason) -> String {
734    match reason {
735        DraftReason::AwaitingRelease {
736            awaiting, target, ..
737        } => format!("a draft awaiting {awaiting} {target}"),
738        DraftReason::Held { because } => {
739            format!("a draft held by the session that opened it ({because})")
740        }
741    }
742}
743
744/// What a publication answers for an identity no change request can be opened
745/// against.
746///
747/// Two failures rather than one, as the real implementation keeps them: an
748/// identity that is not hosted at all is asking for the wrong policy, while a
749/// hosted one on a host this build does not speak for is asking for an
750/// implementation that has not arrived.
751fn refusal(identity: &str) -> PublishOutcome {
752    failed(&if identity.split('/').count() == 3 {
753        Error::NotImplemented {
754            operation: "RemoteHost for a host other than github.com",
755        }
756    } else {
757        Error::Invalid {
758            reason: format!(
759                "identity {identity:?} is not a hosted repository, so it cannot publish a \
760                 change request; a local identity publishes with local-direct"
761            ),
762        }
763    })
764}
765
766/// One failure, as the outcome a publication that started and did not land is.
767fn failed(error: &Error) -> PublishOutcome {
768    PublishOutcome::Failed {
769        // Through the crate's own mapping, so the kind a caller branches on is the
770        // one the real implementation would report for the same failure.
771        kind: FailureKind::of(error),
772        reason: error.to_string(),
773        // A provider has no execution checkout, so there is nowhere a branch could
774        // have been handed back to and nothing to report about one.
775        retained: None,
776    }
777}
778
779/// The argv that lands a preserved branch, as `recoverable` reports it.
780///
781/// Both verbs take the branch by name and the repository by path, the way the real
782/// implementation reports them: the provenance decides which one, and neither
783/// depends on the directory a reader happens to be standing in.
784// llmlint: ignore[names_match_behavior] named for the public
785// `Recoverable::recover_command` field it fills, which `onevcs` publishes and this
786// crate must answer identically; a provider whose helper were named otherwise would
787// read as filling something else.
788fn recover_command(branch: &str, checkout: &Path, provenance: Provenance) -> Vec<String> {
789    let verb = match provenance {
790        Provenance::IncompleteStep => "recover",
791        Provenance::Complete => "publish-branch",
792    };
793    vec![
794        "onevcs".to_owned(),
795        verb.to_owned(),
796        branch.to_owned(),
797        "--repo".to_owned(),
798        checkout.display().to_string(),
799    ]
800}
801
802/// How a provenance kind is spelled in an event payload.
803fn spell(provenance: Provenance) -> &'static str {
804    match provenance {
805        Provenance::Complete => "complete",
806        Provenance::IncompleteStep => "incomplete-step",
807    }
808}
809
810fn object(value: Value) -> Map<String, Value> {
811    value.as_object().cloned().unwrap_or_default()
812}