Skip to main content

majordomus_cli/release/
model.rs

1//! The typed value every release surface answers with.
2//!
3//! Published through the capability registry, so these types share the one OpenAPI component
4//! namespace with every other module's: each carries a `Release`-prefixed schema name, the
5//! way `distribution` prefixes its own for exactly the same reason.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10/// The schema of the changelog document.
11pub const CHANGELOG_SCHEMA: &str = "majordomus/changelog/v1";
12
13/// What a conventional commit says it did.
14///
15/// The set is the one the repository's own commit convention uses; a commit whose subject
16/// does not parse is [`ChangeKind::Other`] and still appears, because a changelog that
17/// silently drops what it cannot classify is a changelog that lies by omission.
18#[derive(
19    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
20)]
21#[serde(rename_all = "snake_case")]
22#[schemars(rename = "ReleaseChangeKind")]
23pub enum ChangeKind {
24    /// A capability a person did not have before.
25    Feat,
26    /// Behaviour that was wrong and is not any more.
27    Fix,
28    /// Performance, with the behaviour unchanged.
29    Perf,
30    /// Structure, with the behaviour unchanged.
31    Refactor,
32    /// Documentation.
33    Docs,
34    /// Tests.
35    Test,
36    /// The build, the pipeline, the tooling.
37    Chore,
38    /// Continuous integration.
39    Ci,
40    /// Formatting, with the behaviour unchanged.
41    Style,
42    /// The build system or its dependencies.
43    Build,
44    /// A change that undoes another.
45    Revert,
46    /// An integration: one line of work brought into another.
47    ///
48    /// Not in the conventional-commit specification, and in this repository's history 38
49    /// times and counting, because integration here is a deliberate act with a subject
50    /// somebody writes — `merge: bring origin/master into feature/x` — and not the sentence
51    /// `git merge` composes. Declaring it is what lets the validator hold those subjects to
52    /// the same width and the same references as every other commit, instead of either
53    /// refusing a convention this project practises or exempting it from being checked at
54    /// all. The changelog never renders it: `commits::in_range` reads the log with
55    /// `--no-merges`, so a merge commit is not a release entry however its subject is
56    /// spelled.
57    Merge,
58    /// A commit whose subject does not parse as a conventional commit.
59    Other,
60}
61
62impl ChangeKind {
63    /// Every type word the convention defines, with the kind it names.
64    ///
65    /// One list, read in both directions: [`ChangeKind::parse`] maps a word to a kind, and
66    /// [`ChangeKind::WORDS`] is what a validator quotes back to somebody who typed a word
67    /// that is not one. A second copy of these eleven words in the validator is exactly the
68    /// duplication that lets a type added here go unmentioned there.
69    const TABLE: &'static [(&'static str, ChangeKind)] = &[
70        ("feat", ChangeKind::Feat),
71        ("fix", ChangeKind::Fix),
72        ("perf", ChangeKind::Perf),
73        ("refactor", ChangeKind::Refactor),
74        ("docs", ChangeKind::Docs),
75        ("test", ChangeKind::Test),
76        ("chore", ChangeKind::Chore),
77        ("ci", ChangeKind::Ci),
78        ("style", ChangeKind::Style),
79        ("build", ChangeKind::Build),
80        ("revert", ChangeKind::Revert),
81        ("merge", ChangeKind::Merge),
82    ];
83
84    /// The type words, in the order `Self::TABLE` declares them.
85    ///
86    /// ```
87    /// use majordomus_cli::release::ChangeKind;
88    /// assert_eq!(ChangeKind::WORDS.first(), Some(&"feat"));
89    /// assert!(ChangeKind::WORDS.iter().all(|w| ChangeKind::parse(w) != ChangeKind::Other));
90    /// ```
91    pub const WORDS: &'static [&'static str] = &[
92        "feat", "fix", "perf", "refactor", "docs", "test", "chore", "ci", "style", "build",
93        "revert", "merge",
94    ];
95
96    /// The kind one conventional-commit type word names.
97    pub fn parse(word: &str) -> ChangeKind {
98        ChangeKind::TABLE
99            .iter()
100            .find(|(w, _)| *w == word)
101            .map(|(_, k)| *k)
102            .unwrap_or(ChangeKind::Other)
103    }
104
105    /// The word this kind is written as, or `None` for [`ChangeKind::Other`], which is not
106    /// a type anybody writes.
107    ///
108    /// The inverse of [`ChangeKind::parse`] over the one table, so the two cannot disagree
109    /// about which words exist:
110    ///
111    /// ```
112    /// use majordomus_cli::release::model::ChangeKind;
113    ///
114    /// assert_eq!(ChangeKind::parse("feat").word(), Some("feat"));
115    /// assert_eq!(ChangeKind::Perf.word(), Some("perf"));
116    ///
117    /// // `Other` is what an unknown word parses to, and it is not itself a word: a
118    /// // validator quoting the vocabulary back must not offer it as something to type.
119    /// assert_eq!(ChangeKind::parse("nonsense"), ChangeKind::Other);
120    /// assert_eq!(ChangeKind::Other.word(), None);
121    ///
122    /// // every word the vocabulary advertises round-trips through both directions
123    /// for word in ChangeKind::WORDS {
124    ///     assert_eq!(ChangeKind::parse(word).word(), Some(*word), "{word} round-trips");
125    /// }
126    /// ```
127    pub fn word(self) -> Option<&'static str> {
128        ChangeKind::TABLE
129            .iter()
130            .find(|(_, k)| *k == self)
131            .map(|(w, _)| *w)
132    }
133
134    /// The heading this kind is rendered under, and the order the headings appear in.
135    ///
136    /// Ordered by what a reader of a changelog came for: what is new, what is fixed, what is
137    /// faster, then the rest. Nothing is hidden — a section with no entries is simply absent.
138    pub fn heading(self) -> &'static str {
139        match self {
140            ChangeKind::Feat => "Added",
141            ChangeKind::Fix => "Fixed",
142            ChangeKind::Perf => "Performance",
143            ChangeKind::Refactor => "Changed",
144            ChangeKind::Docs => "Documentation",
145            ChangeKind::Test => "Tests",
146            ChangeKind::Ci => "Pipeline",
147            ChangeKind::Chore => "Housekeeping",
148            ChangeKind::Style => "Formatting",
149            ChangeKind::Build => "Build",
150            ChangeKind::Revert => "Reverted",
151            ChangeKind::Merge => "Integration",
152            ChangeKind::Other => "Other",
153        }
154    }
155
156    /// Where the heading sorts.
157    pub fn rank(self) -> u8 {
158        match self {
159            ChangeKind::Feat => 0,
160            ChangeKind::Fix => 1,
161            ChangeKind::Perf => 2,
162            ChangeKind::Refactor => 3,
163            ChangeKind::Docs => 4,
164            ChangeKind::Test => 5,
165            ChangeKind::Ci => 6,
166            ChangeKind::Chore => 7,
167            // A revert is what a reader most needs to see and least expects, so it sits with
168            // the fixes rather than at the bottom with the housekeeping.
169            ChangeKind::Revert => 2,
170            ChangeKind::Build => 6,
171            ChangeKind::Style => 7,
172            ChangeKind::Merge => 8,
173            ChangeKind::Other => 8,
174        }
175    }
176}
177
178/// A record of the layer that a commit names in its own text.
179///
180/// Inferred, never declared beside the commit: an issue id or a milestone id appearing in a
181/// subject or a body is a reference, and the layer already holds the object it refers to. A
182/// reference to something the layer does not have is not carried — a link to a record that
183/// does not exist is worse than no link, because the reader cannot tell until they follow it.
184///
185/// ```
186/// use majordomus_cli::release::model::Reference;
187/// let r = Reference {
188///     kind: "issue".into(),
189///     id: "I1305".into(),
190///     title: "An observed contract has a fingerprint".into(),
191///     route: Some("/plan/i1305/".into()),
192/// };
193/// let json = serde_json::to_value(&r).unwrap();
194/// assert_eq!(json["id"], "I1305");
195/// assert_eq!(json["route"], "/plan/i1305/");
196/// // a reference the site has no page for carries no route rather than an invented one
197/// let bare = Reference { route: None, ..r };
198/// assert!(serde_json::to_value(&bare).unwrap().get("route").is_none());
199/// ```
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
201#[schemars(rename = "ReleaseReference")]
202pub struct Reference {
203    /// `issue` or `milestone`.
204    pub kind: String,
205    /// `I1305`, `M000`.
206    pub id: String,
207    /// What it is, from the record itself.
208    pub title: String,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    /// Where it is published on this site, when it has a page.
211    pub route: Option<String>,
212}
213
214/// One change, from one commit.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
216#[schemars(rename = "ReleaseChange")]
217pub struct Change {
218    /// What it did.
219    pub kind: ChangeKind,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    /// The area it did it in, when the subject named one: `commands`, `ci`, `site`.
222    pub scope: Option<String>,
223    /// The subject, without the type and scope that prefixed it.
224    pub subject: String,
225    /// Whether the commit marked itself breaking, with `!` or a `BREAKING CHANGE:` trailer.
226    pub breaking: bool,
227    /// The abbreviated commit.
228    pub commit: String,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    /// Where that commit can be read, when the repository's own URL is known. Derived from
231    /// `about::REPOSITORY`, never written beside each entry.
232    pub url: Option<String>,
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    /// The records of the layer this commit names — issues, milestones — resolved against
235    /// what the layer actually holds.
236    pub references: Vec<Reference>,
237}
238
239/// One decision, as the layer's own ADR object states it.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
241#[schemars(rename = "ReleaseDecision")]
242pub struct Decision {
243    /// `adr-0027`.
244    pub id: String,
245    /// The decision, in its own words.
246    pub title: String,
247    /// `proposed`, `accepted`, `superseded`.
248    pub status: String,
249    /// The date the record carries.
250    pub date: String,
251    #[serde(skip_serializing_if = "Option::is_none")]
252    /// Where the decision itself can be read: the file in the repository. The site has no
253    /// per-ADR page, so this is the honest destination rather than an invented route.
254    pub url: Option<String>,
255    #[serde(skip_serializing_if = "Option::is_none")]
256    /// The commit that added the file, which is what decides the release it belongs to. The
257    /// front-matter date says when the decision was made; those are different questions.
258    pub added: Option<String>,
259}
260
261/// One published artifact, from the release record's own evidence.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
263#[schemars(rename = "ReleaseChangelogArtifact")]
264pub struct Artifact {
265    /// The platform triple the record names.
266    pub target: String,
267    /// The file name.
268    pub name: String,
269    /// Its SHA-256, as the record read it off the file.
270    pub sha256: String,
271}
272
273/// The changes of one kind, with the heading and the rank that decide where they are shown.
274///
275/// The grouping is in the document rather than in each renderer. It was in the Markdown
276/// renderer alone, and the site — which cannot see a Rust function — grouped alphabetically
277/// instead, so the same changelog read in two orders depending on which surface showed it.
278/// A presentation order stated once and carried is the only kind that survives a projection.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
280#[schemars(rename = "ReleaseChangeGroup")]
281pub struct ChangeGroup {
282    /// What the changes in it did.
283    pub kind: ChangeKind,
284    /// The heading it is shown under.
285    pub heading: String,
286    /// Where it sorts among the other groups; ascending.
287    pub rank: u8,
288    /// The changes, in the order the commits were read.
289    pub changes: Vec<Change>,
290}
291
292/// One version's worth of changelog.
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
294#[schemars(rename = "ReleaseSection")]
295pub struct ReleaseSection {
296    /// The version, or `unreleased`.
297    pub version: String,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    /// The tag, when one was published.
300    pub tag: Option<String>,
301    #[serde(skip_serializing_if = "Option::is_none")]
302    /// When it was published, from the record.
303    pub date: Option<String>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    /// The commit it was published from.
306    pub commit: Option<String>,
307    /// Whether this section is the work that has not been released.
308    pub unreleased: bool,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    /// The release notes the record names — the published release itself.
311    pub notes_url: Option<String>,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    /// Every commit between the previous release and this one, as the forge renders it.
314    pub compare_url: Option<String>,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    /// The tree at this release.
317    pub tree_url: Option<String>,
318    /// The decisions dated inside this release's window.
319    pub decisions: Vec<Decision>,
320    /// The changes, from the commits in this release's range, grouped by what they did and
321    /// ordered by the rank each group carries. The flat list is the concatenation of the
322    /// groups' own; nothing holds it twice.
323    pub groups: Vec<ChangeGroup>,
324    /// What was published, when this section is a release.
325    pub artifacts: Vec<Artifact>,
326}
327
328/// Which capability answered with this document, and where that capability is projected.
329///
330/// The document carries its own provenance so that a reader — a person on the site, a client
331/// over MCP — can find the same value elsewhere without anything having to enumerate the
332/// routes. A page that listed them would be declaring them a second time, which is the
333/// failure `site-check`'s `registry` and `cli` assertions exist to catch; it caught this one.
334///
335/// ```
336/// use majordomus_cli::release::model::ProducedBy;
337/// let by = ProducedBy {
338///     capability: "release.changelog".into(),
339///     cli: Some("majordomus release changelog".into()),
340///     http: Some("/api/v1/changelog".into()),
341///     mcp_tool: None,
342///     mcp_resource: Some("majordomus://changelog".into()),
343/// };
344/// let json = serde_json::to_value(&by).unwrap();
345/// assert_eq!(json["capability"], "release.changelog");
346/// // a surface the capability is not projected on is absent, not null
347/// assert!(json.get("mcp_tool").is_none());
348/// assert_eq!(ProducedBy::default().capability, "");
349/// ```
350#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
351#[schemars(rename = "ReleaseProducedBy")]
352pub struct ProducedBy {
353    /// The capability id.
354    pub capability: String,
355    #[serde(skip_serializing_if = "Option::is_none")]
356    /// The command line that renders it.
357    pub cli: Option<String>,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    /// The HTTP route that answers with it.
360    pub http: Option<String>,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    /// The MCP tool.
363    pub mcp_tool: Option<String>,
364    #[serde(skip_serializing_if = "Option::is_none")]
365    /// The MCP resource URI.
366    pub mcp_resource: Option<String>,
367}
368
369/// The whole changelog.
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
371#[schemars(rename = "ReleaseChangelog")]
372pub struct Changelog {
373    /// The schema this document satisfies.
374    pub schema: String,
375    /// The version the tree currently declares.
376    pub current: String,
377    /// Newest first, the unreleased section leading when there is one.
378    pub sections: Vec<ReleaseSection>,
379    /// What could not be read, said rather than hidden: a repository with no git history,
380    /// a release record that names no commit, a tag that is not in this clone.
381    pub diagnostics: Vec<String>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    /// Which capability answered, and where else the same value can be had.
384    pub produced_by: Option<ProducedBy>,
385}
386
387/// Who answered a version report's `next`: the public contract, the contract with the
388/// commits — or no one, because the contract could not be measured.
389///
390/// ADR 0051 makes the contract the authority and demotes conventional commits to evidence,
391/// so [`DecidedBy::Contract`] is the normal answer. An unmeasurable contract is refused, not
392/// guessed: [`DecidedBy::Undecided`] leaves `next` absent and carries the reason, and the
393/// commit inference stays what it always is, evidence — never the answer in its place.
394///
395/// ```
396/// use majordomus_cli::release::model::DecidedBy;
397/// assert_eq!(serde_json::to_value(DecidedBy::Contract).unwrap(), "contract");
398/// assert_eq!(DecidedBy::Undecided.as_str(), "undecided");
399/// assert_eq!(DecidedBy::default(), DecidedBy::Undecided);
400/// ```
401#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
402#[serde(rename_all = "snake_case")]
403#[schemars(rename = "ReleaseVersionDecidedBy")]
404pub enum DecidedBy {
405    /// `release analyze` measured the public contract against the last release, and `next`
406    /// is the version it requires: the declared version when that already satisfies the
407    /// contract, otherwise the smallest one it allows.
408    Contract,
409    /// The contract was measured and requires no release over the last one — only behaviour
410    /// behind the public boundary changed — while the commits carry changes: `next` is the
411    /// smallest release above the last, a patch, however much the commit subjects claim.
412    ContractAndCommits,
413    /// The contract could not be measured — no published baseline carries a registry, or a
414    /// version is not three numbers — so nothing decides `next`: it is absent, the reason is
415    /// in `contract_unreadable`, and `release bump` without an explicit target refuses.
416    #[default]
417    Undecided,
418}
419
420impl DecidedBy {
421    /// The word the JSON value carries for who answered.
422    ///
423    /// ```
424    /// use majordomus_cli::release::model::DecidedBy;
425    /// for by in [DecidedBy::Contract, DecidedBy::ContractAndCommits, DecidedBy::Undecided] {
426    ///     assert_eq!(serde_json::to_value(by).unwrap(), by.as_str());
427    /// }
428    /// ```
429    pub fn as_str(self) -> &'static str {
430        match self {
431            DecidedBy::Contract => "contract",
432            DecidedBy::ContractAndCommits => "contract_and_commits",
433            DecidedBy::Undecided => "undecided",
434        }
435    }
436
437    /// Who answered, as the text renderings print it in parentheses after `next`.
438    ///
439    /// ```
440    /// use majordomus_cli::release::model::DecidedBy;
441    /// assert_eq!(DecidedBy::Contract.phrase(), "decided by the contract");
442    /// let both = DecidedBy::ContractAndCommits.phrase();
443    /// assert_eq!(both, "decided by the contract and the commits");
444    /// let none = DecidedBy::Undecided.phrase();
445    /// assert_eq!(none, "undecided: the contract could not be measured");
446    /// ```
447    pub fn phrase(self) -> &'static str {
448        match self {
449            DecidedBy::Contract => "decided by the contract",
450            DecidedBy::ContractAndCommits => "decided by the contract and the commits",
451            DecidedBy::Undecided => "undecided: the contract could not be measured",
452        }
453    }
454}
455
456/// What the version is, what the public contract requires it to become, and what the
457/// commits since the last release say about themselves.
458///
459/// `next` is the contract's answer (ADR 0051): the analysis' `required_version`, or the
460/// smallest release above the last when that requirement is the last release itself. The
461/// commit inference is evidence — `bump` and `commits_imply` — and never the answer: when the
462/// contract cannot be measured `next` is absent, `decided_by` is `undecided` and
463/// `contract_unreadable` says why. The selection is [`crate::release::version::select`], the
464/// one function `release bump` also takes its default target from.
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
466#[schemars(rename = "ReleaseVersionReport")]
467pub struct VersionReport {
468    /// The version the crate manifest declares — the one place it is authored.
469    pub declared: String,
470    /// The version `bin/majordomus` prints: what its projection, `share/version.txt`, states.
471    pub tool: String,
472    /// Whether that projection is current. `generate --check` refuses a stale one and
473    /// `scripts/release-version --check` proves the tool prints it; this is the same question
474    /// asked by the executable, so every surface can show the answer.
475    pub agree: bool,
476    #[serde(skip_serializing_if = "Option::is_none")]
477    /// The last release: the highest version the layer records (the newest version tag when
478    /// it records none) — the baseline `release analyze` measures the contract from, so the
479    /// contract and the commit evidence describe one window.
480    pub last_release: Option<String>,
481    /// What the commit subjects since it imply: `major`, `minor`, `patch`, or `none`.
482    /// Evidence only — the contract decides `next`; `release analyze` reports where the two
483    /// disagree.
484    pub bump: String,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    /// The version that bump would produce from the last release (from the declared version
487    /// when nothing was released yet). Evidence only, even when the contract cannot answer.
488    pub commits_imply: Option<String>,
489    #[serde(skip_serializing_if = "Option::is_none")]
490    /// The next version. When the contract could be measured, the version it requires: the
491    /// declared version when that already satisfies it, otherwise the smallest one it allows
492    /// — and, when that is the last release itself, the smallest release above it. Absent
493    /// when nothing would be released, and when the contract could not be measured
494    /// (`decided_by: undecided`): an unmeasurable baseline is refused, not guessed.
495    pub next: Option<String>,
496    #[serde(default)]
497    /// Who answered `next`.
498    pub decided_by: DecidedBy,
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    /// Why the contract could not answer, when `decided_by` is `undecided`.
501    pub contract_unreadable: Option<String>,
502    /// How many commits since the last release, and of what kind — the evidence for the
503    /// bump, so that a surprising answer can be checked rather than believed.
504    pub changes: Vec<Change>,
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn the_type_vocabulary_reads_the_same_in_both_directions() {
513        // `WORDS` is what a validator quotes and `TABLE` is what the parser reads. They are
514        // two spellings of one list, and this is the assertion that keeps them one: a type
515        // added to the table and forgotten in the words, or the reverse, fails here rather
516        // than in a validator that quietly stops mentioning it.
517        let from_table: Vec<&str> = ChangeKind::TABLE.iter().map(|(w, _)| *w).collect();
518        assert_eq!(from_table, ChangeKind::WORDS);
519        for word in ChangeKind::WORDS {
520            let kind = ChangeKind::parse(word);
521            assert_ne!(kind, ChangeKind::Other, "{word} parses to nothing");
522            assert_eq!(kind.word(), Some(*word), "{word} does not render back");
523        }
524        // every kind but `Other` is reachable from a word, so no variant is orphaned
525        assert_eq!(ChangeKind::TABLE.len(), ChangeKind::WORDS.len());
526        assert_eq!(ChangeKind::Other.word(), None);
527        assert_eq!(ChangeKind::parse("wip"), ChangeKind::Other);
528    }
529
530    /// Who decided `next` is one word on the wire and one phrase in the text renderings, and
531    /// the word is the serialization itself: a variant renamed in one place and not the
532    /// other would make the JSON and the terminal name different deciders.
533    #[test]
534    fn who_decided_reads_the_same_on_the_wire_and_in_the_text() {
535        let all = [
536            DecidedBy::Contract,
537            DecidedBy::ContractAndCommits,
538            DecidedBy::Undecided,
539        ];
540        for by in all {
541            assert_eq!(serde_json::to_value(by).unwrap(), by.as_str());
542            let back: DecidedBy = serde_json::from_value(by.as_str().into()).unwrap();
543            assert_eq!(back, by);
544        }
545        assert_eq!(
546            all.map(DecidedBy::as_str),
547            ["contract", "contract_and_commits", "undecided"]
548        );
549        assert_eq!(DecidedBy::Contract.phrase(), "decided by the contract");
550        assert_eq!(
551            DecidedBy::ContractAndCommits.phrase(),
552            "decided by the contract and the commits"
553        );
554        // the undecided phrase says why nothing was decided, never a version's provenance
555        assert_eq!(
556            DecidedBy::Undecided.phrase(),
557            "undecided: the contract could not be measured"
558        );
559        // the absent field is the undecided one: an answer that names no decider decided nothing
560        assert_eq!(DecidedBy::default(), DecidedBy::Undecided);
561    }
562}