Skip to main content

onevcs/
releases.rs

1//! The release-targets file: what each repository releases, and how a release of
2//! it is learned about.
3//!
4//! YAML, first match wins, matched on the same [`RuleMatch`] the rules file uses —
5//! two match vocabularies over the same identities would drift.
6//!
7//! **The style decides the shape of a target rather than labelling it.** An
8//! *automated* target carries a probe and is answered by running it; a *human-step*
9//! target carries no probe at all — there is nothing to ask, because the release
10//! happens when a person does something — and is answered by an explicit record a
11//! person writes afterwards. So `style` is the tag over the two shapes and not a
12//! field beside them: a human-step target naming a `probe:` and an automated target
13//! naming an `action:` are both refused where the document is read, and "a
14//! human-step target has a probe" is not a state this crate can hold.
15//!
16//! Nothing here declares `deny_unknown_fields`. A document a *newer* build wrote
17//! loads on this one, which takes the fields it understands and ignores the keys it
18//! has no opinion on — an older `onevcs` on a host whose release targets were
19//! configured by a newer one is degraded, never stopped. What a target's own shape
20//! still refuses it refuses by naming the target, because those are not keys this
21//! build has no opinion on: they are the two forms of a probe, and which one a
22//! target carries is the whole of what makes it answerable.
23
24use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26use std::time::{Duration, Instant};
27
28use serde::{Deserialize, Serialize};
29
30use crate::declaration::{Declaration, InstructionTemplate};
31use crate::rules::RuleMatch;
32
33/// The version of the release-targets file this build writes, and the oldest it
34/// reads.
35///
36/// The oldest rather than the newest: a document declaring a *later* version is read
37/// as this shape, with whatever it names beyond it ignored, because refusing it would
38/// stop every release verb on a host a newer `onevcs` had configured.
39pub const VERSION: u32 = 1;
40
41/// The bound a probe runs under when the document names none.
42pub const DEFAULT_PROBE_TIMEOUT_SECONDS: u64 = 60;
43
44/// A release-targets file, as it is stored on disk.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ReleasesFile {
47    /// The schema version: `1` is the shape declared here.
48    // llmlint: ignore[boundary_inputs_validated] which versions this build reads is the
49    // loader's question rather than this type's, and `release::load` answers it: a
50    // document below this one is refused by number, before the shape is enforced, and a
51    // later one is read as this shape with whatever it names beyond it ignored. What the
52    // shape can reject — an undeclared adoption, or a target whose style and body
53    // disagree — is rejected here and asserted in tests/contract.rs.
54    pub version: u32,
55    /// The rules, in priority order: the first one that matches wins.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub repositories: Vec<ReleaseRule>,
58    /// What a repository no rule below names gets.
59    pub default: ReleaseDefault,
60}
61
62/// The global rung of the adoption chain.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ReleaseDefault {
65    /// What a repository no rule names adopts.
66    pub adoption: Adoption,
67}
68
69/// One rule: what it matches, what that repository adopts, and what it releases.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct ReleaseRule {
72    /// What this rule applies to, in the rules file's own vocabulary.
73    #[serde(rename = "match")]
74    pub r#match: RuleMatch,
75    /// The per-repository rung of the adoption chain. Unset falls to the global one.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub adoption: Option<Adoption>,
78    /// What a consumer naming no target gets.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub default_target: Option<TargetName>,
81    /// What this rule does with the repository's own declaration. Unset is
82    /// [`DeclarationPolicy::Merge`], which is what makes a producer's declaration
83    /// visible to a host that has said nothing about it.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub declaration: Option<DeclarationPolicy>,
86    /// The targets this host waits on for this repository.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub targets: Vec<ReleaseTarget>,
89}
90
91/// What one rule does with the declaration the repository itself carries.
92///
93/// The default is [`Merge`](Self::Merge), because a host that has said nothing
94/// about a repository's own declaration has not said it should be ignored — and a
95/// producer that declares what it publishes is discoverable without this host being
96/// told the same thing twice.
97///
98/// [`Ignore`](Self::Ignore) is the one way a host says *a target it does not
99/// consume*: the rule's own targets become the whole answer, which is exactly what
100/// a rule answered before a producer declaration existed.
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum DeclarationPolicy {
104    /// This rule's targets are laid over the repository's own, by short name.
105    #[default]
106    Merge,
107    /// The repository's own declaration is not consulted for targets at all.
108    Ignore,
109}
110
111impl DeclarationPolicy {
112    /// How this policy is spelled in the document and in a rendering.
113    pub fn as_str(&self) -> &'static str {
114        match self {
115            DeclarationPolicy::Merge => "merge",
116            DeclarationPolicy::Ignore => "ignore",
117        }
118    }
119}
120
121impl std::fmt::Display for DeclarationPolicy {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.write_str(self.as_str())
124    }
125}
126
127/// Whether a node waits for the *work* or for the *release* that carries it.
128///
129/// Two rungs of a four-rung chain live here — the global one and the
130/// per-repository one. The node rung and a consumer's own default are the
131/// consumer's, which is why [`adoption_for`](crate::adoption_for) answers these two
132/// and never those.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "kebab-case")]
135pub enum Adoption {
136    /// The work is enough: a consumer may proceed as soon as the change lands.
137    Fast,
138    /// The release is what is depended on, so a consumer waits for one.
139    Published,
140}
141
142impl Adoption {
143    /// How this rung is spelled in the document and in a rendering.
144    pub fn as_str(&self) -> &'static str {
145        match self {
146            Adoption::Fast => "fast",
147            Adoption::Published => "published",
148        }
149    }
150}
151
152impl std::fmt::Display for Adoption {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.write_str(self.as_str())
155    }
156}
157
158/// One thing a repository releases.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ReleaseTarget {
161    /// What it is called, which is how every command and every record names it.
162    pub name: TargetName,
163    /// How it is released, and therefore how a release of it is learned about.
164    pub release: ReleaseMethod,
165    /// The minijinja template a consumer of this target is instructed by, after the
166    /// three layers have resolved.
167    ///
168    /// A **declared** target carries the producer's own, copied across when the
169    /// declaration is read; a target this host names carries this host's. An override
170    /// replaces the target whole, this field with it — a half-host, half-producer
171    /// target is a probe nobody wrote. Composition is the consumer's explicit act
172    /// instead, inside the template: a host template may begin
173    /// `{% extends "producer" %}` and override named blocks, and one naming no
174    /// `extends` replaces wholly.
175    pub adoption_instructions: Option<InstructionTemplate>,
176}
177
178impl ReleaseTarget {
179    /// The label this target's style is reported under.
180    pub fn style(&self) -> ReleaseStyle {
181        match self.release {
182            ReleaseMethod::Automated { .. } => ReleaseStyle::Automated,
183            ReleaseMethod::HumanStep { .. } => ReleaseStyle::HumanStep,
184        }
185    }
186
187    /// The probe that answers what is released, when there is one.
188    ///
189    /// **Always `None` for a human-step target**, by construction rather than by a
190    /// check that could be forgotten: the probe lives on the automated variant, so
191    /// there is none here to hand back and none for a caller to run.
192    pub fn probe(&self) -> Option<&Probe> {
193        match &self.release {
194            ReleaseMethod::Automated { probe } => Some(probe),
195            ReleaseMethod::HumanStep { .. } => None,
196        }
197    }
198
199    /// What a person has to do, for the target that waits on one.
200    pub fn action(&self) -> Option<&str> {
201        match &self.release {
202            ReleaseMethod::HumanStep { action } => Some(action),
203            ReleaseMethod::Automated { .. } => None,
204        }
205    }
206}
207
208/// How a target is released, and therefore how a release of it is learned about.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum ReleaseMethod {
211    /// A machine releases it, and a probe says what is out.
212    Automated {
213        /// What to run to find out what is released right now.
214        probe: Probe,
215    },
216    /// A person releases it, and says so afterwards.
217    HumanStep {
218        /// What that person has to do, rendered in the wait.
219        action: String,
220    },
221}
222
223/// Which of the two styles a target is.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "kebab-case")]
226pub enum ReleaseStyle {
227    /// A machine releases it; a probe answers what is out.
228    Automated,
229    /// A person releases it; an acknowledgement records that they did.
230    HumanStep,
231}
232
233impl ReleaseStyle {
234    /// How this style is spelled in the document, in an event, and in a rendering.
235    pub fn as_str(&self) -> &'static str {
236        match self {
237            ReleaseStyle::Automated => "automated",
238            ReleaseStyle::HumanStep => "human-step",
239        }
240    }
241}
242
243impl std::fmt::Display for ReleaseStyle {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        f.write_str(self.as_str())
246    }
247}
248
249/// Where the answer that a release carries a landing came from.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "kebab-case")]
252pub enum ReleaseSource {
253    /// The target's configured probe answered it.
254    Probed,
255    /// A person explicitly recorded it.
256    Acknowledged,
257}
258
259impl std::fmt::Display for ReleaseSource {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            ReleaseSource::Probed => f.write_str("probed"),
263            ReleaseSource::Acknowledged => f.write_str("acknowledged"),
264        }
265    }
266}
267
268/// What to run to find out what version of a target is released right now.
269///
270/// Two forms and no way to spell a third, neither privileged: a script checked into
271/// the repository being released, run as a direct subprocess, and a one-liner
272/// configured on this host, run through `sh -c`. Both are bounded, and a target
273/// naming both forms or neither does not deserialize at all.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum Probe {
276    /// A path relative to the repository root, checked into the repository being
277    /// released. Run as a direct subprocess, never through a shell.
278    Script {
279        /// Where it is, relative to the repository root.
280        script: PathBuf,
281        /// The arguments it is given.
282        args: Vec<String>,
283        /// The bound it runs under.
284        timeout_seconds: u64,
285    },
286    /// A one-liner configured on this host, run through `sh -c`.
287    Shell {
288        /// The command line, as `sh` reads it.
289        shell: String,
290        /// The bound it runs under.
291        timeout_seconds: u64,
292    },
293}
294
295impl Probe {
296    /// Which form this is, as the `release-probed` event reports it.
297    pub fn form(&self) -> &'static str {
298        match self {
299            Probe::Script { .. } => "script",
300            Probe::Shell { .. } => "shell",
301        }
302    }
303}
304
305/// A target's name, checked where it is read.
306///
307/// It names a key in the persisted release record, a directory-free file-safe
308/// token, and an operand of `--target`, so what may spell one is decided in the
309/// conversion — a name that could not be all three is unrepresentable rather than
310/// refused by whichever of the three met it first.
311#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
312#[serde(try_from = "String", into = "String")]
313pub struct TargetName(String);
314
315/// How long a target's name may be. Long enough for any name a person types, short
316/// enough that a refusal quoting one is still a sentence.
317const MAX_TARGET_NAME: usize = 64;
318
319/// How long a human step's action may be. It is one instruction rendered on one
320/// line beside the wait it explains, not the procedure behind it.
321const MAX_ACTION: usize = 200;
322
323/// Whether a value from the release-targets file renders as the one line it is
324/// printed on.
325///
326/// An `action` and a script probe's arguments are both operator-written text that
327/// this crate prints — in a table, in a refusal, and in an event payload — so a
328/// value carrying a newline or a control character renders as something other than
329/// what it is wherever it lands. Checked where the document is read, which is the
330/// boundary it arrives at, rather than by whichever renderer met it first.
331fn renders_on_one_line(value: &str) -> bool {
332    !value.chars().any(char::is_control)
333}
334
335impl TryFrom<String> for TargetName {
336    type Error = String;
337
338    fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
339        if value.is_empty() {
340            return Err("a release target's name cannot be empty".to_owned());
341        }
342        if value.len() > MAX_TARGET_NAME {
343            return Err(format!(
344                "the release target name {value:?} is longer than {MAX_TARGET_NAME} characters"
345            ));
346        }
347        if !value
348            .chars()
349            .next()
350            .is_some_and(|first| first.is_ascii_alphanumeric())
351        {
352            return Err(format!(
353                "the release target name {value:?} must start with a letter or a digit"
354            ));
355        }
356        if !value
357            .chars()
358            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
359        {
360            return Err(format!(
361                "the release target name {value:?} may hold only letters, digits, '-', '_', \
362                 and '.'"
363            ));
364        }
365        Ok(TargetName(value))
366    }
367}
368
369/// The conversion an argument parser reaches for, which is the same check.
370///
371/// A `--target` operand and a document's `name:` are one vocabulary, so a name the
372/// document would refuse is refused on the command line too — and with the same
373/// sentence, rather than with a parser's usage text for one and prose for the other.
374impl std::str::FromStr for TargetName {
375    type Err = String;
376
377    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
378        TargetName::try_from(value.to_owned())
379    }
380}
381
382impl From<TargetName> for String {
383    fn from(name: TargetName) -> Self {
384        name.0
385    }
386}
387
388/// The name itself, quoted — never the wrapper, for the reason
389/// [`TrailerPrefix`](crate::rules::TrailerPrefix) spells its own: every refusal that
390/// names a target writes it with `{:?}`, and a derived `Debug` would spell each of
391/// them at an operator who configured a plain word.
392impl std::fmt::Debug for TargetName {
393    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394        std::fmt::Debug::fmt(&self.0, f)
395    }
396}
397
398impl std::fmt::Display for TargetName {
399    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400        f.write_str(&self.0)
401    }
402}
403
404impl std::ops::Deref for TargetName {
405    type Target = str;
406
407    fn deref(&self) -> &str {
408        &self.0
409    }
410}
411
412/// What one probe answered about what is released right now.
413///
414/// Three values, and the third is the point: **"not answered" and "not released"
415/// are different answers** and stay different all the way up. A consumer holds
416/// indefinitely on "not answered" and never reads it as evidence that a release has
417/// not happened.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(tag = "state", rename_all = "kebab-case")]
420pub enum ReleaseAnswer {
421    /// A release exists, at this version.
422    Released {
423        /// The version the probe printed.
424        version: String,
425    },
426    /// The target has no release yet. An answer, not a failure.
427    NoRelease,
428    /// The question was not answered: a non-zero exit, a timeout, a spawn failure,
429    /// or output that is not one usable line. **Never "not released".**
430    NotAnswered {
431        /// What stopped the probe answering.
432        reason: String,
433    },
434}
435
436/// What a target had at the moment a change landed.
437///
438/// Only these two can be compared against later, which is why the answer a probe
439/// that did not answer left behind is not a member of this type — see
440/// [`BaselineRecord`].
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(tag = "state", rename_all = "kebab-case")]
443pub enum Baseline {
444    /// A release existed. A strictly greater one carries the change.
445    At {
446        /// The version that was out when the change landed.
447        version: String,
448    },
449    /// Nothing had ever been released for this target. The *first* release of any
450    /// version carries the change.
451    NoRelease,
452}
453
454/// What is persisted per landing — a baseline, or the record that there is none.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub enum BaselineRecord {
457    /// A baseline was established at the landing, and can be compared against.
458    Established(Baseline),
459    /// No baseline could be taken at landing: no probe answered then, or the landing
460    /// was found only after a release may already carry it. **Not a baseline**, and
461    /// never usable as one: a probe answering a *version* later cannot repair it,
462    /// because the release carrying this very change may already be included in it.
463    Unestablished {
464        /// Why: what the probe said when it was asked at the landing, or what says a
465        /// release may already carry a landing found late.
466        reason: String,
467        /// When it was asked, RFC3339 with millisecond precision, in UTC.
468        attempted_at: String,
469    },
470}
471
472/// The persisted form of a baseline record: one tagged object rather than a bare
473/// string, because a bare string cannot express the other two answers and
474/// conflating any pair of them is how a change gets reported as released when it is
475/// not.
476#[derive(Serialize, Deserialize)]
477#[serde(tag = "state", rename_all = "kebab-case")]
478enum StoredBaseline {
479    At {
480        version: String,
481    },
482    NoRelease,
483    Unestablished {
484        reason: String,
485        attempted_at: String,
486    },
487}
488
489impl Serialize for BaselineRecord {
490    fn serialize<S: serde::Serializer>(
491        &self,
492        serializer: S,
493    ) -> std::result::Result<S::Ok, S::Error> {
494        let stored = match self {
495            BaselineRecord::Established(Baseline::At { version }) => StoredBaseline::At {
496                version: version.clone(),
497            },
498            BaselineRecord::Established(Baseline::NoRelease) => StoredBaseline::NoRelease,
499            BaselineRecord::Unestablished {
500                reason,
501                attempted_at,
502            } => StoredBaseline::Unestablished {
503                reason: reason.clone(),
504                attempted_at: attempted_at.clone(),
505            },
506        };
507        stored.serialize(serializer)
508    }
509}
510
511impl<'de> Deserialize<'de> for BaselineRecord {
512    fn deserialize<D: serde::Deserializer<'de>>(
513        deserializer: D,
514    ) -> std::result::Result<Self, D::Error> {
515        Ok(match StoredBaseline::deserialize(deserializer)? {
516            StoredBaseline::At { version } => BaselineRecord::Established(Baseline::At { version }),
517            StoredBaseline::NoRelease => BaselineRecord::Established(Baseline::NoRelease),
518            StoredBaseline::Unestablished {
519                reason,
520                attempted_at,
521            } => BaselineRecord::Unestablished {
522                reason,
523                attempted_at,
524            },
525        })
526    }
527}
528
529/// Whether the release that carries one landed change has happened yet.
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(tag = "state", rename_all = "kebab-case")]
532pub enum ReleaseStatus {
533    /// A release carrying this change is out, at this version.
534    Released {
535        /// The target that carries it.
536        target: TargetName,
537        /// Which kind of release that was.
538        style: ReleaseStyle,
539        /// The version that carries the change.
540        version: String,
541        /// Where this answer came from.
542        source: ReleaseSource,
543    },
544    /// Automated only: a probe answered, and the baseline has not been passed.
545    // llmlint: ignore[invalid_states_unrepresentable] `now` is an empty string where
546    // there is no release right now, and that is the approved amendment's own shape —
547    // fixed across the nodes being written against it, so an `Option<String>` here
548    // would be this crate changing a shared interface unilaterally. The side a
549    // comparison is *made against* is already the enum this rule asks for: `at_landing`
550    // is a `Baseline`, which is what carries the state a bare string could not express.
551    // The empty string is documented on the field and rendered as `no release` by the
552    // one thing that prints it, and every path that produces it is a journey.
553    NotReleased {
554        /// What the target had when the change landed.
555        at_landing: Baseline,
556        /// What it has now — empty where there is no release right now at all,
557        /// which is what a target answers before its first release and after a
558        /// yank.
559        now: String,
560    },
561    /// Human step only: it landed, and nobody has acknowledged a release yet.
562    /// Neither [`NotReleased`](ReleaseStatus::NotReleased) (no probe answered) nor
563    /// [`NotAnswered`](ReleaseStatus::NotAnswered) (no probe failed).
564    AwaitingHumanStep {
565        /// The target waiting on a person.
566        target: TargetName,
567        /// What that person has to do.
568        action: String,
569        /// When the wait started: the landing commit's own committer date.
570        since: String,
571    },
572    /// The question was not answered, and this is why. **Never "not released".**
573    NotAnswered {
574        /// What stopped it being answered.
575        reason: String,
576    },
577    /// The work has not reached its base, so there is no release to ask about yet.
578    NotLanded,
579}
580
581/// One release a person performed and then recorded.
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct Acknowledgement {
584    /// The repository identity it belongs to.
585    pub identity: String,
586    /// The target that was released.
587    pub target: TargetName,
588    /// The commit the change landed at, which is what it is recorded against.
589    pub landing_commit: String,
590    /// The version that was released.
591    pub version: String,
592    /// When it was recorded, RFC3339 with millisecond precision, in UTC.
593    pub recorded_at: String,
594    /// Who recorded it.
595    pub actor: String,
596    /// Every version this record replaced, oldest first. Empty on a first record.
597    #[serde(default, skip_serializing_if = "Vec::is_empty")]
598    pub superseded: Vec<SupersededRelease>,
599}
600
601/// A version an acknowledgement replaced, kept so the correction is visible rather
602/// than destructive.
603#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
604pub struct SupersededRelease {
605    /// The version that was recorded before.
606    pub version: String,
607    /// When it was recorded.
608    pub recorded_at: String,
609    /// Who recorded it.
610    pub actor: String,
611}
612
613/// What a repository's own declaration said, or why this build has no answer from
614/// it.
615///
616/// Three states, and the third is why this is a value rather than an
617/// `Option<Declaration>`: **a declaration this build could not read is not a
618/// repository that declares nothing.** A consumer waiting on a release holds
619/// indefinitely on the first and acts on the second, so collapsing them here would
620/// be the "not answered is not not released" mistake made one layer up.
621#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
622#[serde(tag = "state", rename_all = "kebab-case")]
623pub enum DeclarationSource {
624    /// The repository declares what it publishes, and this build read it.
625    Declared {
626        /// The document it was read from.
627        document: PathBuf,
628        /// What that document declares.
629        declared: Declaration,
630    },
631    /// The repository carries no declaration: nothing in it says what it publishes,
632    /// so this host's own document is the whole answer.
633    Undeclared {
634        /// The checkout that was looked in.
635        looked_in: PathBuf,
636    },
637    /// What this repository declares is **unknown** — there is nowhere to read a
638    /// declaration from, or the one that is there is not a declaration this build
639    /// can read.
640    ///
641    /// Never "it declares nothing": whatever targets are answered alongside this may
642    /// be fewer than the repository publishes.
643    Unreadable {
644        /// Why it could not be read, in the words the reader refused it with.
645        reason: String,
646    },
647}
648
649impl DeclarationSource {
650    /// How this state is spelled in a rendering and in the JSON document.
651    pub fn as_str(&self) -> &'static str {
652        match self {
653            DeclarationSource::Declared { .. } => "declared",
654            DeclarationSource::Undeclared { .. } => "undeclared",
655            DeclarationSource::Unreadable { .. } => "unreadable",
656        }
657    }
658
659    /// What the repository declares, where this build read a declaration.
660    pub fn declared(&self) -> Option<&Declaration> {
661        match self {
662            DeclarationSource::Declared { declared, .. } => Some(declared),
663            _ => None,
664        }
665    }
666
667    /// Why what this repository publishes is unknown, where it is.
668    ///
669    /// A caller that has to say "there may be more targets than these" asks this,
670    /// and gets `None` for both states in which the answer is complete.
671    pub fn unreadable(&self) -> Option<&str> {
672        match self {
673            DeclarationSource::Unreadable { reason } => Some(reason),
674            _ => None,
675        }
676    }
677}
678
679/// Which of the three layers put one target in the answer.
680///
681/// Recorded per target rather than inferred by a reader, because "why did this
682/// repository resolve these targets" has to be answerable from the answer itself —
683/// a merged set in which every target looks alike is a set nobody can audit.
684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(rename_all = "kebab-case")]
686pub enum TargetSource {
687    /// The repository's own declaration, which this host did not oppose.
688    Declared,
689    /// This host's own document, naming a target the repository does not declare —
690    /// or does not declare that this build could read.
691    Host,
692    /// This host's own document, replacing a target the repository declared under
693    /// the same short name.
694    Override,
695}
696
697impl TargetSource {
698    /// How this layer is spelled in a rendering and in the JSON document.
699    pub fn as_str(&self) -> &'static str {
700        match self {
701            TargetSource::Declared => "declared",
702            TargetSource::Host => "host",
703            TargetSource::Override => "override",
704        }
705    }
706}
707
708impl std::fmt::Display for TargetSource {
709    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
710        f.write_str(self.as_str())
711    }
712}
713
714/// What one repository releases, and what it adopts.
715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
716pub struct RepositoryReleases {
717    /// The identity key these targets belong to.
718    pub identity: String,
719    /// The rung the adoption chain resolves to here.
720    pub adoption: Adoption,
721    /// What a consumer naming no target gets.
722    #[serde(skip_serializing_if = "Option::is_none")]
723    pub default_target: Option<TargetName>,
724    /// Every target this repository releases: the ones it declares itself, in its
725    /// own publication order, then the ones this host names that it does not.
726    pub targets: Vec<ReleaseTarget>,
727    /// What the repository's own declaration said, or why there is no answer from
728    /// it.
729    pub declaration: DeclarationSource,
730    /// Which layer resolved each target above.
731    pub sources: BTreeMap<TargetName, TargetSource>,
732}
733
734/// Every target one repository has, and what each of them has released right now.
735///
736/// The answer a consumer that waits on releases asks for: [`RepositoryReleases`]
737/// alone says what there is to wait for, and this says what each of those has done.
738/// One call rather than one per target, because a node holding on a dependency has
739/// to ask about all of them and a caller that looped would have this crate resolve
740/// the three layers once per target.
741#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
742pub struct Discovery {
743    /// What this repository releases, and where each target came from.
744    pub releases: RepositoryReleases,
745    /// What each of those targets has released, in `releases.targets` order.
746    pub released: Vec<TargetRelease>,
747}
748
749/// One target, and what it has released right now.
750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct TargetRelease {
752    /// Which target this is.
753    pub target: TargetName,
754    /// How it is released, which is how its answer was arrived at.
755    pub style: ReleaseStyle,
756    /// What is released right now — and `NotAnswered` where nothing said, which is
757    /// never `NoRelease`.
758    pub answer: ReleaseAnswer,
759}
760
761impl RepositoryReleases {
762    /// The target one name selects, or the reason no target answers to it.
763    ///
764    /// A caller naming none gets the declared `default_target`; a repository that
765    /// declares no default answers with what it does declare rather than guessing,
766    /// because which artifact a consumer depends on is not something to infer from
767    /// there happening to be one.
768    pub fn select(&self, named: Option<&TargetName>) -> crate::Result<&ReleaseTarget> {
769        if self.targets.is_empty() {
770            return Err(crate::error::invalid(format!(
771                "the repository {} declares no release targets, so there is nothing to ask \
772                 about; declare some in its own {file}, or under `repositories:` in the \
773                 release-targets file{unknown}",
774                self.identity,
775                file = crate::declaration::FILE,
776                unknown = self.unknown(),
777            )));
778        }
779        let wanted = match named.or(self.default_target.as_ref()) {
780            Some(wanted) => wanted,
781            None => {
782                return Err(crate::error::invalid(format!(
783                    "the repository {} declares no default_target, so a target has to be named: \
784                     --target {}",
785                    self.identity,
786                    self.declared()
787                )))
788            }
789        };
790        self.targets
791            .iter()
792            .find(|target| target.name == *wanted)
793            .ok_or_else(|| {
794                crate::error::invalid(format!(
795                    "the repository {} declares no release target {wanted:?}; it declares \
796                     {declared}. `onevcs release targets {identity}` lists them{unknown}",
797                    self.identity,
798                    declared = self.declared(),
799                    identity = self.identity,
800                    unknown = self.unknown(),
801                ))
802            })
803    }
804
805    /// The targets this repository declares, as a refusal lists them.
806    fn declared(&self) -> String {
807        self.targets
808            .iter()
809            .map(|target| target.name.to_string())
810            .collect::<Vec<String>>()
811            .join(", ")
812    }
813
814    /// What a refusal about a missing target has to add where this build could not
815    /// read what the repository declares.
816    ///
817    /// Without it a refusal states as a fact — "it declares these" — something this
818    /// build only knows half of, and sends somebody to add a target to the host
819    /// document that the repository already publishes. Empty in both states where
820    /// the answer is complete, so no refusal grows a clause about a document that
821    /// read fine.
822    fn unknown(&self) -> String {
823        self.declaration
824            .unreadable()
825            .map_or_else(String::new, |reason| {
826                format!(
827                    ". This repository's own declaration could not be read, so it may declare \
828                 targets this does not name: {reason}"
829                )
830            })
831    }
832}
833
834/// A release target as the document spells it: the name, the style, and whichever
835/// body that style takes.
836///
837/// The shape is read flat and converted, rather than being a serde-tagged enum
838/// beside a name, so that every refusal can **name the target it is about** — which
839/// is the whole of what an operator needs to find the four lines they wrote.
840#[derive(Deserialize)]
841struct StoredTarget {
842    name: TargetName,
843    style: ReleaseStyle,
844    #[serde(default)]
845    probe: Option<StoredProbe>,
846    #[serde(default)]
847    action: Option<String>,
848    #[serde(default)]
849    adoption_instructions: Option<InstructionTemplate>,
850}
851
852/// A probe as the document spells it: both forms' keys, exactly one of which a
853/// target may name.
854#[derive(Deserialize)]
855struct StoredProbe {
856    #[serde(default)]
857    script: Option<PathBuf>,
858    #[serde(default)]
859    args: Option<Vec<String>>,
860    #[serde(default)]
861    shell: Option<String>,
862    #[serde(default)]
863    timeout_seconds: Option<u64>,
864}
865
866/// The serialized form, which is the same flat shape it is read from.
867#[derive(Serialize)]
868struct WrittenTarget<'a> {
869    name: &'a TargetName,
870    style: ReleaseStyle,
871    #[serde(skip_serializing_if = "Option::is_none")]
872    probe: Option<WrittenProbe<'a>>,
873    #[serde(skip_serializing_if = "Option::is_none")]
874    action: Option<&'a str>,
875    #[serde(skip_serializing_if = "Option::is_none")]
876    adoption_instructions: Option<&'a InstructionTemplate>,
877}
878
879#[derive(Serialize)]
880struct WrittenProbe<'a> {
881    #[serde(skip_serializing_if = "Option::is_none")]
882    script: Option<&'a Path>,
883    #[serde(skip_serializing_if = "Option::is_none")]
884    args: Option<&'a [String]>,
885    #[serde(skip_serializing_if = "Option::is_none")]
886    shell: Option<&'a str>,
887    timeout_seconds: u64,
888}
889
890impl Serialize for ReleaseTarget {
891    fn serialize<S: serde::Serializer>(
892        &self,
893        serializer: S,
894    ) -> std::result::Result<S::Ok, S::Error> {
895        let written = WrittenTarget {
896            name: &self.name,
897            style: self.style(),
898            probe: self.probe().map(|probe| match probe {
899                Probe::Script {
900                    script,
901                    args,
902                    timeout_seconds,
903                } => WrittenProbe {
904                    script: Some(script),
905                    args: Some(args),
906                    shell: None,
907                    timeout_seconds: *timeout_seconds,
908                },
909                Probe::Shell {
910                    shell,
911                    timeout_seconds,
912                } => WrittenProbe {
913                    script: None,
914                    args: None,
915                    shell: Some(shell),
916                    timeout_seconds: *timeout_seconds,
917                },
918            }),
919            action: self.action(),
920            adoption_instructions: self.adoption_instructions.as_ref(),
921        };
922        written.serialize(serializer)
923    }
924}
925
926impl<'de> Deserialize<'de> for ReleaseTarget {
927    fn deserialize<D: serde::Deserializer<'de>>(
928        deserializer: D,
929    ) -> std::result::Result<Self, D::Error> {
930        let stored = StoredTarget::deserialize(deserializer)?;
931        ReleaseTarget::try_from(stored).map_err(serde::de::Error::custom)
932    }
933}
934
935impl TryFrom<StoredTarget> for ReleaseTarget {
936    type Error = String;
937
938    fn try_from(stored: StoredTarget) -> std::result::Result<Self, Self::Error> {
939        let name = stored.name;
940        let release = match stored.style {
941            ReleaseStyle::Automated => {
942                if stored.action.is_some() {
943                    return Err(format!(
944                        "the release target {name:?} declares style: automated and names an \
945                         action, which only a human-step target has — a machine releases an \
946                         automated target and its probe says what is out. Remove the action, or \
947                         declare style: human-step"
948                    ));
949                }
950                let probe = stored.probe.ok_or_else(|| {
951                    format!(
952                        "the release target {name:?} declares style: automated and names no \
953                         probe; an automated target is answered by running one. Give it a \
954                         `probe:` with either `script:` or `shell:`"
955                    )
956                })?;
957                ReleaseMethod::Automated {
958                    probe: probe.into_probe(&name)?,
959                }
960            }
961            ReleaseStyle::HumanStep => {
962                if stored.probe.is_some() {
963                    return Err(format!(
964                        "the release target {name:?} declares style: human-step and names a \
965                         probe, which only an automated target has — a human-step target has \
966                         nothing to ask, because the release happens when a person does \
967                         something. Remove the probe, or declare style: automated"
968                    ));
969                }
970                let action = stored.action.ok_or_else(|| {
971                    format!(
972                        "the release target {name:?} declares style: human-step and names no \
973                         action; the action is what a person has to do, and it is what the wait \
974                         renders. Give it an `action:`"
975                    )
976                })?;
977                if action.trim().is_empty() {
978                    return Err(format!(
979                        "the release target {name:?} has a blank action; it is what a person \
980                         reads to know what to do, so a blank one is a wait nobody can act on"
981                    ));
982                }
983                if !renders_on_one_line(&action) || action.chars().count() > MAX_ACTION {
984                    return Err(format!(
985                        "the release target {name:?} has an action that is not one printable \
986                         line of at most {MAX_ACTION} characters; it is rendered beside the wait \
987                         it explains, so it says what to do rather than how"
988                    ));
989                }
990                ReleaseMethod::HumanStep { action }
991            }
992        };
993        Ok(ReleaseTarget {
994            name,
995            release,
996            adoption_instructions: stored.adoption_instructions,
997        })
998    }
999}
1000
1001impl StoredProbe {
1002    /// The probe this spells, or the reason it spells none.
1003    fn into_probe(self, name: &TargetName) -> std::result::Result<Probe, String> {
1004        let timeout_seconds = match self.timeout_seconds {
1005            None => DEFAULT_PROBE_TIMEOUT_SECONDS,
1006            // Zero is refused rather than held: a bound that has already fired is not
1007            // a bound, and one no clock on this host can reach names a moment that
1008            // never arrives, which is the unbounded run the bound exists to prevent.
1009            Some(seconds)
1010                if seconds > 0
1011                    && Instant::now()
1012                        .checked_add(Duration::from_secs(seconds))
1013                        .is_some() =>
1014            {
1015                seconds
1016            }
1017            Some(seconds) => {
1018                return Err(format!(
1019                    "the release target {name:?} names timeout_seconds: {seconds}; a probe's \
1020                     bound must be above zero and short enough to be waited out from now"
1021                ))
1022            }
1023        };
1024        match (self.script, self.shell) {
1025            (Some(_), Some(_)) => Err(format!(
1026                "the release target {name:?} names both a script and a shell probe; a probe is \
1027                 one or the other. Keep the form the repository actually carries"
1028            )),
1029            (None, None) => Err(format!(
1030                "the release target {name:?} names neither a script nor a shell probe; an \
1031                 automated target is answered by running one of the two"
1032            )),
1033            (Some(script), None) => {
1034                relative_to_the_repository(&script, name)?;
1035                let args = self.args.unwrap_or_default();
1036                if let Some(argument) = args.iter().find(|it| !renders_on_one_line(it)) {
1037                    return Err(format!(
1038                        "the release target {name:?} names a probe argument carrying a control \
1039                         character ({argument:?}); an argument is printed wherever the probe is \
1040                         named, so it has to be the one line it renders as"
1041                    ));
1042                }
1043                Ok(Probe::Script {
1044                    script,
1045                    args,
1046                    timeout_seconds,
1047                })
1048            }
1049            (None, Some(shell)) => {
1050                if self.args.is_some() {
1051                    return Err(format!(
1052                        "the release target {name:?} names args beside a shell probe; a shell \
1053                         probe is one line that `sh` reads, so its arguments are written into \
1054                         it. Move them into the `shell:` line, or name a `script:` instead"
1055                    ));
1056                }
1057                if shell.trim().is_empty() {
1058                    return Err(format!(
1059                        "the release target {name:?} names a blank shell probe; there is nothing \
1060                         there to run"
1061                    ));
1062                }
1063                if !renders_on_one_line(&shell) {
1064                    return Err(format!(
1065                        "the release target {name:?} names a shell probe carrying a control \
1066                         character; this form is the one line `sh` reads, and it is printed \
1067                         wherever the probe is named. Put a script the repository carries behind \
1068                         a `script:` instead"
1069                    ));
1070                }
1071                Ok(Probe::Shell {
1072                    shell,
1073                    timeout_seconds,
1074                })
1075            }
1076        }
1077    }
1078}
1079
1080/// Refuse a script path that does not name a file inside the repository.
1081///
1082/// The form exists to run something the repository being released **carries**, so a
1083/// path that leaves the repository root is refused where the document is read
1084/// rather than resolved at the moment it would be executed.
1085fn relative_to_the_repository(script: &Path, name: &TargetName) -> std::result::Result<(), String> {
1086    if script.as_os_str().is_empty() {
1087        return Err(format!(
1088            "the release target {name:?} names an empty script path"
1089        ));
1090    }
1091    if script.is_absolute() {
1092        return Err(format!(
1093            "the release target {name:?} names the absolute script path {script}; a script probe \
1094             is a path relative to the repository root, because it runs what the repository being \
1095             released carries",
1096            script = script.display()
1097        ));
1098    }
1099    if script
1100        .components()
1101        .any(|component| matches!(component, std::path::Component::ParentDir))
1102    {
1103        return Err(format!(
1104            "the release target {name:?} names the script path {script}, which leaves the \
1105             repository root; a script probe runs what the repository being released carries",
1106            script = script.display()
1107        ));
1108    }
1109    Ok(())
1110}