Skip to main content

majordomus_cli/environment/
mod.rs

1//! The repository environment: one typed snapshot of what this checkout is right now —
2//! the project, the repository, version control, the toolchains it declares, what the
3//! layer holds, the workflows a person can run, the provider projections and the local
4//! services — with a provenance entry for every fact in it.
5//!
6//! It is the canonical source for every surface that says any of those things. The direnv
7//! banner, `majordomus env`, `GET /api/v1/environment`, the MCP resource
8//! `majordomus://environment`, the Cockpit's overview and the documentation are
9//! renderings of this one value; none of them discovers anything of its own. That is the
10//! whole point: before it existed, "what branch is this and what is running" was a shell
11//! pipeline in whoever's dotfiles, and a second one in the next surface that wanted it.
12//!
13//! # Two resolutions, one model
14//!
15//! Building the index costs seconds, and the banner runs on every `cd`. So a snapshot is
16//! resolved in one of two modes ([`Resolution`]), and both produce the same type:
17//!
18//! - [`Resolution::Full`] reads everything, including the index, and writes the cache.
19//! - [`Resolution::Fast`] reads only what is cheap — one `git` call, one `just` call, a
20//!   few file reads — and takes the rest from the cache written by the last full
21//!   resolution. What no cache can supply is reported as unknown, never guessed and never
22//!   defaulted: a count that is not a count is worse than no count.
23//!
24//! The modes differ in what they may read, never in what they mean. `fast_and_full_agree`
25//! holds them to that.
26//!
27//! # What is canonical, and what is derived
28//!
29//! | Fact | Canonical source |
30//! |---|---|
31//! | project name, version, licence, summary | the crate manifest and [`crate::about`], at compile time |
32//! | repository root, layer sections | `.ai/manifest.yaml`, through [`crate::repository::Repository`] |
33//! | version control | one `git status --porcelain=v2 --branch` |
34//! | toolchains | the manifest that declares each one |
35//! | what the layer holds | the index, through the capability registry |
36//! | workflows | `just --dump --dump-format json` |
37//! | provider projections | the policy's `projections[]` |
38//! | services | the capability registry's routes and the shared server's lease |
39//!
40//! Nothing in this module writes to the repository outside `.ai/local/`, and nothing in it
41//! opens a socket to anything but the loopback address the lease names.
42
43pub mod cache;
44pub mod preflight;
45pub mod probe;
46pub mod render;
47pub mod resolve;
48pub mod services;
49pub mod shell;
50pub mod text;
51pub mod toolchain;
52pub mod vcs;
53pub mod workflows;
54
55use std::collections::BTreeMap;
56
57use schemars::JsonSchema;
58use serde::{Deserialize, Serialize};
59
60use crate::model::Diagnostic;
61
62pub use cache::{Cache, CachedTier};
63pub use resolve::{resolve, EnvironmentQuery, Inputs};
64pub use vcs::{GitWorkingTree, VcsState};
65
66/// The schema identity of [`RepositoryEnvironment`]. It travels in the snapshot itself, so
67/// a document read from a cache, an API or a file says which contract it was written
68/// under; [`SCHEMA_VERSION`] rises when a reader of the old shape would misread the new
69/// one.
70pub const SCHEMA: &str = "majordomus/repository-environment";
71
72/// The version of [`SCHEMA`]. A cache entry written under a different version is not read.
73pub const SCHEMA_VERSION: u32 = 1;
74
75/// How completely a snapshot was resolved. Carried in the snapshot because a consumer must
76/// be able to tell "there is no server running" from "nobody looked".
77#[derive(
78    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
79)]
80#[serde(rename_all = "snake_case")]
81pub enum Resolution {
82    /// Only what is cheap enough for a shell prompt; the rest from the cache.
83    Fast,
84    /// Everything, including the index. Writes the cache.
85    Full,
86}
87
88impl Resolution {
89    /// The word as serialised.
90    ///
91    /// ```
92    /// use majordomus_cli::environment::Resolution;
93    /// assert_eq!(Resolution::Fast.as_str(), "fast");
94    /// ```
95    pub fn as_str(self) -> &'static str {
96        match self {
97            Resolution::Fast => "fast",
98            Resolution::Full => "full",
99        }
100    }
101}
102
103/// Where a tier of the snapshot came from. A tier the active resolution cannot reach is
104/// [`TierState::Unavailable`] with the reason, and its values are absent rather than zero.
105#[derive(
106    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
107)]
108#[serde(rename_all = "snake_case")]
109pub enum TierState {
110    /// Read from its canonical source during this resolution.
111    Resolved,
112    /// Taken from the cache written by an earlier full resolution.
113    Cached,
114    /// Neither available; the values are absent and the reason is a diagnostic.
115    Unavailable,
116}
117
118impl TierState {
119    /// Did the tier produce values?
120    pub fn is_known(self) -> bool {
121        !matches!(self, TierState::Unavailable)
122    }
123
124    /// The word as serialised.
125    pub fn as_str(self) -> &'static str {
126        match self {
127            TierState::Resolved => "resolved",
128            TierState::Cached => "cached",
129            TierState::Unavailable => "unavailable",
130        }
131    }
132}
133
134/// What the project is, from the crate manifest and the prose written once in
135/// [`crate::about`]. Every field here is a compile-time constant of this executable: there
136/// is no file to read, nothing to parse, and nothing that can disagree with the binary
137/// that answers.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139pub struct ProjectIdentity {
140    /// The product name.
141    pub name: String,
142    /// The version of this executable, from the crate manifest.
143    pub version: String,
144    /// One sentence: what this is.
145    pub summary: String,
146    /// The SPDX licence identifier.
147    pub license: String,
148    /// The source repository.
149    pub repository: String,
150    /// The Rust target triple this executable was built for.
151    pub target: String,
152    /// The cargo profile it was built with.
153    pub profile: String,
154    /// The commit it was built from, or `unknown` outside a work tree.
155    pub commit: String,
156}
157
158impl ProjectIdentity {
159    /// The identity of this executable. Reads nothing.
160    pub fn of_this_build() -> Self {
161        ProjectIdentity {
162            name: crate::about::NAME.into(),
163            version: crate::VERSION.into(),
164            summary: crate::about::SUMMARY.into(),
165            license: crate::about::LICENSE.into(),
166            repository: crate::about::REPOSITORY.into(),
167            target: crate::TARGET.into(),
168            profile: crate::PROFILE.into(),
169            commit: crate::COMMIT.into(),
170        }
171    }
172}
173
174/// The checkout this snapshot is of.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
176pub struct RepositoryIdentity {
177    /// The name a person calls it: the base name of the root directory.
178    pub name: String,
179    /// The root, absolute. The same value `repository.info` reports.
180    pub root: String,
181    /// The manifest's `schema`, `ai-repository/v1`.
182    pub layer_schema: String,
183    /// Section name to repository-relative path, as the manifest declares them.
184    pub sections: BTreeMap<String, String>,
185    /// The checkout-local half of the layer, repository-relative. Never tracked, and
186    /// where anything this checkout alone knows — the server's lease, this snapshot's
187    /// cache — is kept.
188    pub local_path: String,
189    /// Whether this checkout is a linked work tree rather than the main one.
190    pub linked_worktree: bool,
191}
192
193/// A toolchain the repository declares, and what is installed for it. The three are kept
194/// apart on purpose: a repository can declare a version nobody has, and a machine can have
195/// a version no repository asked for, and reporting either as the other is how a version
196/// mismatch stays invisible for a week.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
198pub struct ToolchainState {
199    /// A stable id, `[a-z][a-z0-9-]*`: `rust`, `node`.
200    pub id: String,
201    /// The short name a person reads.
202    pub title: String,
203    /// The version the repository declares, when it declares one.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub declared: Option<String>,
206    /// The repository-relative file that declares it.
207    pub declared_by: String,
208    /// The version installed here, when it could be asked and the answer is not stale.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub installed: Option<String>,
211    /// Where the installed version stands.
212    pub availability: ToolchainAvailability,
213}
214
215/// Whether a declared toolchain is usable here.
216#[derive(
217    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
218)]
219#[serde(rename_all = "snake_case")]
220pub enum ToolchainAvailability {
221    /// Installed, and its version answered.
222    Installed,
223    /// The executable is not on the path.
224    Missing,
225    /// Nobody asked: this resolution may not run a subprocess for it, and no cache held it.
226    Unknown,
227    /// Installed and answering, and its version does not satisfy the one the repository
228    /// declares. Reported as itself rather than as installed, because a binary that is on
229    /// the path and answers the wrong version is the case a person otherwise debugs for an
230    /// hour (ADR 0064). Decided only when both versions are plain dotted numbers.
231    Mismatch,
232}
233
234/// What the layer holds, counted per kind, plus the registry the executable composes.
235/// Every number here comes from the index — the same objects every other surface serves —
236/// and never from counting files that match a pattern: a file that does not parse is not
237/// a rule, and a count that says otherwise is a lie a person acts on.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
239pub struct LayerSummary {
240    /// Where these numbers came from.
241    pub state: TierState,
242    /// One entry per kind present, sorted by kind.
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    pub kinds: Vec<KindCount>,
245    /// How many objects the index holds.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub objects: Option<usize>,
248    /// How many capabilities the registry holds.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub capabilities: Option<usize>,
251    /// How many files the layer declared that did not become objects.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub invalid: Option<usize>,
254    /// Which ones, and what decided it: the index's own error diagnostics, each naming the
255    /// file, the code and the constraint it failed. A count says something is wrong; only
256    /// this says what and where, and a reader that has the count and not the list has to go
257    /// looking through a log for the answer the index already produced.
258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
259    pub refused: Vec<crate::model::Diagnostic>,
260    /// Whether the layer read cleanly.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub degraded: Option<bool>,
263}
264
265impl LayerSummary {
266    /// A summary nothing could resolve.
267    pub fn unavailable() -> Self {
268        LayerSummary {
269            state: TierState::Unavailable,
270            kinds: Vec::new(),
271            objects: None,
272            capabilities: None,
273            invalid: None,
274            refused: Vec::new(),
275            degraded: None,
276        }
277    }
278
279    /// The count of one kind, when the kind is present.
280    pub fn kind(&self, id: &str) -> Option<usize> {
281        self.kinds.iter().find(|k| k.kind == id).map(|k| k.count)
282    }
283}
284
285/// How many valid objects of one kind the layer holds.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
287pub struct KindCount {
288    /// The kind, as `share/kinds.yaml` declares it.
289    pub kind: String,
290    /// How many objects of it the index holds.
291    pub count: usize,
292}
293
294// The kinds line ranks by count, largest first, and the workflow list reads by name. Both
295// end on an identity, so neither depends on the order its source produced.
296impl crate::order::Ordered for KindCount {
297    fn order_key(&self) -> crate::order::OrderKey<'_> {
298        // Negated: the canonical rank ascends, and this line wants the largest first.
299        crate::order::OrderKey::plain(&self.kind, &self.kind).ranked(-(self.count as i64))
300    }
301}
302
303/// The workflows a person can run here, as the workflow runner itself describes them.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305pub struct WorkflowCatalogue {
306    /// Where these came from.
307    pub state: TierState,
308    /// The command that produced them, when one did.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub source: Option<String>,
311    /// Every public workflow, sorted by name.
312    #[serde(default, skip_serializing_if = "Vec::is_empty")]
313    pub workflows: Vec<WorkflowDescriptor>,
314    /// The entry point of each group, in the order the groups are declared: what a person
315    /// new to the repository runs first. Derived, never listed anywhere.
316    #[serde(default, skip_serializing_if = "Vec::is_empty")]
317    pub entrypoints: Vec<WorkflowEntrypoint>,
318}
319
320impl WorkflowCatalogue {
321    /// A catalogue nothing could resolve.
322    pub fn unavailable() -> Self {
323        WorkflowCatalogue {
324            state: TierState::Unavailable,
325            source: None,
326            workflows: Vec::new(),
327            entrypoints: Vec::new(),
328        }
329    }
330}
331
332/// One workflow: a recipe of the repository's `justfile`, as `just` describes it.
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
334pub struct WorkflowDescriptor {
335    /// The name a person types after `just`.
336    pub name: String,
337    /// The module path, for a recipe in an imported module; empty at the root.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub namespace: Option<String>,
340    /// The recipe's doc comment, when it has one.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub description: Option<String>,
343    /// The `[group(...)]` it belongs to, when it declares one.
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub group: Option<String>,
346    /// Its parameters, in declaration order.
347    #[serde(default, skip_serializing_if = "Vec::is_empty")]
348    pub parameters: Vec<WorkflowParameter>,
349    /// The recipes it runs first, in order.
350    #[serde(default, skip_serializing_if = "Vec::is_empty")]
351    pub dependencies: Vec<String>,
352    /// Whether it asks before it runs (`[confirm]`).
353    pub confirm: bool,
354}
355
356// By the name a person types. The namespace is part of the identity rather than a group:
357// `just build` and `just site::build` are two recipes, and the list is read as one.
358impl crate::order::Ordered for WorkflowDescriptor {
359    fn order_key(&self) -> crate::order::OrderKey<'_> {
360        crate::order::OrderKey::plain(&self.name, self.namespace.as_deref().unwrap_or(&self.name))
361    }
362}
363
364/// One parameter of a workflow.
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
366pub struct WorkflowParameter {
367    /// The name.
368    pub name: String,
369    /// Whether it may be repeated (`*args`, `+args`).
370    pub variadic: bool,
371    /// Whether it must be given.
372    pub required: bool,
373}
374
375/// The entry point of one group of workflows: what the group is, and the one workflow that
376/// stands for it.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
378pub struct WorkflowEntrypoint {
379    /// The group, as the justfile declares it.
380    pub group: String,
381    /// The workflow that stands for the group.
382    pub workflow: String,
383    /// The command a person types.
384    pub command: String,
385    /// The workflow's description, when it has one.
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub description: Option<String>,
388}
389
390/// Reasoning as the environment reports it: a summary of `reasoning.advisors`, from the
391/// same derivation, never a provider list of its own.
392///
393/// ```
394/// use majordomus_cli::environment::ReasoningSummary;
395///
396/// let summary: ReasoningSummary = serde_json::from_value(serde_json::json!({
397///     "operational": true, "mode": "offline", "available": ["local"], "unavailable": 2,
398/// })).unwrap();
399/// assert!(summary.operational);
400/// assert_eq!(summary.available, ["local"]);
401/// assert_eq!(serde_json::to_value(&summary).unwrap()["unavailable"], 2);
402/// ```
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
404pub struct ReasoningSummary {
405    /// Reasoning works with no advisor at all; always `true`.
406    pub operational: bool,
407    /// The mode in force: `offline`, `ci`, `fast`, `standard`, `strict`.
408    pub mode: String,
409    /// The advisors that may be asked now, in preference order.
410    pub available: Vec<String>,
411    /// How many declared advisors may not, each optional.
412    pub unavailable: usize,
413}
414
415/// One provider projection the policy declares, and whether the file on disk still matches
416/// what the policy renders.
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
418pub struct ProviderState {
419    /// The provider id, as the policy names it: `agents`, `claude-code`.
420    pub id: String,
421    /// The repository-relative file it renders to.
422    pub target: String,
423    /// Whether the file matches what the policy renders now.
424    pub state: ProjectionState,
425    /// Whether the provider loads this file into every context.
426    pub always_loaded: bool,
427}
428
429/// Where one provider projection stands against its policy.
430#[derive(
431    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
432)]
433#[serde(rename_all = "snake_case")]
434pub enum ProjectionState {
435    /// The file matches the rendering byte for byte.
436    Current,
437    /// The file differs: it was hand-edited, or the policy moved under it.
438    Stale,
439    /// The file is not there.
440    Absent,
441    /// It could not be rendered, so nothing can be said.
442    Unknown,
443}
444
445/// One local service of this repository: what it is, where it is, and whether anything
446/// answers there. The path is the one the router serves; the URL exists only while a
447/// server does.
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
449pub struct ServiceState {
450    /// A stable id, `[a-z][a-z0-9-]*`.
451    pub id: String,
452    /// The short name a person reads.
453    pub title: String,
454    /// The absolute path the router serves it under.
455    pub path: String,
456    /// The full URL, when a server is running and published its address.
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub url: Option<String>,
459    /// Whether anything answers there.
460    pub availability: ServiceAvailability,
461}
462
463/// Whether a service answers. `Unknown` is a real answer and never a disguised `no`: a
464/// probe that timed out and a port that refused the connection lead a reader to different
465/// actions.
466#[derive(
467    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
468)]
469#[serde(rename_all = "snake_case")]
470pub enum ServiceAvailability {
471    /// The address accepted a connection.
472    Available,
473    /// No server holds the repository's lease, or the address refused the connection.
474    NotRunning,
475    /// The probe did not finish in its budget, or this resolution did not probe.
476    Unknown,
477}
478
479/// Where one field of the snapshot came from. This is what makes an inferred system
480/// debuggable: every fact can name the thing that decided it, so "why does it say that"
481/// is answered by the tool rather than by reading its source.
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
483pub struct FieldSource {
484    /// The field, in dotted form: `services.cockpit.url`, `vcs.branch`.
485    pub field: String,
486    /// The value as it appears in the snapshot, rendered for a person; absent for a field
487    /// that resolved to nothing.
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub value: Option<String>,
490    /// What decided it: a file, a command, a compile-time constant, the cache.
491    pub source: String,
492    /// The part of this executable that read it.
493    pub resolver: String,
494    /// How certain the value is.
495    pub confidence: Confidence,
496}
497
498impl FieldSource {
499    /// A field resolved exactly from a named source.
500    pub fn exact(
501        field: impl Into<String>,
502        value: Option<String>,
503        source: impl Into<String>,
504        resolver: &'static str,
505    ) -> Self {
506        FieldSource {
507            field: field.into(),
508            value,
509            source: source.into(),
510            resolver: resolver.into(),
511            confidence: Confidence::Exact,
512        }
513    }
514
515    /// A field taken from an earlier full resolution rather than read now.
516    pub fn cached(
517        field: impl Into<String>,
518        value: Option<String>,
519        source: impl Into<String>,
520        resolver: &'static str,
521    ) -> Self {
522        FieldSource {
523            field: field.into(),
524            value,
525            source: source.into(),
526            resolver: resolver.into(),
527            confidence: Confidence::Cached,
528        }
529    }
530
531    /// A field nothing could resolve; `source` says what was tried.
532    pub fn unknown(
533        field: impl Into<String>,
534        source: impl Into<String>,
535        resolver: &'static str,
536    ) -> Self {
537        FieldSource {
538            field: field.into(),
539            value: None,
540            source: source.into(),
541            resolver: resolver.into(),
542            confidence: Confidence::Unknown,
543        }
544    }
545}
546
547/// How far a resolved value can be trusted.
548#[derive(
549    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
550)]
551#[serde(rename_all = "snake_case")]
552pub enum Confidence {
553    /// Read from its canonical source during this resolution.
554    Exact,
555    /// Read from a cache whose fingerprint still matches its inputs.
556    Cached,
557    /// Not resolved.
558    Unknown,
559}
560
561/// The snapshot: what this checkout is, right now.
562///
563/// Every surface that reports any of this renders this value. Collections are in a
564/// documented, stable order — kinds and workflows by name, services and providers in the
565/// order their canonical source declares them, diagnostics in the order they were found —
566/// so that two snapshots of the same repository serialise identically apart from
567/// [`RepositoryEnvironment::generated_at`].
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
569pub struct RepositoryEnvironment {
570    /// The contract this document follows, `majordomus/repository-environment/v1`.
571    pub schema: String,
572    /// When it was taken, RFC 3339 in UTC.
573    pub generated_at: String,
574    /// How completely it was resolved.
575    pub resolution: Resolution,
576    /// What the project is.
577    pub project: ProjectIdentity,
578    /// What the checkout is.
579    pub repository: RepositoryIdentity,
580    /// What version control says.
581    pub vcs: VcsState,
582    /// The toolchains the repository declares, sorted by id.
583    pub toolchains: Vec<ToolchainState>,
584    /// What the layer holds.
585    pub layer: LayerSummary,
586    /// The workflows a person can run.
587    pub workflows: WorkflowCatalogue,
588    /// The provider projections the policy declares, in the policy's order.
589    pub providers: Vec<ProviderState>,
590    /// The local services, in the order the service table declares them.
591    pub services: Vec<ServiceState>,
592    /// Reasoning (ADR 0098): operational whatever the advisors, the mode in force, and how
593    /// many optional advisors can be asked. Absent when the distribution declares none.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub reasoning: Option<ReasoningSummary>,
596    /// Everything that went wrong or is worth knowing, in the order it was found.
597    pub diagnostics: Vec<Diagnostic>,
598    /// Where every field came from.
599    pub provenance: Vec<FieldSource>,
600}
601
602impl RepositoryEnvironment {
603    /// The schema identity a document of this shape carries.
604    pub fn schema_id() -> String {
605        format!("{SCHEMA}/v{SCHEMA_VERSION}")
606    }
607
608    /// The provenance of one field, by its dotted name.
609    ///
610    /// ```
611    /// use majordomus_cli::environment::{FieldSource, RepositoryEnvironment};
612    /// # fn demo(env: &RepositoryEnvironment) {
613    /// let _ = env.explain("project.version");
614    /// # }
615    /// ```
616    pub fn explain(&self, field: &str) -> Option<&FieldSource> {
617        self.provenance.iter().find(|p| p.field == field)
618    }
619
620    /// A digest of everything a reader would notice, excluding the moment it was taken.
621    /// Two snapshots with the same digest say the same thing, which is how the banner
622    /// decides whether a person has already seen this.
623    pub fn digest(&self) -> String {
624        let mut stable = self.clone();
625        stable.generated_at = String::new();
626        // The resolution is not part of what a reader sees: the same repository resolved
627        // fast and full must digest the same, or every warm banner would look like news.
628        stable.resolution = Resolution::Full;
629        stable.provenance.clear();
630        let text = serde_json::to_string(&stable).unwrap_or_default();
631        crate::policy::sha256_hex(&text)
632    }
633
634    /// The digest that decides whether a banner is news: [`Self::digest`] without the
635    /// services.
636    ///
637    /// The entry file watches the server's lease, so a server coming up re-evaluates entry
638    /// twice more — once when it claims the lease, once when it publishes its address — and
639    /// a digest that counted the services made each of those a first look, drawing the whole
640    /// box again at a person who had just seen it. A server arriving or leaving is not a
641    /// different repository; the short form names the Cockpit when it answers.
642    ///
643    /// ```
644    /// use majordomus_cli::environment::RepositoryEnvironment;
645    /// # fn demo(env: &RepositoryEnvironment) {
646    /// // a server arriving or leaving is not a different repository, so clearing the
647    /// // services changes nothing this digest can see
648    /// let mut without = env.clone();
649    /// without.services.clear();
650    /// assert_eq!(env.news_digest(), without.news_digest());
651    /// // and with no services left, the two digests are the same value
652    /// assert_eq!(without.news_digest(), without.digest());
653    /// # }
654    /// ```
655    pub fn news_digest(&self) -> String {
656        let mut stable = self.clone();
657        stable.services.clear();
658        stable.digest()
659    }
660
661    /// The service with this id.
662    pub fn service(&self, id: &str) -> Option<&ServiceState> {
663        self.services.iter().find(|s| s.id == id)
664    }
665
666    /// The worst thing in the snapshot worth telling a person about, when there is one.
667    pub fn headline_diagnostic(&self) -> Option<&Diagnostic> {
668        self.diagnostics
669            .iter()
670            .max_by_key(|d| d.severity)
671            .filter(|d| d.severity != crate::model::Severity::Info)
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    #[test]
680    fn the_schema_identity_carries_its_version() {
681        assert_eq!(
682            RepositoryEnvironment::schema_id(),
683            "majordomus/repository-environment/v1"
684        );
685    }
686
687    #[test]
688    fn a_tier_nothing_resolved_reports_no_values_rather_than_zero() {
689        let s = LayerSummary::unavailable();
690        assert_eq!(s.state, TierState::Unavailable);
691        assert!(!s.state.is_known());
692        // The distinction this type exists for: "no rules" and "nobody counted" must not
693        // serialise the same, because a person reads the first as a broken layer.
694        assert_eq!(s.objects, None);
695        assert_eq!(s.kind("rule"), None);
696    }
697
698    #[test]
699    fn the_identity_of_this_build_reads_nothing() {
700        let p = ProjectIdentity::of_this_build();
701        assert_eq!(p.version, crate::VERSION);
702        assert_eq!(p.name, crate::about::NAME);
703        assert!(!p.target.is_empty());
704    }
705}