mind-body: pin the library's public surface as a whole

tests/public_surface.rs reads the library source the way a consumer sees it
and compares every pub item with its signature, pub fields, derives, impl
headers and every path-looking string literal to tests/public_surface.txt.
A new pub static or pub fn under any name, a derive onto Grant, or a root
read from another path fails it. It replaces the three compile_fail
doctests that named with_root, TEST_ROOT and control_root. Dev-dependencies
syn, quote and proc-macro2, already in the graph through serde's derive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 20:15:19 +02:00
commit 19e6c050ed
5 changed files with 354 additions and 13 deletions

3
Cargo.lock generated
View file

@ -1122,8 +1122,11 @@ dependencies = [
"eureka-state",
"huginn-mind",
"libc",
"proc-macro2",
"quote",
"rust_decimal",
"serde",
"syn 2.0.119",
"tempfile",
]

View file

@ -30,7 +30,13 @@ serde = { version = "1", features = ["derive"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# `syn`, `quote` and `proc-macro2` read the library's own source for
# tests/public_surface.rs, which pins its public surface. All three are already
# in the workspace's graph (serde's derive uses them); they are test-only.
[dev-dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
tempfile = "3"
[lib]

View file

@ -263,19 +263,6 @@ pub fn load_state(source: &impl ControlSource) -> Result<ControlState> {
/// let _ = mind_body::control::Grant::for_test;
/// ```
///
/// ```compile_fail
/// // the test seam that moves the root is not in the library build, public or not
/// use mind_body::control::with_root;
/// ```
///
/// ```compile_fail
/// use mind_body::control::TEST_ROOT;
/// ```
///
/// ```compile_fail
/// use mind_body::control::control_root;
/// ```
///
/// The same preamble with the real call compiles:
///
/// ```

View file

@ -0,0 +1,273 @@
//! The library's public surface, pinned as a whole.
//!
//! The brake's rule is that no caller can name a root, a source or a way to make
//! a `Grant`. A test that names the routes it knows (`with_root`, `TEST_ROOT`)
//! passes on every route it does not name. This one reads the library's source
//! the way a consumer sees it and compares everything a consumer could reach to
//! `public_surface.txt`: every `pub` item with its signature, every `pub` field,
//! the derives and the header of every impl, and every string literal that looks
//! like a path. A new `pub static`, a new `pub fn` under any name, a `Clone` or
//! `Default` derived onto `Grant`, a root read from a file other than the
//! constant: each is a line this file does not have, and the test fails.
//!
//! Items under `#[cfg(test)]` are not the library and are skipped; `cfg(not(test))`
//! and the `unix`/`windows` splits are the library and are read. Any other `cfg`, a `#[path]`, an item macro, a
//! `macro_rules!`, an `extern` block or an exported symbol attribute would hide
//! an item from this reader, so each fails the test outright. A seam that is
//! `pub(crate)` and not `cfg(test)` is outside this reader's concern and inside
//! clippy's: it is dead code in the library build, and `-D warnings` refuses it.
//!
//! A deliberate change to the surface is a change to `public_surface.txt` in the
//! same commit, which is where a reviewer sees it. The failure prints the full
//! current surface to copy from.
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use proc_macro2::{Delimiter, Group, TokenStream, TokenTree};
use quote::ToTokens;
use syn::{Attribute, Fields, ImplItem, Item, Visibility};
const SNAPSHOT: &str = include_str!("public_surface.txt");
/// Attributes that would put an item outside this reader's sight or export it by name.
const HIDING: [&str; 7] = ["path", "no_mangle", "export_name", "link_section", "macro_export", "macro_use", "cfg_attr"];
fn is_pub(visibility: &Visibility) -> bool {
matches!(visibility, Visibility::Public(_))
}
fn without_docs(stream: TokenStream) -> TokenStream {
let tokens: Vec<TokenTree> = stream.into_iter().collect();
let mut out = Vec::new();
let mut at = 0;
while at < tokens.len() {
if matches!(&tokens[at], TokenTree::Punct(p) if p.as_char() == '#') {
let mut group_at = at + 1;
if matches!(tokens.get(group_at), Some(TokenTree::Punct(p)) if p.as_char() == '!') {
group_at += 1;
}
if let Some(TokenTree::Group(group)) = tokens.get(group_at) {
let is_doc = group.delimiter() == Delimiter::Bracket
&& matches!(group.stream().into_iter().next(), Some(TokenTree::Ident(name)) if name == "doc");
if is_doc {
at = group_at + 1;
continue;
}
}
}
match &tokens[at] {
TokenTree::Group(group) => out.push(TokenTree::Group(Group::new(group.delimiter(), without_docs(group.stream())))),
other => out.push(other.clone()),
}
at += 1;
}
out.into_iter().collect()
}
fn text(node: impl ToTokens) -> String {
without_docs(node.to_token_stream()).to_string()
}
/// The string literals in `stream` that look like a path.
fn path_literals(stream: TokenStream, out: &mut BTreeSet<String>) {
for token in stream {
match token {
TokenTree::Group(group) => path_literals(group.stream(), out),
TokenTree::Literal(literal) => {
let rendered = literal.to_string();
if rendered.starts_with('"') && rendered.contains('/') {
out.insert(format!("path-literal {rendered}"));
}
}
_ => {}
}
}
}
fn attributes(item: &Item) -> &[Attribute] {
match item {
Item::Const(i) => &i.attrs,
Item::Enum(i) => &i.attrs,
Item::ExternCrate(i) => &i.attrs,
Item::Fn(i) => &i.attrs,
Item::ForeignMod(i) => &i.attrs,
Item::Impl(i) => &i.attrs,
Item::Macro(i) => &i.attrs,
Item::Mod(i) => &i.attrs,
Item::Static(i) => &i.attrs,
Item::Struct(i) => &i.attrs,
Item::Trait(i) => &i.attrs,
Item::TraitAlias(i) => &i.attrs,
Item::Type(i) => &i.attrs,
Item::Union(i) => &i.attrs,
Item::Use(i) => &i.attrs,
_ => &[],
}
}
/// Whether the library build contains what these attributes sit on.
fn in_library(attrs: &[Attribute], at: &str) -> bool {
let mut keep = true;
for attr in attrs {
let rendered = attr.to_token_stream().to_string().replace(' ', "");
assert!(!HIDING.iter().any(|word| attr.path().is_ident(*word)) && !rendered.contains("no_mangle") && !rendered.contains("export_name"), "{at}: `{rendered}` hides an item from the surface test");
if attr.path().is_ident("cfg") {
match rendered.as_str() {
"#[cfg(test)]" => keep = false,
// Both sides of a platform split are the library, and both are read.
"#[cfg(not(test))]" | "#[cfg(unix)]" | "#[cfg(not(unix))]" | "#[cfg(windows)]" | "#[cfg(not(windows))]" => {}
other => panic!("{at}: `{other}`: the surface test reads only cfg(test), cfg(not(test)) and the unix/windows splits"),
}
}
}
keep
}
fn kept_fields(fields: &Fields) -> Vec<String> {
match fields {
Fields::Named(named) => named.named.iter().filter(|f| is_pub(&f.vis)).map(|f| format!("{}: {}", text(&f.ident), text(&f.ty))).collect(),
Fields::Unnamed(unnamed) => unnamed.unnamed.iter().enumerate().filter(|(_, f)| is_pub(&f.vis)).map(|(i, f)| format!("{i}: {}", text(&f.ty))).collect(),
Fields::Unit => Vec::new(),
}
}
fn derives(attrs: &[Attribute]) -> String {
attrs.iter().filter(|a| a.path().is_ident("derive") || a.path().is_ident("repr")).map(text).collect::<Vec<_>>().join(" ")
}
fn walk(items: &[Item], path: &str, dir: &Path, out: &mut BTreeSet<String>) {
for item in items {
if !in_library(attributes(item), path) {
continue;
}
if !matches!(item, Item::Impl(_)) {
path_literals(without_docs(item.to_token_stream()), out);
}
match item {
Item::Fn(f) if is_pub(&f.vis) => {
out.insert(format!("{path}: {}", text(&f.sig)));
}
Item::Const(c) if is_pub(&c.vis) => {
out.insert(format!("{path}: const {} : {}", c.ident, text(&c.ty)));
}
Item::Static(s) if is_pub(&s.vis) => {
out.insert(format!("{path}: static {} : {} mutable={}", s.ident, text(&s.ty), matches!(s.mutability, syn::StaticMutability::Mut(_))));
}
Item::Struct(s) if is_pub(&s.vis) => {
out.insert(format!("{path}: struct {}{} {} pub-fields[{}]", s.ident, text(&s.generics), derives(&s.attrs), kept_fields(&s.fields).join(", ")));
}
Item::Enum(e) if is_pub(&e.vis) => {
out.insert(format!("{path}: {}", text(Item::Enum(e.clone()))));
}
Item::Union(u) if is_pub(&u.vis) => {
out.insert(format!("{path}: {}", text(Item::Union(u.clone()))));
}
Item::Trait(t) if is_pub(&t.vis) => {
out.insert(format!("{path}: {}", text(Item::Trait(t.clone()))));
}
Item::TraitAlias(t) if is_pub(&t.vis) => {
out.insert(format!("{path}: {}", text(Item::TraitAlias(t.clone()))));
}
Item::Type(t) if is_pub(&t.vis) => {
out.insert(format!("{path}: {}", text(Item::Type(t.clone()))));
}
Item::Use(u) if is_pub(&u.vis) => {
out.insert(format!("{path}: {}", text(u)));
}
Item::Impl(i) => {
let header = match &i.trait_ {
Some((bang, tr, _)) => format!("impl{} {}{} for {}", text(&i.generics), text(bang), text(tr), text(&i.self_ty)),
None => format!("impl{} {}", text(&i.generics), text(&i.self_ty)),
};
out.insert(format!("{path}: {header}"));
path_literals(without_docs(i.generics.to_token_stream()), out);
for member in &i.items {
let attrs = match member {
ImplItem::Const(m) => &m.attrs,
ImplItem::Fn(m) => &m.attrs,
ImplItem::Type(m) => &m.attrs,
ImplItem::Macro(m) => &m.attrs,
_ => &[][..],
};
if !in_library(attrs, path) {
continue;
}
path_literals(without_docs(member.to_token_stream()), out);
if i.trait_.is_none() {
match member {
ImplItem::Fn(f) if is_pub(&f.vis) => {
out.insert(format!("{path}: impl {}: {}", text(&i.self_ty), text(&f.sig)));
}
ImplItem::Const(c) if is_pub(&c.vis) => {
out.insert(format!("{path}: impl {}: const {} : {}", text(&i.self_ty), c.ident, text(&c.ty)));
}
ImplItem::Type(t) if is_pub(&t.vis) => {
out.insert(format!("{path}: impl {}: {}", text(&i.self_ty), text(t)));
}
ImplItem::Macro(_) | ImplItem::Verbatim(_) => panic!("{path}: an impl member the surface test cannot read"),
_ => {}
}
}
}
}
Item::Mod(m) => {
let name = m.ident.to_string();
if is_pub(&m.vis) {
out.insert(format!("{path}: mod {name}"));
}
let inner = format!("{path}::{name}");
let below = dir.join(&name);
match &m.content {
Some((_, content)) => walk(content, &inner, &below, out),
None => {
let file = [dir.join(format!("{name}.rs")), below.join("mod.rs")].into_iter().find(|candidate| candidate.exists());
let file = file.unwrap_or_else(|| panic!("{inner}: no source file"));
let parsed = syn::parse_file(&fs::read_to_string(&file).unwrap()).unwrap_or_else(|e| panic!("{}: {e}", file.display()));
if in_library(&parsed.attrs, &inner) {
walk(&parsed.items, &inner, &below, out);
}
}
}
}
Item::Macro(_) => panic!("{path}: an item macro in library source: the surface test cannot see what it makes"),
Item::ForeignMod(_) | Item::ExternCrate(_) | Item::Verbatim(_) => panic!("{path}: an item the surface test cannot read"),
_ => {}
}
}
}
fn surface() -> BTreeSet<String> {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let parsed = syn::parse_file(&fs::read_to_string(src.join("lib.rs")).unwrap()).unwrap();
let mut out = BTreeSet::new();
walk(&parsed.items, "mind_body", &src, &mut out);
out
}
#[test]
fn the_librarys_public_surface_is_the_committed_one() {
let actual = surface();
let committed: BTreeSet<String> = SNAPSHOT.lines().filter(|line| !line.is_empty()).map(str::to_string).collect();
let added: Vec<_> = actual.difference(&committed).collect();
let removed: Vec<_> = committed.difference(&actual).collect();
assert!(
added.is_empty() && removed.is_empty(),
"the public surface changed.\nadded:\n{}\nremoved:\n{}\nthe full current surface, for tests/public_surface.txt:\n{}",
added.iter().map(|l| format!(" {l}")).collect::<Vec<_>>().join("\n"),
removed.iter().map(|l| format!(" {l}")).collect::<Vec<_>>().join("\n"),
actual.iter().cloned().collect::<Vec<_>>().join("\n"),
);
}
#[test]
fn the_reader_sees_what_it_claims_to() {
// The snapshot must hold the brake's own names, or the reader is blind to them.
let actual = surface();
for needle in ["mind_body::control: fn read_effective", "mind_body::control: struct Grant", "path-literal \"/etc/gamecult/minds\""] {
assert!(actual.iter().any(|line| line.contains(needle)), "{needle} is not in the surface");
}
// And nothing under cfg(test) leaked in.
assert!(!actual.iter().any(|line| line.contains("for_test") || line.contains("with_root") || line.contains("TEST_ROOT")), "a cfg(test) item is in the surface");
}

View file

@ -0,0 +1,72 @@
mind_body: mod cli
mind_body: mod control
mind_body: mod launch
mind_body: mod queue
mind_body::cli: fn operator_name (sudo_user : Option < String > , user : Option < String >) -> String
mind_body::cli: fn run (args : & [String] , root : & Path , set_by : & str , now : DateTime < Utc > , out : & mut impl Write) -> Result < () >
mind_body::control: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum HeldReason { BrakeAbsent , BrakeHeld , DialAbsent , DialOutOfBounds , Undecodable , }
mind_body::control: # [derive (Debug , PartialEq , Eq)] pub enum Effective { Held { reason : HeldReason } , Released (Grant) , }
mind_body::control: const BRAKE_KEY : & str
mind_body::control: const CONTROL_ROOT : & str
mind_body::control: const DIAL_KEY : & str
mind_body::control: fn control_path (root : & Path , instance : & Slug) -> Result < PathBuf >
mind_body::control: fn heat_max () -> Decimal
mind_body::control: fn heat_min () -> Decimal
mind_body::control: fn load_state (source : & impl ControlSource) -> Result < ControlState >
mind_body::control: fn read_effective (instance : & Slug) -> Effective
mind_body::control: impl BurnRate
mind_body::control: impl BurnRate: fn in_bounds (& self) -> bool
mind_body::control: impl ControlSource for FileSource
mind_body::control: impl ControlState
mind_body::control: impl ControlWriter
mind_body::control: impl ControlWriter: fn open (path : impl Into < PathBuf >) -> Result < Self >
mind_body::control: impl ControlWriter: fn set_brake (& mut self , released : bool , set_at : DateTime < Utc > , set_by : & str) -> Result < () >
mind_body::control: impl ControlWriter: fn set_dial (& mut self , dial : BurnRate) -> Result < () >
mind_body::control: impl FileSource
mind_body::control: impl FileSource: fn new (path : impl Into < PathBuf >) -> Self
mind_body::control: impl Grant
mind_body::control: impl Grant: fn cadence (& self) -> Duration
mind_body::control: impl Grant: fn instance (& self) -> & Slug
mind_body::control: impl Grant: fn run_cap_usd (& self) -> Decimal
mind_body::control: impl std :: error :: Error for UnsafeStore
mind_body::control: impl std :: fmt :: Display for UnsafeStore
mind_body::control: pub trait ControlSource { fn snapshot (& self) -> Result < Vec < CultCacheEnvelope > > ; }
mind_body::control: struct Brake # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[released: bool, set_at: DateTime < Utc >, set_by: String]
mind_body::control: struct BurnRate # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[heat: Decimal, base_cooldown_s: u32, base_run_usd: Decimal, set_at: DateTime < Utc >, set_by: String]
mind_body::control: struct ControlState # [derive (Clone , Debug , PartialEq , Eq , Default)] pub-fields[brake: Option < Brake >, dial: Option < BurnRate >]
mind_body::control: struct ControlWriter pub-fields[]
mind_body::control: struct FileSource pub-fields[]
mind_body::control: struct Grant # [derive (Debug , PartialEq , Eq)] pub-fields[]
mind_body::control: struct UnsafeStore # [derive (Debug)] pub-fields[path: PathBuf, mode: u32, uid: u32]
mind_body::launch: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Declined { Admission (MindRefusal) , Conflict , AlreadyAdmitted , StartFailed , GrantForOtherInstance , PersonaRunTakesNoClaims , }
mind_body::launch: # [derive (Clone , Debug , PartialEq , Eq)] pub enum LaunchOutcome { Launched { run : PipelineRef , } , NotQueued { item : PipelineRef , } , Tripped { item : PipelineRef , question : PipelineRef , } , Busy { run : PipelineRef , } , Refused (Declined) , }
mind_body::launch: const START_GRACE_S : i64
mind_body::launch: fn open_and_launch (ports : & Ports , request : LaunchRequest) -> Result < LaunchOutcome >
mind_body::launch: fn run_label (now : DateTime < Utc >) -> Label
mind_body::launch: impl Clock for SystemClock
mind_body::launch: impl Default for SystemdLauncher
mind_body::launch: impl Launcher for SystemdLauncher
mind_body::launch: impl Systemctl for RealSystemctl
mind_body::launch: impl SystemdLauncher
mind_body::launch: impl SystemdLauncher: fn new () -> Self
mind_body::launch: pub trait Clock { fn now (& self) -> DateTime < Utc > ; }
mind_body::launch: pub trait Launcher { fn start (& self , instance : & Slug , turn : RunTurn) -> Result < () > ; fn alive (& self , instance : & Slug , turn : RunTurn) -> bool ; }
mind_body::launch: pub trait Systemctl { fn run (& self , args : & [& str]) -> Result < SystemctlReply > ; }
mind_body::launch: struct LaunchRequest pub-fields[turn: RunTurn, claims: Vec < PipelineRef >, agent: String, grant: Grant]
mind_body::launch: struct Ports< 'a > pub-fields[mind: & 'a dyn MindPort, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str]
mind_body::launch: struct SystemClock pub-fields[]
mind_body::launch: struct SystemctlReply pub-fields[success: bool, stdout: String]
mind_body::launch: struct SystemdLauncher pub-fields[]
mind_body::queue: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Breaker { Clear , Trip , }
mind_body::queue: const CONSECUTIVE : usize
mind_body::queue: const EMPTY_SHARE : usize
mind_body::queue: const WINDOW : usize
mind_body::queue: fn breaker (mind : & dyn MindPort , item : & PipelineRef , ending : & [PipelineRef]) -> Result < Breaker >
mind_body::queue: fn queue (mind : & dyn MindPort , instance : & Slug , ending : & [PipelineRef]) -> Result < Vec < PipelineRef > >
mind_body::queue: impl HuginnMind < 'static >
mind_body::queue: impl HuginnMind < 'static >: fn new (client : HuginnClient) -> Self
mind_body::queue: impl MindPort for HuginnMind < '_ >
mind_body::queue: impl< 'a > HuginnMind < 'a >
mind_body::queue: pub trait MindPort { fn instance (& self) -> & Slug ; fn query (& self , selection : & Selection) -> Result < PipelineSelectionPage > ; fn admit (& self , agent : & str , session : & str , documents : Vec < PipelineDocument >) -> Result < PipelineAdmissionOutcome > ; }
mind_body::queue: struct HuginnMind< 'a > pub-fields[]
path-literal "/etc/gamecult/minds"