use super::{MatrixError, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, OpenOptions}; use std::io::Write as _; use std::path::{Path, PathBuf}; const BASELINE_PATH: &str = "api/SEMVER-BASELINE.json"; const REPORT_PATH: &str = "api/SEMVER-AUDIT.md"; const MEMBER_PATH: &str = "api/RUST-MAPPING.tsv"; const TYPE_PATH: &str = "api/RUST-TYPES.tsv"; const UPSTREAM_COMMIT: &str = "2aa70bb68513b39795da5d13c88f31b86e85a3ba"; const PUBLIC_CRATES: [(&str, &str); 15] = [ ("libremetaverse", "crates/libremetaverse"), ("libremetaverse-types", "crates/libremetaverse-types"), ( "libremetaverse-structured-data", "crates/libremetaverse-structured-data", ), ("libremetaverse-imaging", "crates/libremetaverse-imaging"), ( "libremetaverse-imaging-skia", "crates/libremetaverse-imaging-skia", ), ( "libremetaverse-prim-mesher", "crates/libremetaverse-prim-mesher", ), ( "libremetaverse-rendering-simple", "crates/libremetaverse-rendering-simple", ), ( "libremetaverse-rendering-mesh-foundry", "crates/libremetaverse-rendering-mesh-foundry", ), ( "libremetaverse-lsl-tools", "crates/libremetaverse-lsl-tools", ), ("libremetaverse-rlv", "crates/libremetaverse-rlv"), ( "libremetaverse-utilities", "crates/libremetaverse-utilities", ), ( "libremetaverse-voice-vivox", "crates/libremetaverse-voice-vivox", ), ( "libremetaverse-voice-webrtc", "crates/libremetaverse-voice-webrtc", ), ("libremetaverse-openjpeg", "crates/libremetaverse-openjpeg"), ("libremetaverse-opus", "crates/libremetaverse-opus"), ]; const REQUIRED_NON_EXHAUSTIVE_ERRORS: [(&str, &str); 7] = [ ("crates/libremetaverse-types/src/shim.rs", "Error"), ("crates/libremetaverse-openjpeg/src/lib.rs", "Error"), ( "crates/libremetaverse-voice-webrtc/src/native.rs", "WebRtcError", ), ( "crates/libremetaverse/src/client_core.rs", "ClientCoreError", ), ( "crates/libremetaverse/src/udp_transport.rs", "UdpTransportError", ), ( "crates/libremetaverse-voice-vivox/src/protocol.rs", "VivoxError", ), ( "crates/libremetaverse-rlv/src/protocol.rs", "RlvParseErrorKind", ), ]; const REQUIRED_THREAD_SAFE_TRAITS: [(&str, &str); 6] = [ ( "crates/libremetaverse/src/generated.rs", "pub trait IBakingTextureProvider: std::any::Any + Send + Sync", ), ( "crates/libremetaverse/src/generated.rs", "pub trait IGridClient: Send + Sync", ), ( "crates/libremetaverse/src/generated.rs", "pub trait ICurrentOutfitPolicy: Send + Sync", ), ( "crates/libremetaverse-rlv/src/generated.rs", "pub trait IRlvActionCallbacks: Send + Sync", ), ( "crates/libremetaverse-rlv/src/generated.rs", "pub trait IRlvQueryCallbacks: Send + Sync", ), ( "crates/libremetaverse-voice-webrtc/src/compatibility.rs", "pub trait IVoiceLogger: Send + Sync", ), ]; const OWNERSHIP_TERMS: [&str; 14] = [ "none", "owned_value", "shared_self", "shared_or_mutable_self", "mutable_self", "owned", "optional_owned", "mutable_borrow", "optional_mutable_borrow", "mutable_output", "shared_borrow", "optional_shared", "shared", "stored_callback", ]; type Row = BTreeMap; #[derive(Debug)] struct QualityCounts { intentional_differences: usize, descriptive_overloads: usize, async_functions: usize, send_boxed_futures: usize, thread_safe_callbacks: usize, enum_types: usize, bitflag_types: usize, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct ApiBaseline { schema: u32, release: String, upstream_commit: String, mapped_public_types: usize, mapped_members: usize, external_signature_types: usize, support_traits: usize, intentional_differences: usize, descriptive_overloads: usize, async_functions: usize, send_boxed_futures: usize, thread_safe_callbacks: usize, thread_safe_dyn_traits: usize, non_exhaustive_error_enums: usize, enum_types: usize, bitflag_types: usize, unexplained_mappings: usize, accidental_public_dependency_types: usize, type_mapping_sha256: String, member_mapping_sha256: String, exported_mapping_sha256: String, rust_declaration_sha256: String, crates: Vec, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct CrateSurface { name: String, version: String, features: BTreeMap>, mapped_types: usize, mapped_members: usize, public_declarations: usize, declaration_sha256: String, } #[derive(Debug, Serialize)] struct ApiEvidence<'a> { schema: u32, release: &'a str, upstream_commit: &'a str, baseline_sha256: String, mapped_public_types: usize, mapped_members: usize, intentional_differences: usize, unexplained_mappings: usize, accidental_public_dependency_types: usize, thread_safe_dyn_traits: usize, non_exhaustive_error_enums: usize, downstream_compile_fixture: &'static str, representative_port: &'static str, status: &'static str, } /// Writes the reviewed first-release API/SemVer baseline and audit report. /// /// # Errors /// /// Returns an error when the mapped/exported surface or a quality invariant is /// incomplete, or when the deterministic output cannot be written. pub fn write_api_baseline(root: &Path) -> Result<()> { let baseline = inspect(root)?; fs::write(root.join(BASELINE_PATH), baseline_json(&baseline)?)?; fs::write(root.join(REPORT_PATH), render_report(&baseline))?; Ok(()) } /// Audits the current API against the checked first-release `SemVer` baseline. /// /// # Errors /// /// Returns an error for missing/unexplained mappings, a changed public surface, /// weak async/thread/error contracts, feature drift, leaked dependency types, /// stale reports, or evidence I/O failures. pub fn audit_api_surface(root: &Path, evidence: &Path) -> Result<()> { if evidence.exists() { return Err(MatrixError::new(format!( "{} already exists; preserve or remove it before rerunning the audit", evidence.display() ))); } let current = inspect(root)?; let baseline_bytes = fs::read(root.join(BASELINE_PATH))?; let baseline: ApiBaseline = serde_json::from_slice(&baseline_bytes)?; if baseline != current { return Err(MatrixError::new( "public API differs from api/SEMVER-BASELINE.json; review the SemVer impact before recording a new baseline", )); } if fs::read_to_string(root.join(REPORT_PATH))? != render_report(¤t) { return Err(MatrixError::new( "API/SemVer audit report is stale; run api-baseline-write after review", )); } let record = ApiEvidence { schema: 1, release: ¤t.release, upstream_commit: ¤t.upstream_commit, baseline_sha256: sha256(&baseline_bytes), mapped_public_types: current.mapped_public_types, mapped_members: current.mapped_members, intentional_differences: current.intentional_differences, unexplained_mappings: current.unexplained_mappings, accidental_public_dependency_types: current.accidental_public_dependency_types, thread_safe_dyn_traits: current.thread_safe_dyn_traits, non_exhaustive_error_enums: current.non_exhaustive_error_enums, downstream_compile_fixture: "tests/api-compile", representative_port: "tests/semver-port", status: "ok", }; if let Some(parent) = evidence.parent() { fs::create_dir_all(parent)?; } let mut file = OpenOptions::new() .write(true) .create_new(true) .open(evidence)?; serde_json::to_writer_pretty(&mut file, &record)?; file.write_all(b"\n")?; file.sync_all()?; Ok(()) } fn inspect(root: &Path) -> Result { let (type_header, type_rows) = read_tsv(&root.join(TYPE_PATH))?; let (member_header, member_rows) = read_tsv(&root.join(MEMBER_PATH))?; validate_mapping_headers(&type_header, &member_header)?; validate_unique(&type_rows, "csharp_type_id", "type mapping")?; validate_unique(&member_rows, "csharp_id", "member mapping")?; validate_unique(&member_rows, "rust_item_path", "Rust member destination")?; validate_members(&member_rows)?; validate_types(&type_rows)?; let (public_type_ids, member_ids) = mapped_ids(&type_rows, &member_rows); let (exported_types, exported_members, exported_bytes) = exported_mappings(root)?; if public_type_ids != exported_types || member_ids != exported_members { return Err(MatrixError::new(format!( "mapped/exported coverage mismatch: types {}/{}, members {}/{}", exported_types.len(), public_type_ids.len(), exported_members.len(), member_ids.len() ))); } validate_non_exhaustive_errors(root)?; validate_thread_safe_traits(root)?; validate_dependency_roots(&member_rows)?; let workspace = fs::read_to_string(root.join("Cargo.toml"))?; let release = package_value(&workspace, "version") .ok_or_else(|| MatrixError::new("workspace package version is missing"))?; let (crates, declarations) = inspect_crates(root, &release, &type_rows, &member_rows)?; let counts = quality_counts(&type_rows, &member_rows); let baseline = ApiBaseline { schema: 1, release, upstream_commit: UPSTREAM_COMMIT.to_owned(), mapped_public_types: public_type_ids.len(), mapped_members: member_ids.len(), external_signature_types: type_rows .iter() .filter(|row| { !field(row, "csharp_type_id").starts_with("T:LibreMetaverse") && field(row, "source_kind") != "referenced_support_trait" }) .count(), support_traits: type_rows .iter() .filter(|row| field(row, "source_kind") == "referenced_support_trait") .count(), intentional_differences: counts.intentional_differences, descriptive_overloads: counts.descriptive_overloads, async_functions: counts.async_functions, send_boxed_futures: counts.send_boxed_futures, thread_safe_callbacks: counts.thread_safe_callbacks, thread_safe_dyn_traits: REQUIRED_THREAD_SAFE_TRAITS.len(), non_exhaustive_error_enums: REQUIRED_NON_EXHAUSTIVE_ERRORS.len(), enum_types: counts.enum_types, bitflag_types: counts.bitflag_types, unexplained_mappings: 0, accidental_public_dependency_types: 0, type_mapping_sha256: sha256(&fs::read(root.join(TYPE_PATH))?), member_mapping_sha256: sha256(&fs::read(root.join(MEMBER_PATH))?), exported_mapping_sha256: sha256(&exported_bytes), rust_declaration_sha256: sha256(declarations.join("\n").as_bytes()), crates, }; validate_totals(&baseline)?; Ok(baseline) } fn validate_mapping_headers(type_header: &[String], member_header: &[String]) -> Result<()> { require_columns( type_header, &[ "csharp_type_id", "csharp_signature", "source_kind", "rust_crate", "rust_path", "mapping_decision", "inheritance_mapping", "enum_mapping", "status", ], TYPE_PATH, )?; require_columns( member_header, &[ "csharp_id", "csharp_kind", "csharp_signature", "rust_crate", "rust_item_path", "rust_signature", "ownership", "asyncness", "error_model", "overload_decision", "mapping_kind", "status", ], MEMBER_PATH, ) } fn mapped_ids(type_rows: &[Row], member_rows: &[Row]) -> (BTreeSet, BTreeSet) { let public_type_ids = type_rows .iter() .filter(|row| { field(row, "csharp_type_id").starts_with("T:LibreMetaverse") && field(row, "source_kind") != "referenced_support_trait" }) .map(|row| field(row, "csharp_type_id").to_owned()) .collect::>(); let member_ids = member_rows .iter() .map(|row| field(row, "csharp_id").to_owned()) .collect::>(); (public_type_ids, member_ids) } fn validate_dependency_roots(member_rows: &[Row]) -> Result<()> { let accidental_roots = accidental_dependency_roots(member_rows); if !accidental_roots.is_empty() { return Err(MatrixError::new(format!( "mapped public signatures expose dependency roots: {}", accidental_roots.into_iter().collect::>().join(", ") ))); } Ok(()) } fn inspect_crates( root: &Path, release: &str, type_rows: &[Row], member_rows: &[Row], ) -> Result<(Vec, Vec)> { let mut declarations = Vec::new(); let mut crates = Vec::new(); for (name, relative) in PUBLIC_CRATES { let directory = root.join(relative); let manifest = fs::read_to_string(directory.join("Cargo.toml"))?; if package_value(&manifest, "name").as_deref() != Some(name) { return Err(MatrixError::new(format!( "public crate manifest mismatch for {name}" ))); } let crate_version = if manifest .lines() .any(|line| line.trim() == "version.workspace = true") { release.to_owned() } else { package_value(&manifest, "version") .ok_or_else(|| MatrixError::new(format!("public crate {name} lacks a version")))? }; if crate_version != release { return Err(MatrixError::new(format!( "public crate {name} has version {crate_version}, expected release {release}" ))); } let features = parse_features(&manifest)?; validate_features(name, &features)?; let crate_declarations = crate_declarations(&directory)?; validate_declaration_dependencies(name, &manifest, &crate_declarations)?; let declaration_sha256 = sha256(crate_declarations.join("\n").as_bytes()); declarations.extend( crate_declarations .iter() .map(|declaration| format!("{name}\t{declaration}")), ); crates.push(CrateSurface { name: name.to_owned(), version: release.to_owned(), features, mapped_types: type_rows .iter() .filter(|row| { field(row, "rust_crate") == name && field(row, "csharp_type_id").starts_with("T:LibreMetaverse") && field(row, "source_kind") != "referenced_support_trait" }) .count(), mapped_members: member_rows .iter() .filter(|row| field(row, "rust_crate") == name) .count(), public_declarations: crate_declarations.len(), declaration_sha256, }); } declarations.sort(); Ok((crates, declarations)) } fn quality_counts(type_rows: &[Row], member_rows: &[Row]) -> QualityCounts { let intentional_differences = member_rows .iter() .filter(|row| field(row, "status") == "intentional_difference") .count(); let descriptive_overloads = member_rows .iter() .filter(|row| field(row, "overload_decision") != "direct_snake_case") .count(); let async_functions = member_rows .iter() .filter(|row| field(row, "asyncness") == "async") .count(); let send_boxed_futures = member_rows .iter() .filter(|row| field(row, "asyncness") == "boxed_future") .count(); let thread_safe_callbacks = member_rows .iter() .filter(|row| field(row, "rust_signature").contains("dyn Fn")) .count(); let enum_types = type_rows .iter() .filter(|row| { field(row, "csharp_type_id").starts_with("T:LibreMetaverse") && field(row, "source_kind") == "enum" }) .count(); let bitflag_types = type_rows .iter() .filter(|row| { field(row, "csharp_type_id").starts_with("T:LibreMetaverse") && field(row, "enum_mapping").starts_with("bitflags<") }) .count(); QualityCounts { intentional_differences, descriptive_overloads, async_functions, send_boxed_futures, thread_safe_callbacks, enum_types, bitflag_types, } } fn validate_members(rows: &[Row]) -> Result<()> { let ownership_terms = OWNERSHIP_TERMS.into_iter().collect::>(); for row in rows { let id = field(row, "csharp_id"); if [ "csharp_id", "csharp_kind", "csharp_signature", "rust_crate", "rust_item_path", "rust_signature", "ownership", "asyncness", "error_model", "overload_decision", "mapping_kind", "status", ] .into_iter() .any(|name| field(row, name).is_empty()) { return Err(MatrixError::new(format!("mapping {id} has an empty field"))); } if field(row, "ownership") .split(',') .any(|term| !ownership_terms.contains(term)) { return Err(MatrixError::new(format!( "mapping {id} has an unexplained ownership term" ))); } let signature = field(row, "rust_signature"); validate_rust_naming(id, signature)?; match field(row, "asyncness") { "sync" if signature.contains("pub async fn") => { return Err(MatrixError::new(format!( "mapping {id} marks an async function as synchronous" ))); } "async" if !signature.contains("pub async fn") => { return Err(MatrixError::new(format!( "mapping {id} lacks an async Rust signature" ))); } "boxed_future" if !signature.contains("dyn std::future::Future") || !signature.contains("+ Send") => { return Err(MatrixError::new(format!( "mapping {id} lacks a Send boxed future" ))); } "sync" | "async" | "boxed_future" => {} value => { return Err(MatrixError::new(format!( "mapping {id} has unknown asyncness {value}" ))); } } if signature.contains("dyn Fn") && !signature.contains("+ Send + Sync") { return Err(MatrixError::new(format!( "mapping {id} exposes a callback without Send + Sync" ))); } match field(row, "error_model") { "Result<_, crate::Error>" if !signature.contains("Result<") => { return Err(MatrixError::new(format!( "mapping {id} declares Result errors without a Result signature" ))); } "none" | "infallible_placeholder" if signature.contains("Result<") => { return Err(MatrixError::new(format!( "mapping {id} returns Result without a matching error model" ))); } "Result<_, crate::Error>" | "none" | "infallible_placeholder" => {} value => { return Err(MatrixError::new(format!( "mapping {id} has unknown error model {value}" ))); } } let intentional = field(row, "status") == "intentional_difference"; if intentional != (field(row, "mapping_kind") == "native_callback_replaces_delegate_apm") { return Err(MatrixError::new(format!( "mapping {id} has an unexplained intentional difference" ))); } if !matches!(field(row, "status"), "mapped" | "intentional_difference") { return Err(MatrixError::new(format!( "mapping {id} has an unresolved status" ))); } } Ok(()) } fn validate_rust_naming(id: &str, signature: &str) -> Result<()> { let function = ["pub async fn ", "pub fn ", "async fn ", "fn "] .into_iter() .find_map(|prefix| signature.strip_prefix(prefix)); if let Some(function) = function { let name = function .split(|character: char| !(character.is_ascii_alphanumeric() || character == '_')) .next() .unwrap_or_default(); if name.is_empty() || name.starts_with(|character: char| character.is_ascii_digit()) || !name.chars().all(|character| { character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' }) { return Err(MatrixError::new(format!( "mapping {id} has a non-idiomatic Rust function name: {name}" ))); } } for prefix in ["pub const ", "pub static "] { if let Some(item) = signature.strip_prefix(prefix) { let name = item .split(|character: char| !(character.is_ascii_alphanumeric() || character == '_')) .next() .unwrap_or_default(); if name.is_empty() || !name.chars().all(|character| { character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' }) { return Err(MatrixError::new(format!( "mapping {id} has a non-idiomatic Rust constant name: {name}" ))); } } } Ok(()) } fn validate_types(rows: &[Row]) -> Result<()> { for row in rows { let id = field(row, "csharp_type_id"); if [ "csharp_type_id", "csharp_signature", "source_kind", "rust_crate", "rust_path", "mapping_decision", "inheritance_mapping", "enum_mapping", "status", ] .into_iter() .any(|name| field(row, name).is_empty()) || field(row, "status") != "mapped" { return Err(MatrixError::new(format!( "type mapping {id} is incomplete or unresolved" ))); } if !matches!( field(row, "mapping_decision"), "native_metacrate_type" | "native_cross_platform_replacement" | "stdlib_or_adopted_crate" | "language" | "native_metacrate_support_trait" ) { return Err(MatrixError::new(format!( "type mapping {id} has an unexplained decision" ))); } } Ok(()) } fn validate_totals(baseline: &ApiBaseline) -> Result<()> { if baseline.mapped_public_types != 3_066 || baseline.mapped_members != 30_789 || baseline.external_signature_types != 142 || baseline.support_traits != 2 || baseline.intentional_differences != 80 || baseline.descriptive_overloads != 7_151 || baseline.async_functions != 334 || baseline.send_boxed_futures != 26 || baseline.thread_safe_callbacks != 137 || baseline.enum_types != 243 || baseline.bitflag_types != 46 || baseline.unexplained_mappings != 0 || baseline.accidental_public_dependency_types != 0 { return Err(MatrixError::new( "API quality totals differ from the reviewed first-release surface", )); } Ok(()) } fn exported_mappings(root: &Path) -> Result<(BTreeSet, BTreeSet, Vec)> { let mut types = BTreeSet::new(); let mut members = BTreeSet::new(); let mut canonical = Vec::new(); for (_, relative) in PUBLIC_CRATES { let path = root.join(relative).join("src/generated.rs"); if !path.exists() { continue; } for line in fs::read_to_string(&path)?.lines().map(str::trim) { if let Some(id) = marker(line, "/// C# type: `") { if !types.insert(id.to_owned()) { return Err(MatrixError::new(format!( "duplicate exported type marker {id}" ))); } canonical.push(format!("type\t{id}")); } if let Some(id) = marker(line, "/// C# member: `") { if !members.insert(id.to_owned()) { return Err(MatrixError::new(format!( "duplicate exported member marker {id}" ))); } canonical.push(format!("member\t{id}")); } } } canonical.sort_unstable(); Ok((types, members, canonical.join("\n").into_bytes())) } fn marker<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { line.strip_prefix(prefix)?.strip_suffix("`.") } fn accidental_dependency_roots(rows: &[Row]) -> BTreeSet { let mut roots = BTreeSet::new(); for row in rows { for root in path_roots(field(row, "rust_signature")) { if !matches!(root.as_str(), "crate" | "std" | "core" | "self" | "super") && !root.starts_with("libremetaverse") { roots.insert(root); } } } roots } fn validate_declaration_dependencies( crate_name: &str, manifest: &str, declarations: &[String], ) -> Result<()> { let dependencies = dependency_roots(manifest); for declaration in declarations { let surface = dependency_surface(declaration); for root in path_roots(&surface).intersection(&dependencies) { if root.starts_with("libremetaverse") { continue; } return Err(MatrixError::new(format!( "public Rust declaration exposes dependency root {root}: {crate_name}/{declaration}" ))); } } Ok(()) } fn dependency_surface(declaration: &str) -> String { let source = declaration .split_once('\t') .map_or(declaration, |(_, source)| source); if !source.starts_with("pub struct ") && !source.starts_with("pub union ") { return source.to_owned(); } let (opening, closing) = if let Some(opening) = source.find('{') { (opening, '}') } else if let Some(opening) = source.find('(') { (opening, ')') } else { return source.to_owned(); }; let Some(closing) = source.rfind(closing).filter(|closing| *closing > opening) else { return source.to_owned(); }; let mut public = source[..opening].to_owned(); for field in split_top_level(&source[opening + 1..closing], ',') { let field = field.trim(); if field.starts_with("pub ") { public.push(' '); public.push_str(field); } } public } fn split_top_level(value: &str, separator: char) -> Vec<&str> { let mut fields = Vec::new(); let mut start = 0; let mut depth = 0usize; for (index, character) in value.char_indices() { match character { '<' | '(' | '[' | '{' => depth += 1, '>' | ')' | ']' | '}' => depth = depth.saturating_sub(1), candidate if candidate == separator && depth == 0 => { fields.push(&value[start..index]); start = index + character.len_utf8(); } _ => {} } } fields.push(&value[start..]); fields } fn dependency_roots(manifest: &str) -> BTreeSet { let mut roots = BTreeSet::new(); let mut normal_dependencies = false; for line in manifest.lines().map(str::trim) { if line.starts_with('[') { normal_dependencies = line == "[dependencies]" || (line.starts_with("[target.") && line.ends_with(".dependencies]")); continue; } if !normal_dependencies || line.is_empty() || line.starts_with('#') { continue; } if let Some((name, _)) = line.split_once('=') { roots.insert(name.trim().replace('-', "_")); } } roots } fn path_roots(value: &str) -> BTreeSet { let bytes = value.as_bytes(); let mut roots = BTreeSet::new(); let mut index = 0; while index < bytes.len() { if bytes[index].is_ascii_lowercase() && (index == 0 || (!bytes[index - 1].is_ascii_alphanumeric() && bytes[index - 1] != b'_' && bytes[index - 1] != b':')) { let start = index; index += 1; while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') { index += 1; } let mut separator = index; while separator < bytes.len() && bytes[separator].is_ascii_whitespace() { separator += 1; } if bytes.get(separator..separator + 2) == Some(b"::") { roots.insert(value[start..index].to_owned()); } } else { index += 1; } } roots } fn validate_non_exhaustive_errors(root: &Path) -> Result<()> { for (relative, name) in REQUIRED_NON_EXHAUSTIVE_ERRORS { let text = fs::read_to_string(root.join(relative))?; let declaration = format!("pub enum {name}"); let Some(position) = text.find(&declaration) else { return Err(MatrixError::new(format!( "missing public error enum {name}" ))); }; let prefix = &text[..position]; let attribute = prefix .rsplit_once("#[non_exhaustive]") .map(|(_, tail)| tail); if attribute.is_none_or(|tail| tail.contains("pub enum")) { return Err(MatrixError::new(format!( "public error enum {name} must be non_exhaustive before release" ))); } } Ok(()) } fn validate_thread_safe_traits(root: &Path) -> Result<()> { for (relative, declaration) in REQUIRED_THREAD_SAFE_TRAITS { if !fs::read_to_string(root.join(relative))?.contains(declaration) { return Err(MatrixError::new(format!( "public callback trait lacks its Send + Sync contract: {declaration}" ))); } } Ok(()) } fn validate_features(name: &str, actual: &BTreeMap>) -> Result<()> { let expected = match name { "libremetaverse" => BTreeMap::from([ ("dds-bc67".to_owned(), vec!["dep:bcdec_rs".to_owned()]), ("default".to_owned(), vec!["dds-bc67".to_owned()]), ( "jpeg2000".to_owned(), vec!["libremetaverse-imaging/jpeg2000".to_owned()], ), ("vorbis".to_owned(), vec!["dep:vorbis_rs".to_owned()]), ]), "libremetaverse-imaging" => BTreeMap::from([ ("default".to_owned(), Vec::new()), ( "jpeg2000".to_owned(), vec!["dep:libremetaverse-openjpeg".to_owned()], ), ]), "libremetaverse-imaging-skia" => BTreeMap::from([ ("default".to_owned(), Vec::new()), ("skia".to_owned(), vec!["dep:skia-safe".to_owned()]), ]), "libremetaverse-voice-webrtc" => BTreeMap::from([ ("default".to_owned(), Vec::new()), ("real-audio".to_owned(), vec!["dep:cpal".to_owned()]), ]), _ => BTreeMap::new(), }; if actual != &expected { return Err(MatrixError::new(format!( "public feature surface changed for {name}: expected {expected:?}, found {actual:?}" ))); } Ok(()) } fn parse_features(manifest: &str) -> Result>> { let mut features = BTreeMap::new(); let mut in_features = false; for line in manifest.lines() { let line = line.trim(); if line.starts_with('[') { in_features = line == "[features]"; continue; } if !in_features || line.is_empty() || line.starts_with('#') { continue; } let (name, values) = line .split_once('=') .ok_or_else(|| MatrixError::new("feature declaration lacks ="))?; let values = serde_json::from_str::>(values.trim())?; if features.insert(name.trim().to_owned(), values).is_some() { return Err(MatrixError::new("duplicate feature declaration")); } } Ok(features) } fn crate_declarations(directory: &Path) -> Result> { let mut files = Vec::new(); collect_rs(&directory.join("src"), &mut files)?; files.sort(); let mut declarations = Vec::new(); for file in files { let relative = file.strip_prefix(directory).unwrap_or(&file); for declaration in extract_public_declarations(&fs::read_to_string(&file)?) { declarations.push(format!("{}\t{declaration}", relative.display())); } } declarations.sort(); Ok(declarations) } fn collect_rs(directory: &Path, files: &mut Vec) -> Result<()> { for entry in fs::read_dir(directory)? { let path = entry?.path(); if path.is_dir() { collect_rs(&path, files)?; } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { files.push(path); } } Ok(()) } fn extract_public_declarations(source: &str) -> Vec { let lines = source.lines().collect::>(); let mut declarations = Vec::new(); let mut index = 0; while index < lines.len() { let trimmed = lines[index].trim_start(); if !trimmed.starts_with("pub ") { index += 1; continue; } let whole_block = ["pub struct ", "pub enum ", "pub union ", "pub trait "] .iter() .any(|prefix| trimmed.starts_with(prefix)); let inline_module = trimmed.starts_with("pub mod ") && trimmed.contains('{'); let mut chunk = Vec::new(); let mut braces = 0isize; let mut opened = false; loop { let line = lines[index].trim(); if !line.starts_with("///") && !line.starts_with("//") { let selected = if !whole_block && !inline_module { line.split_once('{') .map_or(line, |(header, _)| header.trim()) } else if inline_module { line.split_once('{') .map_or(line, |(header, _)| header.trim()) } else { line }; if !selected.is_empty() { chunk.push(selected); } } if whole_block { braces += brace_delta(line); opened |= line.contains('{'); } let finished = if whole_block { (opened && braces == 0) || (!opened && line.ends_with(';')) } else { line.contains('{') || line.ends_with(';') }; index += 1; if finished || index == lines.len() { break; } } let normalized = chunk .join(" ") .split_whitespace() .collect::>() .join(" "); if !normalized.is_empty() { declarations.push(normalized); } } declarations } fn brace_delta(line: &str) -> isize { let mut delta = 0; let mut quoted = false; let mut escaped = false; let mut characters = line.chars().peekable(); while let Some(character) = characters.next() { if !quoted && character == '/' && characters.peek() == Some(&'/') { break; } if quoted { if escaped { escaped = false; } else if character == '\\' { escaped = true; } else if character == '"' { quoted = false; } continue; } match character { '"' => quoted = true, '{' => delta += 1, '}' => delta -= 1, _ => {} } } delta } fn read_tsv(path: &Path) -> Result<(Vec, Vec)> { let text = fs::read_to_string(path)?; let mut lines = text.lines(); let header = lines .next() .ok_or_else(|| MatrixError::new(format!("{} is empty", path.display())))? .split('\t') .map(str::to_owned) .collect::>(); let mut rows = Vec::new(); for line in lines.filter(|line| !line.is_empty()) { let values = line.split('\t').collect::>(); if values.len() != header.len() { return Err(MatrixError::new(format!( "{} contains a malformed row", path.display() ))); } rows.push( header .iter() .cloned() .zip(values.into_iter().map(str::to_owned)) .collect(), ); } Ok((header, rows)) } fn require_columns(header: &[String], required: &[&str], path: &str) -> Result<()> { for field in required { if !header.iter().any(|candidate| candidate == field) { return Err(MatrixError::new(format!( "{path} lacks required column {field}" ))); } } Ok(()) } fn validate_unique(rows: &[Row], key: &str, label: &str) -> Result<()> { let mut values = BTreeSet::new(); for row in rows { let value = field(row, key); if value.is_empty() || !values.insert(value) { return Err(MatrixError::new(format!( "{label} values must be nonempty and unique: {value}" ))); } } Ok(()) } fn field<'a>(row: &'a Row, name: &str) -> &'a str { row.get(name).map_or("", String::as_str) } fn package_value(manifest: &str, key: &str) -> Option { let mut in_package = false; for line in manifest.lines() { let line = line.trim(); if line.starts_with('[') { in_package = matches!(line, "[package]" | "[workspace.package]"); continue; } if in_package && let Some(value) = line.strip_prefix(&format!("{key} = \"")) && let Some(value) = value.strip_suffix('"') { return Some(value.to_owned()); } } None } fn baseline_json(baseline: &ApiBaseline) -> Result> { let mut bytes = serde_json::to_vec_pretty(baseline)?; bytes.push(b'\n'); Ok(bytes) } fn sha256(bytes: &[u8]) -> String { let digest = Sha256::digest(bytes); let mut output = String::with_capacity(digest.len() * 2); for byte in digest { use std::fmt::Write as _; write!(output, "{byte:02x}").expect("writing to a String cannot fail"); } output } fn render_report(baseline: &ApiBaseline) -> String { let crates = baseline .crates .iter() .map(|surface| { let features = if surface.features.is_empty() { "none".to_owned() } else { surface .features .keys() .cloned() .collect::>() .join(", ") }; format!( "| `{}` | {} | {} | {} | {} | `{}` |", surface.name, surface.mapped_types, surface.mapped_members, surface.public_declarations, features, surface.declaration_sha256 ) }) .collect::>() .join("\n"); format!( "# First-release API and SemVer audit\n\n\ Generated by `metacrate-ci-matrix api-baseline-write`; do not edit by hand.\n\n\ The compatibility source is pinned at [`{commit}`](https://github.com/cinderblocks/libremetaverse/tree/{commit}). \ The baseline version is `{release}`.\n\n\ ## Coverage and quality decisions\n\n\ - Mapped/exported public types: **{types} / {types}**; unexplained mappings: **0**.\n\ - Mapped/exported public members: **{members} / {members}**; duplicate destinations: **0**.\n\ - Rust naming and ownership: mapped function/constant names follow Rust conventions and every member uses a reviewed ownership term.\n\ - External signature types: **{external}**; referenced support traits: **{support}**; accidental dependency types exposed by mapped signatures or public Rust declarations: **0**.\n\ - Intentional delegate APM replacements: **{differences}**; each uses a native callback and is compiled by the downstream fixture.\n\ - Descriptively named overloads: **{overloads}**; names remain fixed by the mapping ledger.\n\ - Native `async fn` mappings: **{async_functions}**; boxed object-safe trait futures with explicit `Send`: **{boxed}**.\n\ - Callback signatures requiring `Send + Sync`: **{callbacks}**; reviewed dyn-compatible thread-safe boundary traits: **{traits}** (`IGridClient`, `IBakingTextureProvider`, `ICurrentOutfitPolicy`, `IRlvActionCallbacks`, `IRlvQueryCallbacks`, and `IVoiceLogger`).\n\ - C# enum mappings: **{enums}** ({bitflags} unknown-bit-retaining flag types); stable public error enums marked `non_exhaustive`: **{errors}**.\n\n\ ## Crate baselines\n\n\ | Crate | Mapped types | Mapped members | Rust declarations | Features | Declaration SHA-256 |\n\ | --- | ---: | ---: | ---: | --- | --- |\n\ {crates}\n\n\ The exact mapping, exported-marker, feature, and normalized Rust declaration hashes are stored in \ [`SEMVER-BASELINE.json`](SEMVER-BASELINE.json). A change must be reviewed and classified before the baseline is re-recorded.\n\n\ ## Intentional Rust migration recipes\n\n\ - C# `PascalCase` methods/properties become Rust `snake_case`; overloads use the ledger's stable descriptive suffix, never argument-count dispatch.\n\ - C# reference ownership becomes owned values, shared or mutable borrows, `Arc`-backed managers, and explicit optional values as recorded per member.\n\ - `Task` becomes `async fn -> Result`; object-safe async traits return `Pin> + Send + '_>>`.\n\ - .NET events become `subscribe_*` calls returning an owned `Subscription`; callbacks are `Send + Sync` and dispatch outside internal locks.\n\ - `IDisposable` becomes explicit idempotent shutdown plus `Drop`; cancellation remains a typed `Error::Cancelled`.\n\ - Closed C# enums retain fixed discriminants and are SemVer-breaking to extend; flags retain unknown bits, while public Rust error enums are `non_exhaustive`.\n\ - Optional native backends remain feature-gated (`jpeg2000`, `skia`, `vorbis`, `real-audio`); default and empty-default sets are part of the baseline.\n\ - Capability HTTP injection uses project-owned `HttpMessageHandler`, `CapsHttpLimits`, and `with_native_transport`; the internal `reqwest` client is not part of the public surface.\n\ - External C# signature types map to core/std, adopted cross-platform crates, or project-owned boundary types; no third-party crate path leaks through mapped signatures or public Rust declarations.\n\n\ ## Compile consumers\n\n\ - `tests/api-compile` compiles every mapped type/member from a standalone downstream workspace.\n\ - `tests/semver-port` is a runnable representative port covering lifecycle, value types, structured data, imaging, RLV, cancellation, dyn trait, and `Send`/`Sync` contracts without network access.\n", commit = baseline.upstream_commit, release = baseline.release, types = baseline.mapped_public_types, members = baseline.mapped_members, external = baseline.external_signature_types, support = baseline.support_traits, differences = baseline.intentional_differences, overloads = baseline.descriptive_overloads, async_functions = baseline.async_functions, boxed = baseline.send_boxed_futures, callbacks = baseline.thread_safe_callbacks, traits = baseline.thread_safe_dyn_traits, enums = baseline.enum_types, bitflags = baseline.bitflag_types, errors = baseline.non_exhaustive_error_enums, ) } #[cfg(test)] mod tests { use super::*; #[test] fn declaration_extractor_ignores_function_bodies_and_private_items() { let source = r" struct Private; pub struct Public { pub value: i32, hidden: bool } impl Public { pub fn value( &self, ) -> i32 { self.value } fn hidden(&self) -> bool { self.hidden } } "; let declarations = extract_public_declarations(source); assert_eq!(declarations.len(), 2); assert!(declarations[0].starts_with("pub struct Public")); assert_eq!(declarations[1], "pub fn value( &self, ) -> i32"); assert!(!declarations.join(" ").contains("self.value")); } #[test] fn path_root_parser_ignores_nested_modules() { let roots = path_roots("pub fn f(value: libremetaverse::packets::Packet) -> std::io::Result<()>;"); assert_eq!( roots, BTreeSet::from(["libremetaverse".to_owned(), "std".to_owned()]) ); } #[test] fn dependency_root_parser_covers_normal_target_and_renamed_crates() { let manifest = r#" [dependencies] normal-crate = "1" [dev-dependencies] ignored_dev = "1" [target.'cfg(unix)'.dependencies] target-crate = "1" [build-dependencies] ignored_build = "1" "#; assert_eq!( dependency_roots(manifest), BTreeSet::from(["normal_crate".to_owned(), "target_crate".to_owned()]) ); } #[test] fn dependency_surface_excludes_private_but_retains_public_struct_fields() { let declaration = "src/example.rs\tpub struct Example { private: dep::Hidden, pub visible: dep::Visible<(T, T)>, pub(crate) scoped: dep::Scoped, }"; assert_eq!( dependency_surface(declaration), "pub struct Example pub visible: dep::Visible<(T, T)>" ); assert_eq!( dependency_surface("src/example.rs\tpub struct Tuple(pub dep::Visible, dep::Hidden);"), "pub struct Tuple pub dep::Visible" ); } #[test] fn naming_audit_accepts_rust_conventions_and_rejects_pascal_case_functions() { validate_rust_naming( "good", "pub async fn request_asset_2() -> Result<(), Error>", ) .unwrap(); validate_rust_naming("constant", "pub const MAX_BYTES: usize").unwrap(); assert!(validate_rust_naming("bad", "pub fn RequestAsset() -> bool").is_err()); } #[test] fn repository_surface_has_reviewed_totals() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let baseline = inspect(&root).unwrap(); assert_eq!(baseline.mapped_public_types, 3_066); assert_eq!(baseline.mapped_members, 30_789); assert_eq!(baseline.unexplained_mappings, 0); assert_eq!(baseline.accidental_public_dependency_types, 0); } }