devscout_rs/graph/compiler_facts.rs
1// The compiler-facts artifact: a versioned, atomically published record of
2// what an optional one-shot Roslyn engine run (or a build/CI-produced
3// import of one) discovered, admitted through exactly one Rust path
4// regardless of which producer supplied the candidate bytes. Lives beside
5// `graph.json` under the same graph directory, but is its own file with its
6// own schema -- admitting one never touches `graph.json` or bumps
7// `GRAPH_SCHEMA_VERSION`, the same boundary `graph::imports` already draws
8// for the cross-repo edge artifact.
9//
10// `map` and every query never spawn the engine and never require the
11// network: this module is reached only from the `compiler-facts` CLI verb
12// group. An absent artifact is the ordinary "no engine ever ran here"
13// state, read back through `read_compiler_facts`'s fail-open `None`.
14
15mod admit;
16mod artifact;
17mod context_summary;
18mod expectations;
19mod paths;
20mod reasons;
21mod run;
22
23pub use admit::{admit, AdmittedFacts};
24pub use artifact::{
25 Coverage, IncompleteUnit, Profile, COMPILER_FACTS_ARTIFACT_SCHEMA_VERSION,
26 COMPILER_FACTS_CONTRACT_VERSION, COMPILER_FACTS_FORMAT,
27};
28// Test-only: lets the hand-authored candidates in `graph::tests::compiler_facts`
29// compute a correct `context.contextFingerprint` against their own hand-built
30// `compilations` list, the same way the real producer does, without adding
31// this fold to the crate's own public API (no public API is required outside
32// the crate for this delta).
33#[cfg(test)]
34pub(crate) use artifact::CompilationRef;
35#[cfg(test)]
36pub(crate) use context_summary::recompute as recompute_context_summary;
37pub use expectations::{
38 expectations_for, AdmissionExpectations, EXPECTED_CONTEXT_SCHEMA_VERSION,
39 EXPECTED_DEPENDENCY_FINGERPRINT, EXPECTED_ENGINE_REVISION, EXPECTED_PRODUCER_NAME,
40};
41pub use paths::compiler_facts_json_path;
42pub use reasons::RefusalReason;
43pub use run::{run_engine, DEFAULT_OUTPUT_CAP, DEFAULT_TIMEOUT};
44
45use std::fs;
46use std::io;
47use std::path::{Path, PathBuf};
48
49use super::paths::atomic_write_bytes;
50
51/// The engine locator environment variable: the path to the engine
52/// executable `compiler-facts run` spawns. Read only from this module, with
53/// no default and no download -- a missing engine exits non-zero with one
54/// line and leaves any already-admitted artifact untouched. Named
55/// distinctly from the unrelated, unshipped `SCOUT_SEMANTIC_TOOL` locator a
56/// different design note proposes for a different, still-unbuilt cache, so
57/// the two invocation paths can never be confused once both exist.
58pub const SCOUT_COMPILER_ENGINE: &str = "SCOUT_COMPILER_ENGINE";
59
60/// Locates the engine executable from `SCOUT_COMPILER_ENGINE`, or `None`
61/// when the variable is unset or empty.
62pub fn locate_engine() -> Option<PathBuf> {
63 let raw = std::env::var(SCOUT_COMPILER_ENGINE).ok()?;
64 if raw.is_empty() {
65 return None;
66 }
67 Some(PathBuf::from(raw))
68}
69
70/// Why `admit_and_publish` did not produce an admitted, published artifact:
71/// either the candidate itself was refused, or admission's own atomic write
72/// failed. The two are kept apart because only the first is a
73/// `RefusalReason` a caller might match on by token; the second is an
74/// ordinary filesystem failure.
75#[derive(Debug)]
76pub enum PublishError {
77 /// The candidate was refused; nothing was written.
78 Refused(RefusalReason),
79 /// The candidate was admitted but the atomic write itself failed.
80 /// Whatever artifact already existed is untouched -- the write only
81 /// ever replaces the target once its temp file is fully written.
82 Io(io::Error),
83}
84
85impl From<RefusalReason> for PublishError {
86 fn from(reason: RefusalReason) -> Self {
87 PublishError::Refused(reason)
88 }
89}
90
91/// Admits `candidate_bytes` and, on success, publishes it atomically as
92/// this checkout's compiler-facts artifact -- the one place both `run` and
93/// `import` converge, the concrete mechanism behind "one admission path for
94/// both producers." On any refusal, no write occurs and whatever artifact
95/// already existed is untouched.
96pub fn admit_and_publish(
97 root: &Path,
98 candidate_bytes: &[u8],
99 expected: &AdmissionExpectations,
100) -> Result<AdmittedFacts, PublishError> {
101 let facts = admit::admit(candidate_bytes, expected)?;
102 atomic_write_bytes(&paths::compiler_facts_json_path(root), &facts.bytes)
103 .map_err(PublishError::Io)?;
104 paths::remove_superseded_compiler_facts(root);
105 Ok(facts)
106}
107
108/// Reads this checkout's admitted compiler-facts artifact, or `None` when
109/// absent or unreadable -- the same fail-open convention
110/// `graph::read_graph`/`graph::read_imported_edges` already follow, since
111/// this artifact is auxiliary and its absence is the ordinary "no engine
112/// ever ran here" state, not an error.
113pub fn read_compiler_facts(root: &Path) -> Option<AdmittedFacts> {
114 let bytes = fs::read(paths::compiler_facts_json_path(root)).ok()?;
115 let header = artifact::parse_header(&bytes).ok()?;
116 Some(AdmittedFacts { header, bytes })
117}