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}