Files
Aura/crates/aura-cli/src/project.rs
T
claude b39fd63396 refactor: split the aura-std roster into C28 layer crates
Phase 4 of the Stratification milestone. aura-std held four C28 ladder
layers in one roster; this cuts them into layer-aligned, aura-core-only
node crates so the import direction is enforced by the crate graph:

- aura-std        — engine nodes only (arithmetic/logic/rolling + sinks)
- aura-market     — session, resample
- aura-strategy   — bias, stops, sizer, cost-model machinery
- aura-backtest   — sim_broker, position_management
- aura-vocabulary — the relocated closed std_vocabulary roster

Node modules move verbatim (byte-identical renames); consumers are
rewired by import path only. A new structural test
(aura-vocabulary/tests/c28_layering.rs) asserts each node crate's
[dependencies] stay within its C28-permitted inner set, catching the
acyclic-but-outward violation the compiler misses.

Behaviour byte-identical: full workspace suite green (1448 tests), no
golden edited, clippy -D warnings clean. C28 Status block updated.

closes #288
2026-07-19 20:28:20 +02:00

907 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Project discovery + the cdylib load boundary (cycle 0102, C13/C16).
//!
//! `discover_from` walks up to the nearest `Aura.toml` (the way cargo finds
//! `Cargo.toml`); `load` locates the project's cdylib via `cargo metadata`,
//! loads it **load-and-hold** (leaked, never unloaded), validates the C tier
//! of the descriptor BEFORE touching any Rust-ABI field, then applies the
//! vocabulary charter. `Env` is the per-invocation context every verb reads:
//! merged resolver, runs root, data path, provenance.
use aura_core::PrimitiveBuilder;
use aura_core::project::{
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice,
};
use aura_engine::ProjectProvenance;
use aura_registry::{Registry, TraceStore};
use aura_vocabulary::{std_vocabulary, std_vocabulary_types};
use sha2::{Digest, Sha256};
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// Parsed `Aura.toml` — static project context only (C17): paths, nothing else.
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraToml {
#[serde(default)]
pub paths: AuraPaths,
#[serde(default)]
pub nodes: AuraNodes,
}
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraPaths {
/// Data archive root; default: the data-server default.
#[serde(default)]
pub data: Option<String>,
/// Registry root, relative to the project root; default `"runs"`.
#[serde(default)]
pub runs: Option<PathBuf>,
}
/// Node-crate pointers (C17: static context). Paths resolve relative to the
/// project root (`Path::join`, so absolute paths pass through). The format
/// carries a list; this iteration loads exactly one entry and refuses more.
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraNodes {
#[serde(default)]
pub crates: Vec<String>,
}
#[derive(Debug)]
pub enum ProjectError {
Toml(PathBuf, String),
CargoMetadata(String),
ArtifactMissing(PathBuf),
Load(PathBuf, String),
NotAProjectDylib(PathBuf),
Incompatible { what: &'static str, dylib: String, host: String },
Charter(String),
MultiCrate(usize),
NodesPointer { pointer: String, inner: Box<ProjectError> },
/// #245: the `[nodes]` pointer target does not exist — checked before
/// handing off to `load_crate`, so this never reaches the cargo-metadata
/// probe (whose raw os-error text would otherwise leak through).
PointerDirMissing(PathBuf),
}
impl fmt::Display for ProjectError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Toml(p, e) => write!(f, "failed to parse {}: {e}", p.display()),
Self::CargoMetadata(e) => write!(f, "cargo metadata failed: {e}"),
Self::ArtifactMissing(p) => write!(
f,
"project dylib not found at {} — run `cargo build` in the \
project first (or pass --release to load the release build)",
p.display()
),
Self::Load(p, e) => write!(f, "failed to load {}: {e}", p.display()),
Self::NotAProjectDylib(p) => write!(
f,
"{} exports no valid AURA_PROJECT descriptor (not an aura \
project dylib, or an incompatible descriptor layout)",
p.display()
),
Self::Incompatible { what, dylib, host } => write!(
f,
"project dylib is ABI-incompatible: {what} mismatch \
(dylib: {dylib}, host: {host}) — rebuild the project with \
the host's toolchain/aura-core"
),
Self::Charter(e) => write!(f, "project vocabulary rejected: {e}"),
Self::MultiCrate(n) => write!(
f,
"multi-crate loading is not yet supported (found {n} entries \
in [nodes].crates — only one is accepted)"
),
Self::NodesPointer { pointer, inner } => write!(
f,
"node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}"
),
Self::PointerDirMissing(p) => write!(f, "{} does not exist", p.display()),
}
}
}
/// A discovered project. `native` is present only when a node crate is
/// loaded (the crate's `Library` is leaked — load-and-hold, 'static).
#[derive(Debug)]
pub struct ProjectEnv {
pub root: PathBuf,
pub toml: AuraToml,
pub commit: Option<String>,
pub native: Option<NativeEnv>,
}
/// The loaded node crate: vocabulary + identity stamps.
#[derive(Debug)]
pub struct NativeEnv {
pub namespace: String,
pub dylib_sha256: String,
resolver: fn(&str) -> Option<PrimitiveBuilder>,
type_id_list: &'static [&'static str],
}
impl ProjectEnv {
fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
self.native.as_ref().and_then(|n| (n.resolver)(type_id))
}
}
/// The per-invocation context every verb reads. Outside a project every
/// accessor collapses to today's literal defaults (byte-identical behaviour).
pub struct Env {
project: Option<ProjectEnv>,
}
impl Env {
pub fn std() -> Self {
Self { project: None }
}
pub fn with_project(p: ProjectEnv) -> Self {
Self { project: Some(p) }
}
/// The one resolver every verb consumes: project first, then std. The
/// charter makes overlap impossible, so order is not load-bearing.
pub fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
match &self.project {
Some(p) => p.resolve(type_id).or_else(|| std_vocabulary(type_id)),
None => std_vocabulary(type_id),
}
}
/// project std type ids, project first (for `introspect --vocabulary`).
pub fn type_ids(&self) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
if let Some(n) = self.project.as_ref().and_then(|p| p.native.as_ref()) {
out.extend_from_slice(n.type_id_list);
}
out.extend_from_slice(std_vocabulary_types());
out
}
/// The registry root: `<project>/<paths.runs|"runs">` inside a project,
/// the literal `runs` outside (today's behaviour).
pub fn runs_root(&self) -> PathBuf {
match &self.project {
Some(p) => {
let runs = p.toml.paths.runs.clone().unwrap_or_else(|| "runs".into());
p.root.join(runs)
}
None => PathBuf::from("runs"),
}
}
pub fn registry(&self) -> Registry {
Registry::open(self.runs_root().join("runs.jsonl"))
}
pub fn trace_store(&self) -> TraceStore {
TraceStore::open(self.runs_root())
}
/// The data archive root: `paths.data` inside a project when set
/// (relative values resolved against the project root, same as
/// `runs_root()` and the `[nodes]` pointers), the data-server default
/// otherwise.
pub fn data_path(&self) -> String {
self.project
.as_ref()
.and_then(|p| {
p.toml.paths.data.as_ref().map(|data| {
let path = Path::new(data);
if path.is_absolute() {
data.clone()
} else {
p.root.join(path).to_string_lossy().to_string()
}
})
})
.unwrap_or_else(|| data_server::DEFAULT_DATA_PATH.to_string())
}
pub fn provenance(&self) -> Option<ProjectProvenance> {
self.project.as_ref().map(|p| ProjectProvenance {
namespace: p.native.as_ref().map(|n| n.namespace.clone()),
dylib_sha256: p.native.as_ref().map(|n| n.dylib_sha256.clone()),
commit: p.commit.clone(),
})
}
}
/// Walk up from `start` to the nearest directory containing `Aura.toml`.
pub fn discover_from(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
if d.join("Aura.toml").is_file() {
return Some(d.to_path_buf());
}
dir = d.parent();
}
None
}
fn parse_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
let path = root.join("Aura.toml");
let text = std::fs::read_to_string(&path)
.map_err(|e| ProjectError::Toml(path.clone(), e.to_string()))?;
toml::from_str(&text).map_err(|e| ProjectError::Toml(path, e.to_string()))
}
/// Read a project's `Aura.toml` (for scaffold-side tooling that must not
/// trigger a crate load, e.g. `aura nodes new`).
pub fn read_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
parse_aura_toml(root)
}
/// Derive the cdylib artifact path from `cargo metadata` output (pure —
/// unit-testable on a canned JSON document).
fn artifact_from_metadata(
metadata_json: &str,
release: bool,
) -> Result<PathBuf, ProjectError> {
let v: serde_json::Value = serde_json::from_str(metadata_json)
.map_err(|e| ProjectError::CargoMetadata(e.to_string()))?;
let target_dir = v["target_directory"]
.as_str()
.ok_or_else(|| ProjectError::CargoMetadata("no target_directory".into()))?;
let packages = v["packages"]
.as_array()
.ok_or_else(|| ProjectError::CargoMetadata("no packages".into()))?;
let lib_name = packages
.iter()
.flat_map(|p| p["targets"].as_array().into_iter().flatten())
.find(|t| {
t["kind"]
.as_array()
.is_some_and(|k| k.iter().any(|s| s.as_str() == Some("cdylib")))
})
.and_then(|t| t["name"].as_str())
.ok_or_else(|| {
ProjectError::CargoMetadata(
"no cdylib target in the project crate (is `crate-type = \
[\"cdylib\"]` set?)"
.into(),
)
})?;
let profile = if release { "release" } else { "debug" };
let file = format!(
"{}{}{}",
std::env::consts::DLL_PREFIX,
lib_name.replace('-', "_"),
std::env::consts::DLL_SUFFIX
);
Ok(Path::new(target_dir).join(profile).join(file))
}
fn artifact_path(root: &Path, release: bool) -> Result<PathBuf, ProjectError> {
let out = std::process::Command::new("cargo")
.args(["metadata", "--format-version", "1", "--no-deps"])
.current_dir(root)
.output()
.map_err(|e| ProjectError::CargoMetadata(e.to_string()))?;
if !out.status.success() {
return Err(ProjectError::CargoMetadata(
String::from_utf8_lossy(&out.stderr).trim().to_string(),
));
}
artifact_from_metadata(&String::from_utf8_lossy(&out.stdout), release)
}
/// The vocabulary charter (decision log on the milestone reference issue):
/// non-empty namespace; every listed id `\<ns\>::`-prefixed; no duplicate in
/// the merged project std set; every listed id resolves (list↔resolver
/// cross-check). Pure — unit-testable without a dylib.
fn check_charter(
namespace: &str,
ids: &[&str],
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<(), ProjectError> {
if namespace.is_empty() {
return Err(ProjectError::Charter("empty namespace".into()));
}
let prefix = format!("{namespace}::");
let mut seen = std::collections::BTreeSet::new();
for id in ids {
if !id.starts_with(&prefix) {
return Err(ProjectError::Charter(format!(
"type id `{id}` lacks the project prefix `{prefix}`"
)));
}
if !seen.insert(*id) {
return Err(ProjectError::Charter(format!("duplicate type id `{id}`")));
}
if std_vocabulary(id).is_some() || std_vocabulary_types().contains(id) {
return Err(ProjectError::Charter(format!(
"type id `{id}` collides with the std vocabulary"
)));
}
if resolve(id).is_none() {
return Err(ProjectError::Charter(format!(
"listed type id `{id}` does not resolve through the project \
resolver (list/resolver drift)"
)));
}
}
Ok(())
}
fn project_commit(root: &Path) -> Option<String> {
let head = std::process::Command::new("git")
.args(["-C"])
.arg(root)
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())?;
let mut sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
let dirty = std::process::Command::new("git")
.args(["-C"])
.arg(root)
.args(["status", "--porcelain"])
.output()
.ok()
.filter(|o| o.status.success())?;
if !dirty.stdout.is_empty() {
sha.push_str("-dirty");
}
Some(sha)
}
/// Validate the C tier of a descriptor's already-extracted fields — pure
/// value checks, no dylib/pointer work (the raw reads happen once, in
/// `load`, before this runs). Front-to-back: magic, descriptor version,
/// rustc stamp, aura-core stamp, then the namespace. Returns the namespace
/// on success. Unit-testable without a dylib by constructing the fields
/// directly, the same way `check_charter` is testable without a resolver.
fn validate_c_tier(
magic: u64,
descriptor_version: u32,
rustc_version: StrSlice,
aura_core_version: StrSlice,
namespace: StrSlice,
dylib_path: &Path,
) -> Result<String, ProjectError> {
if magic != AURA_DESCRIPTOR_MAGIC {
return Err(ProjectError::NotAProjectDylib(dylib_path.to_path_buf()));
}
if descriptor_version != AURA_DESCRIPTOR_VERSION {
return Err(ProjectError::Incompatible {
what: "descriptor version",
dylib: descriptor_version.to_string(),
host: AURA_DESCRIPTOR_VERSION.to_string(),
});
}
let dylib_rustc = unsafe { rustc_version.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
if dylib_rustc != RUSTC_VERSION {
return Err(ProjectError::Incompatible {
what: "rustc version",
dylib: dylib_rustc.to_string(),
host: RUSTC_VERSION.to_string(),
});
}
let dylib_core = unsafe { aura_core_version.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
if dylib_core != CORE_VERSION {
return Err(ProjectError::Incompatible {
what: "aura-core version",
dylib: dylib_core.to_string(),
host: CORE_VERSION.to_string(),
});
}
unsafe { namespace.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))
.map(str::to_string)
}
/// Render a `SystemTime` as a human-readable UTC timestamp with a 4-digit
/// year (`YYYY-MM-DD HH:MM:SS UTC`). Hand-rolled (Howard Hinnant's
/// civil-from-days algorithm) rather than pulling in a date-formatting
/// dependency for this one warning line.
fn format_mtime(t: SystemTime) -> String {
let secs = t
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let days = secs.div_euclid(86_400);
let day_secs = secs.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC",
day_secs / 3600,
(day_secs % 3600) / 60,
day_secs % 60
)
}
/// Days-since-epoch -> (year, month, day), UTC civil calendar. See
/// <https://howardhinnant.github.io/date_algorithms.html#civil_from_days>.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
(if m <= 2 { y + 1 } else { y }, m, d)
}
/// The role-2 authoring loop's staleness check (#237): if `source_mtime` is
/// strictly newer than `dylib_mtime`, a forgotten `cargo build` would run the
/// previous dylib and report plausible-but-stale numbers. Returns the warning
/// line naming both timestamps, or `None` when the dylib is not stale (the
/// only case where this fires — a fresh build stays silent).
fn stale_warning(
dylib_path: &Path,
dylib_mtime: SystemTime,
source_mtime: SystemTime,
) -> Option<String> {
if source_mtime > dylib_mtime {
Some(format!(
"warning: {} may be stale — dylib built {} but a project source \
file was modified {} (run `cargo build` to refresh); proceeding \
with the existing dylib",
dylib_path.display(),
format_mtime(dylib_mtime),
format_mtime(source_mtime),
))
} else {
None
}
}
/// The newest modification time among the project's `Cargo.toml` and every
/// file under its `src/` tree (the set that, changed, invalidates the built
/// dylib). `None` if neither exists / is readable — never fatal, staleness
/// is a warning, not a refusal.
fn newest_source_mtime(root: &Path) -> Option<SystemTime> {
let mut newest: Option<SystemTime> = None;
let mut consider = |t: SystemTime| {
if newest.is_none_or(|n| t > n) {
newest = Some(t);
}
};
if let Ok(meta) = std::fs::metadata(root.join("Cargo.toml"))
&& let Ok(t) = meta.modified()
{
consider(t);
}
let mut stack = vec![root.join("src")];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(ft) if ft.is_dir() => stack.push(path),
Ok(_) => {
if let Ok(t) = entry.metadata().and_then(|m| m.modified()) {
consider(t);
}
}
Err(_) => {}
}
}
}
newest
}
/// Discover the project's tier and load it. Data-only (no `[nodes]`, no root
/// crate) loads nothing; a `[nodes]` pointer or a root Cargo.toml routes into
/// the unchanged crate pipeline (`load_crate`).
pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
let toml = parse_aura_toml(root)?;
let native = match toml.nodes.crates.as_slice() {
[] => {
if root.join("Cargo.toml").is_file() {
Some(load_crate(root, release)?)
} else {
None
}
}
[pointer] => {
let dir = root.join(pointer);
if !dir.is_dir() {
return Err(ProjectError::NodesPointer {
pointer: pointer.clone(),
inner: Box::new(ProjectError::PointerDirMissing(dir)),
});
}
Some(load_crate(&dir, release).map_err(|e| ProjectError::NodesPointer {
pointer: pointer.clone(),
inner: Box::new(e),
})?)
}
many => return Err(ProjectError::MultiCrate(many.len())),
};
Ok(ProjectEnv {
root: root.to_path_buf(),
toml,
commit: project_commit(root),
native,
})
}
/// Locate, load (load-and-hold), verify, and charter-check a node crate's
/// dylib.
fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectError> {
let dylib_path = artifact_path(crate_root, release)?;
if !dylib_path.is_file() {
return Err(ProjectError::ArtifactMissing(dylib_path));
}
if let Ok(dylib_mtime) = std::fs::metadata(&dylib_path).and_then(|m| m.modified())
&& let Some(source_mtime) = newest_source_mtime(crate_root)
&& let Some(warning) = stale_warning(&dylib_path, dylib_mtime, source_mtime)
{
eprintln!("{warning}");
}
let bytes = std::fs::read(&dylib_path)
.map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?;
let dylib_sha256 = format!("{:x}", Sha256::digest(&bytes));
// Load-and-hold: leak the Library so every pointer read from the
// descriptor is valid for 'static. There is deliberately no unload path
// (one-shot process; ledger C13 note).
let lib = unsafe { libloading::Library::new(&dylib_path) }
.map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?;
let lib: &'static libloading::Library = Box::leak(Box::new(lib));
let sym = unsafe { lib.get::<*const ProjectDescriptor>(AURA_PROJECT_SYMBOL) }
.map_err(|_| ProjectError::NotAProjectDylib(dylib_path.clone()))?;
let desc_ptr: *const ProjectDescriptor = *sym;
if desc_ptr.is_null() {
return Err(ProjectError::NotAProjectDylib(dylib_path));
}
// ---- C tier, validated front-to-back; no Rust-ABI field touched yet ----
let magic = unsafe { (*desc_ptr).magic };
let descriptor_version = unsafe { (*desc_ptr).descriptor_version };
let rustc_version = unsafe { (*desc_ptr).rustc_version };
let aura_core_version = unsafe { (*desc_ptr).aura_core_version };
let namespace_stamp = unsafe { (*desc_ptr).namespace };
let namespace = validate_c_tier(
magic,
descriptor_version,
rustc_version,
aura_core_version,
namespace_stamp,
&dylib_path,
)?;
// ---- Rust tier: stamps match, the ABI is trusted from here on ----
let desc: &'static ProjectDescriptor = unsafe { &*desc_ptr };
let resolver = desc.vocabulary;
let type_id_list = (desc.type_ids)();
check_charter(&namespace, type_id_list, &|t| resolver(t))?;
Ok(NativeEnv { namespace, dylib_sha256, resolver, type_id_list })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discover_walks_up_to_aura_toml() {
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-disc");
let _ = std::fs::remove_dir_all(&tmp);
let nested = tmp.join("a/b/c");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(discover_from(&nested), None);
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
assert_eq!(discover_from(&nested), Some(tmp.clone()));
assert_eq!(discover_from(&tmp), Some(tmp.clone()));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn aura_toml_parses_a_nodes_pointer_list() {
let t: AuraToml = toml::from_str("[nodes]\ncrates = [\"../my-nodes\"]").unwrap();
assert_eq!(t.nodes.crates, vec!["../my-nodes".to_string()]);
let t: AuraToml = toml::from_str("").unwrap();
assert!(t.nodes.crates.is_empty());
}
/// #241: an Aura.toml with neither a [nodes] pointer nor a root crate is
/// the data-only tier — it loads with no native env, resolves std-only,
/// and stamps commit-only provenance.
#[test]
fn data_only_project_loads_without_a_crate() {
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-dataonly");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap();
let p = load(&tmp, false).expect("data-only load");
assert!(p.native.is_none());
let env = Env::with_project(p);
assert!(env.resolve("SMA").is_some());
assert!(env.resolve("demo::Identity").is_none());
assert_eq!(env.runs_root(), tmp.join("runs"));
let prov = env.provenance().expect("a data-only project is a project");
assert!(prov.namespace.is_none());
assert!(prov.dylib_sha256.is_none());
std::fs::remove_dir_all(&tmp).ok();
}
/// #254: a relative `paths.data` must resolve against the project root
/// (`p.root`), matching `runs_root()`'s and the `[nodes]` pointers'
/// resolution — not against the invoking process's cwd, which silently
/// changes the data location depending on the caller's working directory.
#[test]
fn data_path_resolves_a_relative_paths_data_against_the_project_root() {
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-datarel");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[paths]\ndata = \"data\"\n").unwrap();
let p = load(&tmp, false).expect("data-only load");
let env = Env::with_project(p);
assert_eq!(env.data_path(), tmp.join("data").to_string_lossy().to_string());
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn two_nodes_pointers_refuse_with_multi_crate() {
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-multi");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap();
let e = load(&tmp, false).unwrap_err();
assert!(e.to_string().contains("multi-crate loading is not yet supported"), "{e}");
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn a_broken_nodes_pointer_names_the_pointer() {
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-badptr");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap();
let e = load(&tmp, false).unwrap_err();
let msg = e.to_string();
assert!(msg.contains("../nope"), "{msg}");
assert!(msg.contains("[nodes]"), "{msg}");
std::fs::remove_dir_all(&tmp).ok();
}
/// #245: a `[nodes]` pointer at a directory that does not exist refuses
/// with a clean message — it names the missing directory and keeps the
/// `[nodes]` pointer context, but does NOT leak the raw `cargo metadata`
/// toolchain error. That wrap is reserved for a directory that exists but
/// is not a valid crate; a plainly-absent directory should say exactly
/// that (`load` still refuses → CLI exit 1).
#[test]
fn a_missing_nodes_pointer_dir_refuses_without_the_cargo_metadata_leak() {
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-missingptr");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
// `missing-node-crate` is never created — the pointer target is absent.
std::fs::write(
tmp.join("Aura.toml"),
"[nodes]\ncrates = [\"missing-node-crate\"]\n",
)
.unwrap();
let e = load(&tmp, false).unwrap_err();
let msg = e.to_string();
// names the offending pointer path ...
assert!(msg.contains("missing-node-crate"), "names the pointer path: {msg}");
// ... says plainly that the directory is not there ...
assert!(msg.contains("does not exist"), "states the directory is absent: {msg}");
// ... keeps the [nodes] pointer context ...
assert!(msg.contains("[nodes]"), "keeps the pointer context: {msg}");
// ... and does not leak the raw toolchain error for a simple absence.
assert!(
!msg.contains("cargo metadata"),
"no cargo-metadata leak for an absent directory: {msg}"
);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn aura_toml_parses_empty_partial_and_unknown_keys() {
let t: AuraToml = toml::from_str("").unwrap();
assert_eq!(t, AuraToml::default());
let t: AuraToml = toml::from_str("[paths]\nruns = \"r\"").unwrap();
assert_eq!(t.paths.runs, Some(PathBuf::from("r")));
assert_eq!(t.paths.data, None);
// unknown keys tolerated (forward-compat; serde default is lenient)
let t: AuraToml = toml::from_str("[future]\nx = 1").unwrap();
assert_eq!(t, AuraToml::default());
}
#[test]
fn artifact_path_derives_from_metadata_json() {
let json = r#"{
"target_directory": "/tmp/proj/target",
"packages": [{"targets": [
{"kind": ["cdylib"], "name": "demo-project"}
]}]
}"#;
let p = artifact_from_metadata(json, false).unwrap();
let expect = format!(
"/tmp/proj/target/debug/{}demo_project{}",
std::env::consts::DLL_PREFIX,
std::env::consts::DLL_SUFFIX
);
assert_eq!(p, PathBuf::from(expect));
let p = artifact_from_metadata(json, true).unwrap();
assert!(p.to_string_lossy().contains("/release/"));
// no cdylib target -> named error
let bad = r#"{"target_directory":"/t","packages":[{"targets":[{"kind":["lib"],"name":"x"}]}]}"#;
assert!(matches!(
artifact_from_metadata(bad, false),
Err(ProjectError::CargoMetadata(_))
));
}
fn none_resolver(_: &str) -> Option<PrimitiveBuilder> {
None
}
#[test]
fn charter_rejects_each_violation_and_accepts_valid() {
let ok_resolver = |t: &str| {
if t == "p::A" || t == "p::B" { std_vocabulary("SMA") } else { None }
};
// valid
assert!(check_charter("p", &["p::A", "p::B"], &ok_resolver).is_ok());
// empty namespace
assert!(matches!(
check_charter("", &[], &none_resolver),
Err(ProjectError::Charter(_))
));
// unprefixed id
let e = check_charter("p", &["A"], &ok_resolver).unwrap_err();
assert!(e.to_string().contains("lacks the project prefix"));
// duplicate id
let e = check_charter("p", &["p::A", "p::A"], &ok_resolver).unwrap_err();
assert!(e.to_string().contains("duplicate"));
// list/resolver drift
let e = check_charter("p", &["p::C"], &ok_resolver).unwrap_err();
assert!(e.to_string().contains("does not resolve"));
}
fn matching_stamps() -> (u64, u32, StrSlice, StrSlice) {
(
AURA_DESCRIPTOR_MAGIC,
AURA_DESCRIPTOR_VERSION,
StrSlice::new(RUSTC_VERSION),
StrSlice::new(CORE_VERSION),
)
}
#[test]
fn validate_c_tier_accepts_matching_stamps() {
let (magic, version, rustc, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let ns = validate_c_tier(magic, version, rustc, core, StrSlice::new("demo"), &path)
.unwrap();
assert_eq!(ns, "demo");
}
/// A magic mismatch means the symbol is not an `AURA_PROJECT` descriptor
/// at all (foreign dylib) — refused as `NotAProjectDylib`, not
/// `Incompatible` (there is no known-good stamp to compare against).
#[test]
fn validate_c_tier_rejects_magic_mismatch() {
let (_, version, rustc, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let err = validate_c_tier(0xdead_beef, version, rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
}
#[test]
fn validate_c_tier_rejects_descriptor_version_mismatch() {
let (magic, _, rustc, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let err = validate_c_tier(magic, 99, rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "descriptor version", .. }
));
}
#[test]
fn validate_c_tier_rejects_rustc_version_mismatch() {
let (magic, version, _, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let bad_rustc = StrSlice::new("rustc 0.0.0-fake");
let err = validate_c_tier(magic, version, bad_rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "rustc version", .. }
));
}
#[test]
fn validate_c_tier_rejects_aura_core_version_mismatch() {
let (magic, version, rustc, _) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let bad_core = StrSlice::new("9.9.9-fake");
let err = validate_c_tier(magic, version, rustc, bad_core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "aura-core version", .. }
));
}
/// A null stamp pointer (e.g. a zeroed/corrupt descriptor) refuses rather
/// than dereferencing it, same as the standalone `StrSlice::as_str` test
/// in aura-core — here exercised through the loader's own refusal path.
#[test]
fn validate_c_tier_rejects_null_stamp() {
let (magic, version, _, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let null_rustc = StrSlice { ptr: std::ptr::null(), len: 0 };
let err = validate_c_tier(magic, version, null_rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
}
/// A non-UTF-8 stamp (foreign/corrupt bytes at the pointer) refuses
/// rather than mangling a comparison or panicking.
#[test]
fn validate_c_tier_rejects_non_utf8_stamp() {
let (magic, version, rustc, _) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
static BAD: [u8; 2] = [0xFF, 0xFE];
let bad_core = StrSlice { ptr: BAD.as_ptr(), len: BAD.len() };
let err = validate_c_tier(magic, version, rustc, bad_core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
}
#[test]
fn env_std_collapses_to_current_defaults() {
let env = Env::std();
assert_eq!(env.runs_root(), PathBuf::from("runs"));
assert_eq!(env.data_path(), data_server::DEFAULT_DATA_PATH.to_string());
assert!(env.provenance().is_none());
assert!(env.resolve("SMA").is_some());
assert!(env.resolve("demo::Identity").is_none());
assert!(env.type_ids().contains(&"SMA"));
}
/// The role-2 authoring-loop staleness check (#237) is a pure comparison:
/// a source mtime strictly newer than the dylib's produces a warning
/// naming both timestamps in human-readable (4-digit-year) form.
#[test]
fn stale_warning_fires_and_names_both_timestamps_when_source_is_newer() {
use std::time::{Duration, UNIX_EPOCH};
let dylib_mtime = UNIX_EPOCH + Duration::from_secs(993_859_200); // 2001-06-30
let source_mtime = UNIX_EPOCH + Duration::from_secs(2_003_702_400); // 2033-06-30
let path = PathBuf::from("/tmp/x.so");
let msg = stale_warning(&path, dylib_mtime, source_mtime).expect("source is newer");
assert!(msg.contains("2001"), "names the dylib's mtime: {msg}");
assert!(msg.contains("2033"), "names the source's newer mtime: {msg}");
}
/// The inverse of the above: a fresh (or equally-timed) dylib produces no
/// warning at all — staleness is the only trigger, never routine builds.
#[test]
fn stale_warning_is_silent_when_source_is_not_newer() {
use std::time::{Duration, UNIX_EPOCH};
let t = UNIX_EPOCH + Duration::from_secs(1_000_000_000);
let path = PathBuf::from("/tmp/x.so");
assert!(stale_warning(&path, t, t).is_none(), "equal mtimes: not stale");
assert!(
stale_warning(&path, t, t - Duration::from_secs(10)).is_none(),
"older source: not stale"
);
}
}