648 lines
27 KiB
Rust
648 lines
27 KiB
Rust
|
|
//! Level trait, runner, and output assembly (spec §7, §12).
|
|||
|
|
//!
|
|||
|
|
//! The runner executes each registered [`Level`], stamps its result into the
|
|||
|
|
//! [`OracleOutput`] envelope, and auto-emits a `skipped` / `"not implemented"`
|
|||
|
|
//! report for every required level that has no implementation registered yet.
|
|||
|
|
//! This is the mechanism that keeps the §7 contract honest as later stages
|
|||
|
|
//! land — a missing extractor is visible in the output, not silently absent
|
|||
|
|
//! (spec §4, anchor test 2).
|
|||
|
|
|
|||
|
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
|||
|
|
use std::path::{Path, PathBuf};
|
|||
|
|
use std::process::Command;
|
|||
|
|
use std::time::Instant;
|
|||
|
|
|
|||
|
|
pub use cstat_schema::{
|
|||
|
|
CostClass, EntityRecord, LevelReport, LevelStatus, OracleOutput, Provenance, Toolchain,
|
|||
|
|
Vector,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/// Static description of a representation level required by spec §4.
|
|||
|
|
///
|
|||
|
|
/// `id` is the stable short identifier used both in the §7 `levels` map and
|
|||
|
|
/// as the prefix for the level's keys in the flat `vector` namespace.
|
|||
|
|
/// `spec_number` is the numeric label from spec §2.1–§2.9; carried purely for
|
|||
|
|
/// human cross-reference.
|
|||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|||
|
|
pub struct LevelDescriptor {
|
|||
|
|
pub id: &'static str,
|
|||
|
|
pub spec_number: u32,
|
|||
|
|
pub cost_class: CostClass,
|
|||
|
|
pub deterministic: bool,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The canonical list of representation levels the output envelope must
|
|||
|
|
/// always cover (spec §4: §2.1–§2.9, excluding §2.10).
|
|||
|
|
///
|
|||
|
|
/// Order is the spec's order so the §7 `levels` map walks tidily when
|
|||
|
|
/// presented to a human. The map itself is a `BTreeMap`, so the wire-format
|
|||
|
|
/// iteration order is alphabetical by `id`.
|
|||
|
|
pub const REQUIRED_LEVELS: &[LevelDescriptor] = &[
|
|||
|
|
// §2.1 Textual and pre-syntactic
|
|||
|
|
LevelDescriptor { id: "fs.layout", spec_number: 1, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "fs.bytes", spec_number: 2, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "fs.source", spec_number: 3, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "tok.stream", spec_number: 4, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "tok.comments", spec_number: 5, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "fmt.whitespace", spec_number: 6, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
// §2.2 Syntactic
|
|||
|
|
LevelDescriptor { id: "ast.cst", spec_number: 7, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "ast.items", spec_number: 8, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "ast.expanded", spec_number: 9, cost_class: CostClass::B, deterministic: false },
|
|||
|
|
LevelDescriptor { id: "ast.resolved", spec_number: 10, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
// §2.3 Compiler-internal IRs
|
|||
|
|
LevelDescriptor { id: "hir", spec_number: 11, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "thir", spec_number: 12, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "mir.preopt", spec_number: 13, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "mir.opt", spec_number: 14, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "borrowck", spec_number: 15, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
// §2.4 Type-system derived views
|
|||
|
|
LevelDescriptor { id: "type.usage", spec_number: 16, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "type.trait_impl", spec_number: 17, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "type.mono", spec_number: 18, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "type.coherence", spec_number: 19, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
// §2.5 Codegen
|
|||
|
|
LevelDescriptor { id: "llvm.preopt", spec_number: 20, cost_class: CostClass::C, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "llvm.opt", spec_number: 21, cost_class: CostClass::C, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "asm", spec_number: 22, cost_class: CostClass::C, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "obj", spec_number: 23, cost_class: CostClass::C, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "bin", spec_number: 24, cost_class: CostClass::C, deterministic: true },
|
|||
|
|
// §2.6 Binary-artifact
|
|||
|
|
LevelDescriptor { id: "sym.table", spec_number: 25, cost_class: CostClass::C, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "sym.demangled", spec_number: 26, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "sym.sections", spec_number: 27, cost_class: CostClass::C, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "sym.relocs", spec_number: 28, cost_class: CostClass::C, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "dwarf", spec_number: 29, cost_class: CostClass::C, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "link.map", spec_number: 30, cost_class: CostClass::C, deterministic: true },
|
|||
|
|
// §2.7 Graph-derived
|
|||
|
|
LevelDescriptor { id: "graph.mod", spec_number: 31, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "graph.call_static", spec_number: 32, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "graph.call_mono", spec_number: 33, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "graph.cfg", spec_number: 34, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "graph.dataflow", spec_number: 35, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "graph.type_usage", spec_number: 36, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "graph.sccs", spec_number: 37, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "graph.communities", spec_number: 38, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "graph.power_law", spec_number: 39, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "graph.self_sim", spec_number: 40, cost_class: CostClass::L, deterministic: true },
|
|||
|
|
// §2.8 Project-level metadata
|
|||
|
|
LevelDescriptor { id: "cargo.workspace", spec_number: 41, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "cargo.lock", spec_number: 42, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "cargo.cfg", spec_number: 43, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "cargo.toolchain", spec_number: 44, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
// §2.9 External-tool derived
|
|||
|
|
LevelDescriptor { id: "clippy", spec_number: 45, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "rustdoc", spec_number: 46, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "rustfmt", spec_number: 47, cost_class: CostClass::F, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "tests", spec_number: 48, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
LevelDescriptor { id: "doctests", spec_number: 49, cost_class: CostClass::B, deterministic: true },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/// Look up the descriptor for a level id, if it is in the required-levels
|
|||
|
|
/// table.
|
|||
|
|
pub fn descriptor_for(id: &str) -> Option<&'static LevelDescriptor> {
|
|||
|
|
REQUIRED_LEVELS.iter().find(|d| d.id == id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Per-run configuration. Mirrors the CLI's user-controllable flags.
|
|||
|
|
#[derive(Debug, Clone, Default)]
|
|||
|
|
pub struct Config {
|
|||
|
|
pub enable_rustc_internal: bool,
|
|||
|
|
pub toolchain_override: Option<String>,
|
|||
|
|
/// Opt-in for spec §2.5 codegen levels (20–24). These drive `cargo
|
|||
|
|
/// rustc` and parse LLVM IR / assembly / object output, so they're
|
|||
|
|
/// the first stage that pays a real build cost. Off by default; the
|
|||
|
|
/// five codegen levels skip cleanly with a documented reason when
|
|||
|
|
/// not enabled.
|
|||
|
|
pub enable_codegen: bool,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// What each [`Level::extract`] call receives.
|
|||
|
|
#[derive(Debug, Clone)]
|
|||
|
|
pub struct Context {
|
|||
|
|
pub project_root: PathBuf,
|
|||
|
|
pub config: Config,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// What a successful [`Level::extract`] produces.
|
|||
|
|
///
|
|||
|
|
/// `vector` is a list of `(dotted_key, value)` pairs the level wishes to
|
|||
|
|
/// add to the flat namespace. `entities` is per-entity records grouped by
|
|||
|
|
/// `<level>.<entity_kind>` (e.g. `"ast.func"`), per spec §6.
|
|||
|
|
#[derive(Debug, Default, Clone)]
|
|||
|
|
pub struct LevelOutput {
|
|||
|
|
pub vector: Vec<(String, f64)>,
|
|||
|
|
pub entities: BTreeMap<String, Vec<EntityRecord>>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Non-fatal level outcomes. `Skipped` means "prerequisite missing,
|
|||
|
|
/// consumer should treat keys as absent"; `Failed` means "prerequisite
|
|||
|
|
/// present but extraction blew up". Both produce a `reason` in the §7
|
|||
|
|
/// report and do not abort the run.
|
|||
|
|
#[derive(Debug, thiserror::Error)]
|
|||
|
|
pub enum LevelError {
|
|||
|
|
#[error("{0}")]
|
|||
|
|
Skipped(String),
|
|||
|
|
#[error("{0}")]
|
|||
|
|
Failed(String),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// One representation level. Spec §12.
|
|||
|
|
pub trait Level: Send + Sync {
|
|||
|
|
fn id(&self) -> &'static str;
|
|||
|
|
fn cost_class(&self) -> CostClass;
|
|||
|
|
fn deterministic(&self) -> bool;
|
|||
|
|
fn extract(&self, ctx: &Context) -> Result<LevelOutput, LevelError>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Fatal errors that abort the entire run before producing an envelope
|
|||
|
|
/// (spec §7: "exits non-zero only if the orchestrator itself cannot
|
|||
|
|
/// proceed").
|
|||
|
|
#[derive(Debug, thiserror::Error)]
|
|||
|
|
pub enum OrchestrateError {
|
|||
|
|
#[error("project root {path:?} does not exist")]
|
|||
|
|
ProjectRootMissing { path: PathBuf },
|
|||
|
|
#[error("project root {path:?} is not a directory (pass the project's root directory, not a file)")]
|
|||
|
|
ProjectRootNotADirectory { path: PathBuf },
|
|||
|
|
#[error("project root {path:?} is not a Cargo project (no Cargo.toml found)")]
|
|||
|
|
NotACargoProject { path: PathBuf },
|
|||
|
|
#[error("could not probe rustc toolchain: {0}")]
|
|||
|
|
RustcProbeFailed(String),
|
|||
|
|
#[error("vector key {key:?} claimed by both level {first:?} and level {second:?}")]
|
|||
|
|
NamespaceCollision { key: String, first: String, second: String },
|
|||
|
|
#[error("level id {0:?} is not in the required-levels table (typo or unmapped level?)")]
|
|||
|
|
UnknownLevel(String),
|
|||
|
|
#[error("level id {0:?} was registered more than once")]
|
|||
|
|
DuplicateLevel(String),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Owns the registered levels and produces an [`OracleOutput`] when run.
|
|||
|
|
///
|
|||
|
|
/// `Runner` is intentionally dumb: register levels, call `run`. Higher-level
|
|||
|
|
/// orchestration (caching, parallelism) is out of scope for v1 — the rustc
|
|||
|
|
/// subprocess and codegen dominate wall-clock and aren't safely parallelisable
|
|||
|
|
/// within a single project.
|
|||
|
|
pub struct Runner {
|
|||
|
|
levels: Vec<Box<dyn Level>>,
|
|||
|
|
cstat_version: String,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Runner {
|
|||
|
|
pub fn new() -> Self {
|
|||
|
|
Self {
|
|||
|
|
levels: Vec::new(),
|
|||
|
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Override the `cstat_version` stamped into the envelope. The CLI sets
|
|||
|
|
/// this to its own package version so the user-visible tool's version
|
|||
|
|
/// is what shows up in the output.
|
|||
|
|
pub fn with_cstat_version(mut self, version: impl Into<String>) -> Self {
|
|||
|
|
self.cstat_version = version.into();
|
|||
|
|
self
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn register<L: Level + 'static>(&mut self, level: L) -> &mut Self {
|
|||
|
|
self.levels.push(Box::new(level));
|
|||
|
|
self
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Execute the run. See [`OrchestrateError`] for the conditions that
|
|||
|
|
/// produce a fatal error (per-level failures are observable in the
|
|||
|
|
/// envelope, not fatal).
|
|||
|
|
pub fn run(&self, ctx: &Context) -> Result<OracleOutput, OrchestrateError> {
|
|||
|
|
if !ctx.project_root.exists() {
|
|||
|
|
return Err(OrchestrateError::ProjectRootMissing {
|
|||
|
|
path: ctx.project_root.clone(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
if !ctx.project_root.is_dir() {
|
|||
|
|
return Err(OrchestrateError::ProjectRootNotADirectory {
|
|||
|
|
path: ctx.project_root.clone(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
if !ctx.project_root.join("Cargo.toml").is_file() {
|
|||
|
|
return Err(OrchestrateError::NotACargoProject {
|
|||
|
|
path: ctx.project_root.clone(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
self.validate_registrations()?;
|
|||
|
|
|
|||
|
|
let toolchain = probe_toolchain(&ctx.project_root, &ctx.config)
|
|||
|
|
.map_err(OrchestrateError::RustcProbeFailed)?;
|
|||
|
|
|
|||
|
|
let mut envelope = OracleOutput::empty(self.cstat_version.clone(), toolchain);
|
|||
|
|
envelope.config_digest = compute_config_digest(&ctx.config);
|
|||
|
|
|
|||
|
|
let wall_start = Instant::now();
|
|||
|
|
|
|||
|
|
// Tracks which level owns each emitted vector key, for the
|
|||
|
|
// namespace cross-check (anchor test 5).
|
|||
|
|
let mut owner_of: BTreeMap<String, String> = BTreeMap::new();
|
|||
|
|
let registered: HashSet<&str> = self.levels.iter().map(|l| l.id()).collect();
|
|||
|
|
|
|||
|
|
for level in &self.levels {
|
|||
|
|
let descriptor = *descriptor_for(level.id())
|
|||
|
|
.expect("validate_registrations ensures every registered id is known");
|
|||
|
|
let level_start = Instant::now();
|
|||
|
|
let result = level.extract(ctx);
|
|||
|
|
let elapsed = level_start.elapsed().as_secs_f64();
|
|||
|
|
|
|||
|
|
let report = match result {
|
|||
|
|
Ok(output) => {
|
|||
|
|
for (key, value) in output.vector {
|
|||
|
|
if let Some(prior) = owner_of.get(&key) {
|
|||
|
|
return Err(OrchestrateError::NamespaceCollision {
|
|||
|
|
key,
|
|||
|
|
first: prior.clone(),
|
|||
|
|
second: level.id().to_string(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
owner_of.insert(key.clone(), level.id().to_string());
|
|||
|
|
envelope.vector.insert(key.clone(), value);
|
|||
|
|
envelope.provenance.record(key, level.id());
|
|||
|
|
}
|
|||
|
|
for (kind, mut records) in output.entities {
|
|||
|
|
envelope
|
|||
|
|
.entities
|
|||
|
|
.entry(kind)
|
|||
|
|
.or_default()
|
|||
|
|
.append(&mut records);
|
|||
|
|
}
|
|||
|
|
LevelReport {
|
|||
|
|
status: LevelStatus::Ok,
|
|||
|
|
reason: None,
|
|||
|
|
recompute_cost_class: descriptor.cost_class,
|
|||
|
|
deterministic: descriptor.deterministic,
|
|||
|
|
extraction_seconds: elapsed,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Err(LevelError::Skipped(reason)) => LevelReport {
|
|||
|
|
status: LevelStatus::Skipped,
|
|||
|
|
reason: Some(reason),
|
|||
|
|
recompute_cost_class: descriptor.cost_class,
|
|||
|
|
deterministic: descriptor.deterministic,
|
|||
|
|
extraction_seconds: elapsed,
|
|||
|
|
},
|
|||
|
|
Err(LevelError::Failed(reason)) => LevelReport {
|
|||
|
|
status: LevelStatus::Failed,
|
|||
|
|
reason: Some(reason),
|
|||
|
|
recompute_cost_class: descriptor.cost_class,
|
|||
|
|
deterministic: descriptor.deterministic,
|
|||
|
|
extraction_seconds: elapsed,
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
envelope.levels.insert(level.id().to_string(), report);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Auto-fill: every required level not covered by a registered
|
|||
|
|
// Level appears as skipped/"not implemented". The contract from
|
|||
|
|
// spec §4 and anchor test 2: no required level is silently absent.
|
|||
|
|
for descriptor in REQUIRED_LEVELS {
|
|||
|
|
if !registered.contains(descriptor.id) {
|
|||
|
|
envelope.levels.insert(
|
|||
|
|
descriptor.id.to_string(),
|
|||
|
|
LevelReport {
|
|||
|
|
status: LevelStatus::Skipped,
|
|||
|
|
reason: Some("not implemented".to_string()),
|
|||
|
|
recompute_cost_class: descriptor.cost_class,
|
|||
|
|
deterministic: descriptor.deterministic,
|
|||
|
|
extraction_seconds: 0.0,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// meta.* scalars (spec §8). Always present so consumers can detect
|
|||
|
|
// degraded runs without scanning every key. `files_unparseable` is
|
|||
|
|
// derived from `entities.ast.file_errors` so AST levels never have
|
|||
|
|
// to touch the meta namespace directly.
|
|||
|
|
let levels_missing = envelope
|
|||
|
|
.levels
|
|||
|
|
.values()
|
|||
|
|
.filter(|r| !matches!(r.status, LevelStatus::Ok))
|
|||
|
|
.count() as f64;
|
|||
|
|
emit_meta_scalar(&mut envelope, &mut owner_of, "meta.levels_missing.count", levels_missing)?;
|
|||
|
|
let files_unparseable = envelope
|
|||
|
|
.entities
|
|||
|
|
.get("ast.file_errors")
|
|||
|
|
.map(|v| v.len())
|
|||
|
|
.unwrap_or(0) as f64;
|
|||
|
|
emit_meta_scalar(
|
|||
|
|
&mut envelope,
|
|||
|
|
&mut owner_of,
|
|||
|
|
"meta.files_unparseable.count",
|
|||
|
|
files_unparseable,
|
|||
|
|
)?;
|
|||
|
|
|
|||
|
|
envelope.wall_clock_seconds = wall_start.elapsed().as_secs_f64();
|
|||
|
|
Ok(envelope)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn validate_registrations(&self) -> Result<(), OrchestrateError> {
|
|||
|
|
let known: HashSet<&'static str> = REQUIRED_LEVELS.iter().map(|d| d.id).collect();
|
|||
|
|
let mut seen: BTreeSet<&str> = BTreeSet::new();
|
|||
|
|
for level in &self.levels {
|
|||
|
|
let id = level.id();
|
|||
|
|
if !known.contains(id) {
|
|||
|
|
return Err(OrchestrateError::UnknownLevel(id.to_string()));
|
|||
|
|
}
|
|||
|
|
if !seen.insert(id) {
|
|||
|
|
return Err(OrchestrateError::DuplicateLevel(id.to_string()));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Default for Runner {
|
|||
|
|
fn default() -> Self {
|
|||
|
|
Self::new()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn emit_meta_scalar(
|
|||
|
|
envelope: &mut OracleOutput,
|
|||
|
|
owner_of: &mut BTreeMap<String, String>,
|
|||
|
|
key: &str,
|
|||
|
|
value: f64,
|
|||
|
|
) -> Result<(), OrchestrateError> {
|
|||
|
|
if let Some(prior) = owner_of.get(key) {
|
|||
|
|
return Err(OrchestrateError::NamespaceCollision {
|
|||
|
|
key: key.to_string(),
|
|||
|
|
first: prior.clone(),
|
|||
|
|
second: "meta".to_string(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
owner_of.insert(key.to_string(), "meta".to_string());
|
|||
|
|
envelope.vector.insert(key.to_string(), value);
|
|||
|
|
envelope.provenance.record(key, "meta");
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn probe_toolchain(project_root: &Path, config: &Config) -> Result<Toolchain, String> {
|
|||
|
|
// Run rustc from inside the project so that rustup honours any
|
|||
|
|
// `rust-toolchain.toml` pin in the user's tree — that's the contract
|
|||
|
|
// spec §9 names. With no pin in sight this falls back to the default
|
|||
|
|
// toolchain on PATH, which is what we want.
|
|||
|
|
let output = Command::new("rustc")
|
|||
|
|
.arg("--version")
|
|||
|
|
.arg("--verbose")
|
|||
|
|
.current_dir(project_root)
|
|||
|
|
.output()
|
|||
|
|
.map_err(|e| format!("could not invoke rustc: {e}"))?;
|
|||
|
|
if !output.status.success() {
|
|||
|
|
return Err(format!(
|
|||
|
|
"rustc exited with {:?}: {}",
|
|||
|
|
output.status.code(),
|
|||
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|||
|
|
|
|||
|
|
let mut summary_version = String::new();
|
|||
|
|
let mut release = String::new();
|
|||
|
|
let mut host = String::new();
|
|||
|
|
for line in stdout.lines() {
|
|||
|
|
if let Some(rest) = line.strip_prefix("rustc ") {
|
|||
|
|
if summary_version.is_empty() {
|
|||
|
|
summary_version = rest
|
|||
|
|
.split_whitespace()
|
|||
|
|
.next()
|
|||
|
|
.unwrap_or_default()
|
|||
|
|
.to_string();
|
|||
|
|
}
|
|||
|
|
} else if let Some(rest) = line.strip_prefix("release: ") {
|
|||
|
|
release = rest.trim().to_string();
|
|||
|
|
} else if let Some(rest) = line.strip_prefix("host: ") {
|
|||
|
|
host = rest.trim().to_string();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let version = if !release.is_empty() { release } else { summary_version.clone() };
|
|||
|
|
let channel = config
|
|||
|
|
.toolchain_override
|
|||
|
|
.clone()
|
|||
|
|
.unwrap_or_else(|| classify_channel(&summary_version));
|
|||
|
|
|
|||
|
|
Ok(Toolchain { channel, version, host })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn classify_channel(version: &str) -> String {
|
|||
|
|
if version.contains("-nightly") {
|
|||
|
|
"nightly".to_string()
|
|||
|
|
} else if version.contains("-beta") {
|
|||
|
|
"beta".to_string()
|
|||
|
|
} else if version.contains("-dev") {
|
|||
|
|
"dev".to_string()
|
|||
|
|
} else {
|
|||
|
|
"stable".to_string()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// FNV-1a 64 over a canonical text rendering of the config. Deterministic
|
|||
|
|
/// across runs and hosts; no external crate dependency for stage 2.
|
|||
|
|
fn compute_config_digest(config: &Config) -> String {
|
|||
|
|
let canonical = format!(
|
|||
|
|
"enable_rustc_internal={};enable_codegen={};toolchain={}",
|
|||
|
|
config.enable_rustc_internal,
|
|||
|
|
config.enable_codegen,
|
|||
|
|
config.toolchain_override.as_deref().unwrap_or("")
|
|||
|
|
);
|
|||
|
|
let mut hash: u64 = 0xcbf29ce484222325;
|
|||
|
|
for byte in canonical.as_bytes() {
|
|||
|
|
hash ^= *byte as u64;
|
|||
|
|
hash = hash.wrapping_mul(0x100000001b3);
|
|||
|
|
}
|
|||
|
|
format!("fnv1a64:{:016x}", hash)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use std::fs;
|
|||
|
|
|
|||
|
|
fn fixture_path() -> PathBuf {
|
|||
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/single-file-lib")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn ctx_for(path: PathBuf) -> Context {
|
|||
|
|
Context {
|
|||
|
|
project_root: path,
|
|||
|
|
config: Config::default(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
struct FakeLevel {
|
|||
|
|
id: &'static str,
|
|||
|
|
output: LevelOutput,
|
|||
|
|
}
|
|||
|
|
impl Level for FakeLevel {
|
|||
|
|
fn id(&self) -> &'static str { self.id }
|
|||
|
|
fn cost_class(&self) -> CostClass { CostClass::F }
|
|||
|
|
fn deterministic(&self) -> bool { true }
|
|||
|
|
fn extract(&self, _: &Context) -> Result<LevelOutput, LevelError> {
|
|||
|
|
Ok(self.output.clone())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn empty_runner_marks_every_required_level_as_skipped_not_implemented() {
|
|||
|
|
let envelope = Runner::new().run(&ctx_for(fixture_path())).unwrap();
|
|||
|
|
|
|||
|
|
// Spec §4: every required level appears.
|
|||
|
|
assert_eq!(envelope.levels.len(), REQUIRED_LEVELS.len());
|
|||
|
|
for descriptor in REQUIRED_LEVELS {
|
|||
|
|
let report = envelope
|
|||
|
|
.levels
|
|||
|
|
.get(descriptor.id)
|
|||
|
|
.unwrap_or_else(|| panic!("missing level {}", descriptor.id));
|
|||
|
|
assert_eq!(report.status, LevelStatus::Skipped, "{}", descriptor.id);
|
|||
|
|
assert_eq!(
|
|||
|
|
report.reason.as_deref(),
|
|||
|
|
Some("not implemented"),
|
|||
|
|
"{}",
|
|||
|
|
descriptor.id
|
|||
|
|
);
|
|||
|
|
// Descriptor metadata propagates onto the report.
|
|||
|
|
assert_eq!(report.recompute_cost_class, descriptor.cost_class);
|
|||
|
|
assert_eq!(report.deterministic, descriptor.deterministic);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// meta.* scalar is present and consistent with the level-status count.
|
|||
|
|
let missing = envelope
|
|||
|
|
.vector
|
|||
|
|
.get("meta.levels_missing.count")
|
|||
|
|
.expect("meta.levels_missing.count missing");
|
|||
|
|
assert_eq!(missing as usize, REQUIRED_LEVELS.len());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn cross_check_rejects_two_levels_claiming_the_same_vector_key() {
|
|||
|
|
let mut runner = Runner::new();
|
|||
|
|
runner
|
|||
|
|
.register(FakeLevel {
|
|||
|
|
id: "fs.layout",
|
|||
|
|
output: LevelOutput {
|
|||
|
|
vector: vec![("shared.key".into(), 1.0)],
|
|||
|
|
entities: BTreeMap::new(),
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
.register(FakeLevel {
|
|||
|
|
id: "fs.bytes",
|
|||
|
|
output: LevelOutput {
|
|||
|
|
vector: vec![("shared.key".into(), 2.0)],
|
|||
|
|
entities: BTreeMap::new(),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
let err = runner.run(&ctx_for(fixture_path())).unwrap_err();
|
|||
|
|
match err {
|
|||
|
|
OrchestrateError::NamespaceCollision { key, first, second } => {
|
|||
|
|
assert_eq!(key, "shared.key");
|
|||
|
|
assert_eq!(first, "fs.layout");
|
|||
|
|
assert_eq!(second, "fs.bytes");
|
|||
|
|
}
|
|||
|
|
other => panic!("expected NamespaceCollision, got {other:?}"),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn unknown_level_id_is_rejected_before_run_begins() {
|
|||
|
|
let mut runner = Runner::new();
|
|||
|
|
runner.register(FakeLevel {
|
|||
|
|
id: "definitely-not-a-real-level",
|
|||
|
|
output: LevelOutput::default(),
|
|||
|
|
});
|
|||
|
|
let err = runner.run(&ctx_for(fixture_path())).unwrap_err();
|
|||
|
|
assert!(matches!(err, OrchestrateError::UnknownLevel(_)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn duplicate_registration_is_rejected() {
|
|||
|
|
let mut runner = Runner::new();
|
|||
|
|
runner
|
|||
|
|
.register(FakeLevel {
|
|||
|
|
id: "fs.layout",
|
|||
|
|
output: LevelOutput::default(),
|
|||
|
|
})
|
|||
|
|
.register(FakeLevel {
|
|||
|
|
id: "fs.layout",
|
|||
|
|
output: LevelOutput::default(),
|
|||
|
|
});
|
|||
|
|
let err = runner.run(&ctx_for(fixture_path())).unwrap_err();
|
|||
|
|
assert!(matches!(err, OrchestrateError::DuplicateLevel(_)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn registered_ok_level_does_not_inflate_levels_missing_count() {
|
|||
|
|
let mut runner = Runner::new();
|
|||
|
|
runner.register(FakeLevel {
|
|||
|
|
id: "fs.layout",
|
|||
|
|
output: LevelOutput {
|
|||
|
|
vector: vec![("fs.global.file_count".into(), 1.0)],
|
|||
|
|
entities: BTreeMap::new(),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
let envelope = runner.run(&ctx_for(fixture_path())).unwrap();
|
|||
|
|
let missing = envelope
|
|||
|
|
.vector
|
|||
|
|
.get("meta.levels_missing.count")
|
|||
|
|
.unwrap();
|
|||
|
|
// 49 required levels minus the one registered OK level.
|
|||
|
|
assert_eq!(missing as usize, REQUIRED_LEVELS.len() - 1);
|
|||
|
|
// The OK level's report reflects status=ok.
|
|||
|
|
assert_eq!(
|
|||
|
|
envelope.levels.get("fs.layout").unwrap().status,
|
|||
|
|
LevelStatus::Ok
|
|||
|
|
);
|
|||
|
|
// Provenance attributes the key to the level that emitted it.
|
|||
|
|
assert_eq!(
|
|||
|
|
envelope
|
|||
|
|
.provenance
|
|||
|
|
.0
|
|||
|
|
.get("fs.global.file_count")
|
|||
|
|
.map(|v| v.as_slice()),
|
|||
|
|
Some(["fs.layout".to_string()].as_slice())
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn missing_project_root_is_a_fatal_error() {
|
|||
|
|
let runner = Runner::new();
|
|||
|
|
let ctx = ctx_for(PathBuf::from("/nonexistent/path/for/cstat/test"));
|
|||
|
|
let err = runner.run(&ctx).unwrap_err();
|
|||
|
|
assert!(matches!(err, OrchestrateError::ProjectRootMissing { .. }));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn directory_without_cargo_toml_is_a_fatal_error() {
|
|||
|
|
let tmp = std::env::temp_dir().join(format!(
|
|||
|
|
"cstat-orchestrate-test-{}",
|
|||
|
|
std::process::id()
|
|||
|
|
));
|
|||
|
|
fs::create_dir_all(&tmp).unwrap();
|
|||
|
|
let runner = Runner::new();
|
|||
|
|
let err = runner.run(&ctx_for(tmp.clone())).unwrap_err();
|
|||
|
|
fs::remove_dir_all(&tmp).ok();
|
|||
|
|
assert!(matches!(err, OrchestrateError::NotACargoProject { .. }));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn pointing_project_root_at_a_file_is_a_fatal_error() {
|
|||
|
|
let tmp = std::env::temp_dir().join(format!(
|
|||
|
|
"cstat-orchestrate-test-file-{}",
|
|||
|
|
std::process::id()
|
|||
|
|
));
|
|||
|
|
fs::write(&tmp, b"not a directory").unwrap();
|
|||
|
|
let runner = Runner::new();
|
|||
|
|
let err = runner.run(&ctx_for(tmp.clone())).unwrap_err();
|
|||
|
|
fs::remove_file(&tmp).ok();
|
|||
|
|
assert!(matches!(
|
|||
|
|
err,
|
|||
|
|
OrchestrateError::ProjectRootNotADirectory { .. }
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
}
|