Skip to main content

majordomus_cli/environment/
resolve.rs

1//! The resolver: one function that builds a [`RepositoryEnvironment`], and the only place
2//! any surface gets one.
3//!
4//! # The two resolutions
5//!
6//! A full resolution has the index; a fast one does not, and may not build it. That is not
7//! a preference. Building the index reads and validates every declared file of the layer,
8//! which is measured in seconds even in a release build, and a fast resolution is what
9//! runs when a person types `cd`.
10//!
11//! Everything else is cheap enough for either: the project identity is compile-time
12//! constants, the repository comes from one manifest, version control from one `git`
13//! call, the toolchains from a handful of `exists()`, the provider projections from the
14//! policy and its templates, the services from one small file and at most one bounded
15//! connection attempt. The three answers that are not cheap — what the layer holds,
16//! what the workflow runner says, and what versions are installed — come from
17//! [`super::cache`], each with its own fingerprint, and are reported as unavailable when
18//! no cache can supply them.
19//!
20//! Unavailable is a real answer here, and the discipline the whole module is built on: a
21//! count that was not counted is absent, never zero, because a person reads a zero as a
22//! broken repository and acts on it.
23
24use std::path::Path;
25
26use crate::capability::CapabilityRegistry;
27use crate::index::Index;
28use crate::model::{Diagnostic, Severity};
29use crate::repository::Repository;
30use crate::share::Share;
31
32use super::cache::{fingerprint_of, Cache, TOOLCHAIN_LIFETIME};
33use super::{
34    services, toolchain, vcs, workflows, FieldSource, KindCount, LayerSummary, ProjectIdentity,
35    ProjectionState, ProviderState, ReasoningSummary, RepositoryEnvironment, RepositoryIdentity,
36    Resolution, TierState, ToolchainAvailability, VcsState,
37};
38
39/// How many refused files the `layer_degraded` warning names before it counts the rest. A
40/// warning a terminal truncates says nothing; the whole list is in `layer.refused`.
41const LAYER_REFUSALS_NAMED: usize = 3;
42
43/// What a caller wants resolved, and what it will pay for.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct EnvironmentQuery {
46    /// How much may be read.
47    pub resolution: Resolution,
48    /// Whether a running server's address may be contacted. A resolution that says no
49    /// reports every service `unknown`, which is what it knows.
50    pub probe_services: bool,
51    /// Whether the cache may supply the tiers this resolution cannot compute.
52    pub use_cache: bool,
53    /// Whether what was computed is written back for the next fast resolution.
54    pub write_cache: bool,
55}
56
57impl EnvironmentQuery {
58    /// What a shell prompt asks for: everything cheap, the cache for the rest, a bounded
59    /// probe of the local address, and no write of tiers it did not compute.
60    pub fn fast() -> Self {
61        EnvironmentQuery {
62            resolution: Resolution::Fast,
63            probe_services: true,
64            use_cache: true,
65            write_cache: true,
66        }
67    }
68
69    /// What a person waiting on a command asks for: everything, written back.
70    pub fn full() -> Self {
71        EnvironmentQuery {
72            resolution: Resolution::Full,
73            probe_services: true,
74            use_cache: false,
75            write_cache: true,
76        }
77    }
78
79    /// The same, without touching anything outside this process: no probe, no cache read,
80    /// no cache write. What a test and a hosted request use.
81    pub fn sealed(self) -> Self {
82        EnvironmentQuery {
83            probe_services: false,
84            use_cache: false,
85            write_cache: false,
86            ..self
87        }
88    }
89}
90
91/// What the resolver may read. A fast resolution carries no index and no registry; a full
92/// one carries both, and the presence of the index is what makes it full.
93pub struct Inputs<'a> {
94    /// The repository, already discovered.
95    pub repository: &'a Repository,
96    /// The distribution directory, when it was located.
97    pub share: Option<&'a Share>,
98    /// The index, in a full resolution.
99    pub index: Option<&'a Index>,
100    /// The registry, in a full resolution.
101    pub registry: Option<&'a CapabilityRegistry>,
102    /// The policy, when the caller has already read it. The resolver reads it itself when
103    /// this is `None`, which is what every caller that needs nothing else from it does.
104    ///
105    /// It is here for the one caller that does: `majordomus env enter` asks the policy
106    /// whether entry ensures a runtime, and then resolves a snapshot whose provider
107    /// projections come out of the same file. Two reads of one canonical file inside one
108    /// invocation is what `project.hot-path-reads-once` is about, and this is the hot path
109    /// by definition — it runs on every `cd`.
110    pub policy: Option<&'a crate::policy::LoadedPolicy>,
111}
112
113/// Build a snapshot.
114///
115/// Never fails. Anything that cannot be read becomes a diagnostic and an absent value; a
116/// snapshot is the report of what could be learnt, and a resolver that refused to answer
117/// because one thing was missing would take the shell down with it.
118pub fn resolve(inputs: &Inputs<'_>, query: &EnvironmentQuery) -> RepositoryEnvironment {
119    let root = inputs.repository.root();
120    let local_half = inputs.repository.local_path();
121    let mut diagnostics = Vec::new();
122    let mut provenance = Vec::new();
123
124    let project = ProjectIdentity::of_this_build();
125    provenance.push(FieldSource::exact(
126        "project.version",
127        Some(project.version.clone()),
128        "the crate manifest, at compile time (CARGO_PKG_VERSION)",
129        "environment::resolve",
130    ));
131    provenance.push(FieldSource::exact(
132        "project.summary",
133        Some(project.summary.clone()),
134        "majordomus_cli::about::SUMMARY",
135        "environment::resolve",
136    ));
137    provenance.push(FieldSource::exact(
138        "project.commit",
139        Some(project.commit.clone()),
140        "build.rs, at compile time (MAJORDOMUS_COMMIT)",
141        "environment::resolve",
142    ));
143
144    let repository = repository_identity(inputs.repository);
145    provenance.push(FieldSource::exact(
146        "repository.name",
147        Some(repository.name.clone()),
148        "the base name of the repository root",
149        "environment::resolve",
150    ));
151    provenance.push(FieldSource::exact(
152        "repository.sections",
153        Some(format!("{} section(s)", repository.sections.len())),
154        crate::repository::MANIFEST,
155        "environment::resolve",
156    ));
157
158    // ---------------------------------------------------------------- version control
159    let vcs = vcs::inspect(root);
160    match &vcs {
161        VcsState::Git(tree) => {
162            provenance.push(FieldSource::exact(
163                "vcs.branch",
164                tree.branch.clone(),
165                vcs::SOURCE,
166                "environment::vcs",
167            ));
168            provenance.push(FieldSource::exact(
169                "vcs.clean",
170                Some(tree.clean.to_string()),
171                vcs::SOURCE,
172                "environment::vcs",
173            ));
174            provenance.push(match (tree.ahead, tree.behind) {
175                (Some(a), Some(b)) => FieldSource::exact(
176                    "vcs.ahead_behind",
177                    Some(format!("{a}/{b}")),
178                    vcs::SOURCE,
179                    "environment::vcs",
180                ),
181                _ => FieldSource::unknown(
182                    "vcs.ahead_behind",
183                    "the branch tracks no upstream",
184                    "environment::vcs",
185                ),
186            });
187        }
188        VcsState::Unavailable { reason } => {
189            diagnostics.push(Diagnostic::warning(
190                "vcs_unavailable",
191                None,
192                format!("version control could not be read: {reason}"),
193            ));
194            provenance.push(FieldSource::unknown(
195                "vcs.branch",
196                format!("{}: {reason}", vcs::SOURCE),
197                "environment::vcs",
198            ));
199        }
200    }
201
202    let mut cache = if query.use_cache || query.write_cache {
203        Cache::load(root, &local_half)
204    } else {
205        Cache::default()
206    };
207
208    // ---------------------------------------------------------------- what the layer holds
209    let layer_fingerprint = layer_fingerprint(inputs.repository, &vcs);
210    let layer = match (inputs.index, inputs.registry) {
211        (Some(index), Some(registry)) => {
212            let summary = summarise(index, registry);
213            provenance.push(FieldSource::exact(
214                "layer.objects",
215                summary.objects.map(|n| n.to_string()),
216                "the index this process built",
217                "environment::resolve",
218            ));
219            summary
220        }
221        _ => {
222            let cached = query
223                .use_cache
224                .then_some(cache.tiers.layer.as_ref())
225                .flatten()
226                .and_then(|e| e.fresh(&layer_fingerprint, None));
227            match cached {
228                Some(summary) => {
229                    let mut summary = summary.clone();
230                    summary.state = TierState::Cached;
231                    provenance.push(FieldSource::cached(
232                        "layer.objects",
233                        summary.objects.map(|n| n.to_string()),
234                        format!(
235                            "{}, written by the last full resolution",
236                            super::cache::CACHE_PATH
237                        ),
238                        "environment::cache",
239                    ));
240                    summary
241                }
242                None => {
243                    diagnostics.push(Diagnostic::info(
244                        "environment_layer_not_counted",
245                        None,
246                        "what the layer holds is not in the cache for this state of the repository; `majordomus env status` counts it and writes the cache",
247                    ));
248                    provenance.push(FieldSource::unknown(
249                        "layer.objects",
250                        "a fast resolution does not build the index, and no cache entry matched",
251                        "environment::cache",
252                    ));
253                    LayerSummary::unavailable()
254                }
255            }
256        }
257    };
258    if layer.degraded == Some(true) {
259        // Name them here. The count alone sent a reader to `majordomus capabilities
260        // validate`, which validates capabilities against their interfaces and reports
261        // nothing about a file the index refused — the remedy named an answer it does not
262        // have, so the only place the list existed was a log line.
263        let named: Vec<String> = layer
264            .refused
265            .iter()
266            .take(LAYER_REFUSALS_NAMED)
267            .map(|d| match &d.path {
268                Some(p) => format!("{p} — {}", d.message),
269                None => d.message.clone(),
270            })
271            .collect();
272        let total = layer.invalid.unwrap_or(layer.refused.len());
273        let rest = total.saturating_sub(named.len());
274        let list = match (named.is_empty(), rest) {
275            (true, _) => String::new(),
276            (false, 0) => format!(": {}", named.join("; ")),
277            (false, n) => format!(": {}; and {n} more", named.join("; ")),
278        };
279        diagnostics.push(Diagnostic::warning(
280            "layer_degraded",
281            None,
282            format!("{total} declared file(s) did not become objects{list}"),
283        ));
284    }
285
286    // ---------------------------------------------------------------- workflows
287    let workflow_fingerprint = workflow_fingerprint(root);
288    let cached_workflows = query
289        .use_cache
290        .then_some(cache.tiers.workflows.as_ref())
291        .flatten()
292        .and_then(|e| e.fresh(&workflow_fingerprint, None));
293    let workflows = match cached_workflows {
294        Some(catalogue) => {
295            let mut catalogue = catalogue.clone();
296            catalogue.state = TierState::Cached;
297            provenance.push(FieldSource::cached(
298                "workflows",
299                Some(format!("{} workflow(s)", catalogue.workflows.len())),
300                format!("{}, keyed by the workflow files", super::cache::CACHE_PATH),
301                "environment::cache",
302            ));
303            catalogue
304        }
305        None => {
306            // Asking the runner costs two short subprocesses, which a fast resolution can
307            // afford when the answer is not already known; it is the index it cannot.
308            let catalogue = workflows::resolve(root);
309            provenance.push(match catalogue.state {
310                TierState::Unavailable => FieldSource::unknown(
311                    "workflows",
312                    "no workflow runner answered for this repository",
313                    "environment::workflows",
314                ),
315                _ => FieldSource::exact(
316                    "workflows",
317                    Some(format!("{} workflow(s)", catalogue.workflows.len())),
318                    workflows::SOURCE,
319                    "environment::workflows",
320                ),
321            });
322            catalogue
323        }
324    };
325
326    // ---------------------------------------------------------------- toolchains
327    let declared = toolchain::declared(root);
328    let toolchain_fingerprint = fingerprint_of(
329        root,
330        &declared
331            .iter()
332            .map(|t| t.declared_by.clone())
333            .collect::<Vec<_>>(),
334        &["toolchains"],
335    );
336    let toolchains = if query.resolution == Resolution::Full {
337        let resolved = toolchain::with_installed(declared);
338        provenance.push(FieldSource::exact(
339            "toolchains",
340            Some(format!("{} declared", resolved.len())),
341            "each toolchain's own --version, bounded",
342            "environment::toolchain",
343        ));
344        resolved
345    } else {
346        let cached = query
347            .use_cache
348            .then_some(cache.tiers.toolchains.as_ref())
349            .flatten()
350            .and_then(|e| e.fresh(&toolchain_fingerprint, Some(TOOLCHAIN_LIFETIME)))
351            .cloned();
352        match cached {
353            Some(resolved) => {
354                provenance.push(FieldSource::cached(
355                    "toolchains",
356                    Some(format!("{} declared", resolved.len())),
357                    format!("{}, within its lifetime", super::cache::CACHE_PATH),
358                    "environment::cache",
359                ));
360                resolved
361            }
362            None => {
363                provenance.push(FieldSource::exact(
364                    "toolchains",
365                    Some(format!("{} declared", declared.len())),
366                    "the file that declares each one; no version was asked",
367                    "environment::toolchain",
368                ));
369                declared
370            }
371        }
372    };
373    for missing in toolchains
374        .iter()
375        .filter(|t| t.availability == ToolchainAvailability::Missing)
376    {
377        diagnostics.push(Diagnostic::warning(
378            "toolchain_missing",
379            Some(missing.declared_by.clone()),
380            format!("{} is declared here and is not installed", missing.title),
381        ));
382    }
383    for mismatch in toolchains
384        .iter()
385        .filter(|t| t.availability == ToolchainAvailability::Mismatch)
386    {
387        diagnostics.push(Diagnostic::warning(
388            "toolchain_mismatch",
389            Some(mismatch.declared_by.clone()),
390            format!(
391                "{} {} is installed and this file declares {}",
392                mismatch.title,
393                mismatch.installed.as_deref().unwrap_or("?"),
394                mismatch.declared.as_deref().unwrap_or("?"),
395            ),
396        ));
397    }
398
399    // ---------------------------------------------------------------- providers
400    let (providers, provider_source) =
401        provider_states(inputs.repository, inputs.share, inputs.policy);
402    provenance.push(FieldSource::exact(
403        "providers",
404        Some(format!("{} projection(s)", providers.len())),
405        provider_source,
406        "environment::resolve",
407    ));
408    for stale in providers
409        .iter()
410        .filter(|p| matches!(p.state, ProjectionState::Stale | ProjectionState::Absent))
411    {
412        diagnostics.push(Diagnostic::warning(
413            "projection_stale",
414            Some(stale.target.clone()),
415            format!(
416                "{} is {} against the policy that renders it; `majordomus generate` rewrites it",
417                stale.target,
418                match stale.state {
419                    ProjectionState::Absent => "missing",
420                    _ => "stale",
421                }
422            ),
423        ));
424    }
425
426    // ---------------------------------------------------------------- services
427    let services = services::resolve(root, &local_half, query.probe_services);
428    provenance.push(match services.first().and_then(|s| s.url.clone()) {
429        Some(_) => FieldSource::exact(
430            "services.url",
431            services.first().and_then(|s| s.url.clone()),
432            format!("{}/{}", local_half, crate::lease::LEASE_PATH),
433            "environment::services",
434        ),
435        None => FieldSource::unknown(
436            "services.url",
437            format!(
438                "no server holds the lease at {}/{}",
439                local_half,
440                crate::lease::LEASE_PATH
441            ),
442            "environment::services",
443        ),
444    });
445
446    // ---------------------------------------------------------------- reasoning
447    // Presence only: a PATH lookup and a variable test per declared advisor, and the
448    // reasoning records' directory listing. No network and no model, at any tier.
449    let reasoning = inputs.share.and_then(|share| {
450        let declarations = share.providers().ok()?;
451        let situation =
452            crate::reasoning::Situation::resolve(root, Some(share.dir()), &declarations, &[])
453                .ok()?;
454        if situation.catalogue.advisors.is_empty() {
455            return None;
456        }
457        Some(ReasoningSummary {
458            operational: true,
459            mode: situation.mode.mode.as_str().to_string(),
460            unavailable: situation.states.len() - situation.available(),
461            available: situation
462                .states
463                .iter()
464                .filter(|a| a.status == crate::reasoning::AdvisorStatus::Available)
465                .map(|a| a.id.clone())
466                .collect(),
467        })
468    });
469
470    let environment = RepositoryEnvironment {
471        schema: RepositoryEnvironment::schema_id(),
472        generated_at: crate::peers::rfc3339(std::time::SystemTime::now()),
473        resolution: query.resolution,
474        project,
475        repository,
476        vcs,
477        toolchains,
478        layer,
479        workflows,
480        providers,
481        services,
482        reasoning,
483        diagnostics,
484        provenance,
485    };
486
487    if query.write_cache {
488        // Only what this resolution actually computed is written back; a tier taken from
489        // the cache is rewritten with the same fingerprint, which is a no-op, and a tier
490        // nothing resolved is left as it was rather than being replaced with an absence.
491        if environment.layer.state == TierState::Resolved {
492            cache.tiers.layer = Some(Cache::entry(layer_fingerprint, environment.layer.clone()));
493        }
494        if environment.workflows.state == TierState::Resolved {
495            cache.tiers.workflows = Some(Cache::entry(
496                workflow_fingerprint,
497                environment.workflows.clone(),
498            ));
499        }
500        if environment
501            .toolchains
502            .iter()
503            .any(|t| t.availability != ToolchainAvailability::Unknown)
504        {
505            cache.tiers.toolchains = Some(Cache::entry(
506                toolchain_fingerprint,
507                environment.toolchains.clone(),
508            ));
509        }
510        if let Err(e) = cache.store(root, &local_half) {
511            tracing::debug!(error = %e, "the environment cache could not be written");
512        }
513    }
514
515    environment
516}
517
518/// The identity of the checkout.
519fn repository_identity(repository: &Repository) -> RepositoryIdentity {
520    let root = repository.root();
521    RepositoryIdentity {
522        name: root
523            .file_name()
524            .map(|n| n.to_string_lossy().into_owned())
525            .unwrap_or_else(|| root.display().to_string()),
526        root: root.display().to_string(),
527        layer_schema: repository.manifest().schema.clone(),
528        sections: repository
529            .manifest()
530            .sections
531            .iter()
532            .map(|(name, path)| (name.clone(), format!(".ai/{path}")))
533            .collect(),
534        local_path: repository.local_path(),
535        // A linked work tree has a `.git` file rather than a `.git` directory. One stat.
536        linked_worktree: root.join(".git").is_file(),
537    }
538}
539
540/// What the layer holds, from the index and the registry that already hold it.
541fn summarise(index: &Index, registry: &CapabilityRegistry) -> LayerSummary {
542    // The index already decided each of these and said why; carrying the diagnostics rather
543    // than their number is what lets every reader of the snapshot name the file.
544    let refused: Vec<crate::model::Diagnostic> = index
545        .diagnostics
546        .iter()
547        .filter(|d| d.severity == Severity::Error)
548        .cloned()
549        .collect();
550    LayerSummary {
551        state: TierState::Resolved,
552        kinds: index
553            .kinds()
554            .into_iter()
555            .map(|(kind, count)| KindCount {
556                kind: kind.to_string(),
557                count,
558            })
559            .collect(),
560        objects: Some(index.objects.len()),
561        capabilities: Some(registry.summary().total),
562        invalid: Some(refused.len()),
563        refused,
564        degraded: Some(index.state == crate::index::State::Degraded),
565    }
566}
567
568/// The provider projections the policy declares, each against the file it renders to.
569fn provider_states(
570    repository: &Repository,
571    share: Option<&Share>,
572    already: Option<&crate::policy::LoadedPolicy>,
573) -> (Vec<ProviderState>, String) {
574    let source = format!(
575        "{}, projections[]",
576        repository
577            .section_path("policy")
578            .unwrap_or_else(|| ".ai/repo/policy.yaml".into())
579    );
580    let read;
581    let policy = match already {
582        Some(policy) => policy,
583        None => {
584            let Ok(loaded) = crate::policy::LoadedPolicy::load(repository) else {
585                return (Vec::new(), format!("{source} (unreadable)"));
586            };
587            read = loaded;
588            &read
589        }
590    };
591    // Rendering needs the templates, which live in the distribution. Without it the
592    // projections are still known — the policy declares them — but whether each file is
593    // current is not, and `Unknown` says exactly that.
594    let rendered = share.and_then(|share| {
595        crate::providers::artifacts(repository, share, policy)
596            .ok()
597            .map(|artifacts| {
598                artifacts
599                    .into_iter()
600                    .map(|a| {
601                        let state = crate::providers::state_of(repository.root(), &a);
602                        (a.path, state)
603                    })
604                    .collect::<Vec<_>>()
605            })
606    });
607    let states = policy
608        .policy
609        .projections
610        .iter()
611        .map(|p| ProviderState {
612            id: p.provider.clone(),
613            target: p.target.clone(),
614            always_loaded: p.always_loaded,
615            state: match rendered
616                .as_ref()
617                .and_then(|r| r.iter().find(|(path, _)| *path == p.target))
618            {
619                Some((_, crate::providers::TargetState::Current)) => ProjectionState::Current,
620                Some((_, crate::providers::TargetState::Stale)) => ProjectionState::Stale,
621                Some((_, crate::providers::TargetState::Absent)) => ProjectionState::Absent,
622                None => ProjectionState::Unknown,
623            },
624        })
625        .collect();
626    (states, source)
627}
628
629/// The fingerprint of everything that can change what the layer holds: the commit, the
630/// files that configure discovery, and whatever is dirty under a section of the layer.
631///
632/// The dirty set is what makes this exact rather than approximate. A rule edited and not
633/// committed moves no commit and touches no configuration file, so a fingerprint over
634/// those alone would keep serving the old count; the file appears in the status report
635/// this snapshot has already paid for, and its size and modification time go into the
636/// fingerprint at the cost of one `stat`.
637fn layer_fingerprint(repository: &Repository, vcs: &VcsState) -> String {
638    let mut paths: Vec<String> = vec![
639        crate::repository::MANIFEST.to_string(),
640        ".ai/repo/knowledge/sources.yaml".to_string(),
641        ".ai/repo/knowledge/kinds.yaml".to_string(),
642        "share/kinds.yaml".to_string(),
643    ];
644    if let Some(policy) = repository.section_path("policy") {
645        paths.push(policy);
646    }
647    if let Some(scope) = repository.section_path("scope") {
648        paths.push(scope);
649    }
650    let head = vcs
651        .tree()
652        .and_then(|t| t.head.clone())
653        .unwrap_or_else(|| "unborn".into());
654    if let Some(tree) = vcs.tree() {
655        // only the dirty paths that could be objects of the layer, so that a change to an
656        // unrelated file costs nothing
657        paths.extend(
658            tree.changed_paths
659                .iter()
660                .filter(|p| is_layer_path(repository, p))
661                .cloned(),
662        );
663    }
664    fingerprint_of(repository.root(), &paths, &["layer", &head])
665}
666
667/// Could a repository-relative path be an object of the layer? Derived from the manifest's
668/// own sections plus the two trees outside `.ai/` that the source classes reach into.
669fn is_layer_path(repository: &Repository, path: &str) -> bool {
670    if path.starts_with(&repository.local_path()) {
671        return false;
672    }
673    path.starts_with(".ai/")
674        || path.starts_with("docs/")
675        || path.starts_with("share/")
676        || path.starts_with("lib/")
677        || path.starts_with("test/cases/")
678        || path.starts_with("bin/")
679}
680
681/// The fingerprint of the workflow files: the justfile and whatever it imports.
682fn workflow_fingerprint(root: &Path) -> String {
683    let mut paths = vec!["justfile".to_string(), ".justfile".to_string()];
684    // One directory read of the module directory, when there is one. A justfile split into
685    // modules must expire when any of them changes, and reading one directory is the whole
686    // cost of knowing that.
687    if let Ok(entries) = std::fs::read_dir(root.join(".just")) {
688        let mut names: Vec<String> = entries
689            .flatten()
690            .filter_map(|e| e.file_name().into_string().ok())
691            .map(|name| format!(".just/{name}"))
692            .collect();
693        crate::order::canonical(&mut names);
694        paths.extend(names);
695    } else {
696        // The absence of the directory is itself part of the fingerprint: creating it must
697        // expire the catalogue.
698        paths.push(".just".to_string());
699    }
700    fingerprint_of(root, &paths, &["workflows"])
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::synthetic::{Shape, SyntheticRepository};
707
708    fn repository() -> SyntheticRepository {
709        SyntheticRepository::new(Shape {
710            rules: 3,
711            ..Shape::default()
712        })
713        .expect("a synthetic repository")
714    }
715
716    fn full(repo: &SyntheticRepository) -> RepositoryEnvironment {
717        let index = repo.index().expect("an index");
718        let registry = CapabilityRegistry::builder()
719            .with_modules(crate::capability::builtin::modules())
720            .with_index(&index)
721            .build()
722            .expect("a registry");
723        let repository = Repository::open(repo.root()).expect("a repository");
724        resolve(
725            &Inputs {
726                repository: &repository,
727                share: None,
728                index: Some(&index),
729                registry: Some(&registry),
730                policy: None,
731            },
732            &EnvironmentQuery::full().sealed(),
733        )
734    }
735
736    fn fast(repo: &SyntheticRepository, query: EnvironmentQuery) -> RepositoryEnvironment {
737        let repository = Repository::open(repo.root()).expect("a repository");
738        resolve(
739            &Inputs {
740                repository: &repository,
741                share: None,
742                index: None,
743                registry: None,
744                policy: None,
745            },
746            &query,
747        )
748    }
749
750    #[test]
751    fn a_full_resolution_counts_what_the_layer_holds() {
752        let repo = repository();
753        let env = full(&repo);
754        assert_eq!(env.resolution, Resolution::Full);
755        assert_eq!(env.layer.state, TierState::Resolved);
756        assert!(env.layer.objects.unwrap_or(0) > 0);
757        assert_eq!(env.layer.kind("rule"), Some(3));
758    }
759
760    /// The discipline the module is built on: a fast resolution with nothing cached says
761    /// it does not know, and never says zero.
762    #[test]
763    fn a_fast_resolution_with_a_cold_cache_reports_unknown_and_never_zero() {
764        let repo = repository();
765        let env = fast(&repo, EnvironmentQuery::fast().sealed());
766        assert_eq!(env.layer.state, TierState::Unavailable);
767        assert_eq!(env.layer.objects, None);
768        assert_eq!(env.layer.kind("rule"), None);
769        assert!(env
770            .diagnostics
771            .iter()
772            .any(|d| d.code == "environment_layer_not_counted"));
773        assert_eq!(
774            env.explain("layer.objects").map(|p| p.confidence),
775            Some(super::super::Confidence::Unknown)
776        );
777    }
778
779    #[test]
780    fn a_full_resolution_warms_the_cache_a_fast_one_then_reads() {
781        let repo = repository();
782        let index = repo.index().expect("an index");
783        let registry = CapabilityRegistry::builder()
784            .with_modules(crate::capability::builtin::modules())
785            .with_index(&index)
786            .build()
787            .expect("a registry");
788        let repository = Repository::open(repo.root()).expect("a repository");
789        let warm = resolve(
790            &Inputs {
791                repository: &repository,
792                share: None,
793                index: Some(&index),
794                registry: Some(&registry),
795                policy: None,
796            },
797            &EnvironmentQuery {
798                probe_services: false,
799                ..EnvironmentQuery::full()
800            },
801        );
802        assert_eq!(warm.layer.state, TierState::Resolved);
803
804        let then = fast(
805            &repo,
806            EnvironmentQuery {
807                probe_services: false,
808                write_cache: false,
809                ..EnvironmentQuery::fast()
810            },
811        );
812        assert_eq!(then.layer.state, TierState::Cached);
813        assert_eq!(then.layer.objects, warm.layer.objects);
814        assert_eq!(then.layer.kinds, warm.layer.kinds);
815        assert_eq!(
816            then.explain("layer.objects").map(|p| p.confidence),
817            Some(super::super::Confidence::Cached)
818        );
819    }
820
821    /// The two resolutions differ in what they may read, never in what they mean. Every
822    /// field a fast resolution does fill must equal the full one's.
823    #[test]
824    fn fast_and_full_agree_on_everything_fast_can_see() {
825        let repo = repository();
826        let full = full(&repo);
827        let fast = fast(&repo, EnvironmentQuery::fast().sealed());
828        assert_eq!(fast.project, full.project);
829        assert_eq!(fast.repository, full.repository);
830        assert_eq!(fast.vcs, full.vcs);
831        assert_eq!(fast.providers, full.providers);
832        let ids = |e: &RepositoryEnvironment| -> Vec<String> {
833            e.services.iter().map(|s| s.id.clone()).collect()
834        };
835        assert_eq!(ids(&fast), ids(&full));
836        assert_eq!(
837            fast.toolchains
838                .iter()
839                .map(|t| t.id.clone())
840                .collect::<Vec<_>>(),
841            full.toolchains
842                .iter()
843                .map(|t| t.id.clone())
844                .collect::<Vec<_>>()
845        );
846    }
847
848    /// Two snapshots of one unchanged repository must be the same document apart from the
849    /// moment they were taken, or every consumer that compares them sees news that is not
850    /// there.
851    #[test]
852    fn a_snapshot_of_an_unchanged_repository_is_deterministic() {
853        let repo = repository();
854        let a = full(&repo);
855        let b = full(&repo);
856        assert_eq!(a.digest(), b.digest());
857        let mut a_json = serde_json::to_value(&a).expect("json");
858        let mut b_json = serde_json::to_value(&b).expect("json");
859        for v in [&mut a_json, &mut b_json] {
860            v["generated_at"] = serde_json::Value::Null;
861        }
862        assert_eq!(a_json, b_json);
863    }
864
865    /// The digest answers "is there anything new to show a person", so it moves when a
866    /// count moves. It deliberately does not move when a file's bytes change without
867    /// changing what the snapshot says: a banner that turned into news on every keystroke
868    /// would be one a person learns to ignore.
869    #[test]
870    fn the_digest_follows_what_the_snapshot_says_and_not_every_byte() {
871        let repo = repository();
872        let before = full(&repo).digest();
873
874        repo.touch_rule(0, "-changed").expect("a changed rule");
875        assert_eq!(
876            before,
877            full(&repo).digest(),
878            "editing a rule's body changes no count and so is not news"
879        );
880
881        std::fs::write(
882            repo.root().join(".ai/repo/rules/project/rule-99.v1.md"),
883            "---\nid: project.rule-99\nversion: 1\nkind: rule\ntitle: T\ndescription: D\nstatement: S\nstatus: active\nclass: advisory\n---\n\n# Rationale\n\nx\n",
884        )
885        .expect("a new rule");
886        assert_ne!(
887            before,
888            full(&repo).digest(),
889            "a rule that did not exist before is news"
890        );
891    }
892
893    /// The digest answers "would a person see anything new", so the resolution that
894    /// produced it must not be part of it: a warm fast banner and a full one of the same
895    /// repository would otherwise always look like different news.
896    #[test]
897    fn the_digest_ignores_the_moment_and_the_resolution() {
898        let repo = repository();
899        let mut a = full(&repo);
900        let mut b = a.clone();
901        b.generated_at = "1999-01-01T00:00:00Z".into();
902        b.resolution = Resolution::Fast;
903        b.provenance.clear();
904        assert_eq!(a.digest(), b.digest());
905        a.repository.name = "renamed".into();
906        assert_ne!(a.digest(), b.digest());
907    }
908
909    #[test]
910    fn a_repository_that_is_not_a_work_tree_still_produces_a_snapshot() {
911        let repo = repository();
912        let env = fast(&repo, EnvironmentQuery::fast().sealed());
913        // The synthetic repository is not a git work tree, which is exactly the
914        // degradation this asserts: a diagnostic, not a failure.
915        if matches!(env.vcs, VcsState::Unavailable { .. }) {
916            assert!(env.diagnostics.iter().any(|d| d.code == "vcs_unavailable"));
917        }
918        assert!(!env.schema.is_empty());
919        assert_eq!(env.project.version, crate::VERSION);
920    }
921
922    #[test]
923    fn every_named_field_of_the_snapshot_can_say_where_it_came_from() {
924        let repo = repository();
925        let env = full(&repo);
926        for field in [
927            "project.version",
928            "project.summary",
929            "project.commit",
930            "repository.name",
931            "repository.sections",
932            "layer.objects",
933            "workflows",
934            "toolchains",
935            "providers",
936            "services.url",
937        ] {
938            let source = env
939                .explain(field)
940                .unwrap_or_else(|| panic!("{field} has no provenance"));
941            assert!(!source.source.is_empty(), "{field} names no source");
942            assert!(!source.resolver.is_empty(), "{field} names no resolver");
943        }
944    }
945}