Complete Rust API and migration documentation (#102)
Some checks failed
Native code generation / deterministic (push) Failing after 2m5s
Concurrency and resource soak audit / soak (push) Failing after 6m22s
Documentation / documentation (push) Failing after 1m25s
Imaging and meshing gate / native (push) Failing after 2m47s
JPEG 2000 feature / linux (push) Successful in 3m54s
Release platform and feature matrix / audit (push) Successful in 37s
Native Rust workspace compile / compile (push) Failing after 1m14s
Skia feature / linux (push) Successful in 30m46s
Dependency and supply-chain audit / audit (push) Failing after 8m56s
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Failing after 9m16s
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Failing after 1m20s
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Failing after 1m18s
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Some checks failed
Native code generation / deterministic (push) Failing after 2m5s
Concurrency and resource soak audit / soak (push) Failing after 6m22s
Documentation / documentation (push) Failing after 1m25s
Imaging and meshing gate / native (push) Failing after 2m47s
JPEG 2000 feature / linux (push) Successful in 3m54s
Release platform and feature matrix / audit (push) Successful in 37s
Native Rust workspace compile / compile (push) Failing after 1m14s
Skia feature / linux (push) Successful in 30m46s
Dependency and supply-chain audit / audit (push) Failing after 8m56s
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Failing after 9m16s
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Failing after 1m20s
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Failing after 1m18s
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
This commit is contained in:
606
tools/ci-matrix/src/documentation.rs
Normal file
606
tools/ci-matrix/src/documentation.rs
Normal file
@@ -0,0 +1,606 @@
|
||||
use super::{MatrixError, Result};
|
||||
use serde::Serialize;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAPPING_PATH: &str = "api/RUST-MAPPING.tsv";
|
||||
const TYPES_PATH: &str = "api/RUST-TYPES.tsv";
|
||||
const GUIDE_PATH: &str = "docs/rust-api-guide.md";
|
||||
const REPORT_PATH: &str = "api/DOCUMENTATION-COVERAGE.md";
|
||||
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 PROGRAMS: [&str; 9] = [
|
||||
"osd-inspector",
|
||||
"simple-bot",
|
||||
"packet-dump",
|
||||
"prim-inspector",
|
||||
"inventory-explorer",
|
||||
"irc-gateway",
|
||||
"test-client",
|
||||
"vivox-test",
|
||||
"webrtc-test",
|
||||
];
|
||||
|
||||
const REQUIRED_GUIDE_SECTIONS: [&str; 11] = [
|
||||
"## Choosing crates and features",
|
||||
"## Naming and overload migration",
|
||||
"## Ownership and disposal",
|
||||
"## Async work and cancellation",
|
||||
"## Errors",
|
||||
"## Events and subscriptions",
|
||||
"## Threading and callbacks",
|
||||
"## Security boundaries",
|
||||
"## Native prerequisites",
|
||||
"## Live `OpenSim` setup",
|
||||
"## Programs and operational tools",
|
||||
];
|
||||
|
||||
const REQUIRED_MAPPING_FIELDS: [&str; 11] = [
|
||||
"csharp_id",
|
||||
"csharp_signature",
|
||||
"rust_crate",
|
||||
"rust_item_path",
|
||||
"rust_signature",
|
||||
"ownership",
|
||||
"asyncness",
|
||||
"error_model",
|
||||
"overload_decision",
|
||||
"mapping_kind",
|
||||
"status",
|
||||
];
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DocumentationEvidence {
|
||||
schema: u32,
|
||||
upstream_commit: &'static str,
|
||||
public_crates: usize,
|
||||
mapped_public_types: usize,
|
||||
documented_public_types: usize,
|
||||
mapped_members: usize,
|
||||
documented_members: usize,
|
||||
tested_rust_snippets: usize,
|
||||
linked_programs: usize,
|
||||
checked_local_links: usize,
|
||||
required_guide_sections: usize,
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
struct Snapshot {
|
||||
mapped_public_types: usize,
|
||||
documented_public_types: usize,
|
||||
mapped_members: usize,
|
||||
documented_members: usize,
|
||||
rust_snippets: usize,
|
||||
local_links: usize,
|
||||
}
|
||||
|
||||
/// Regenerates the deterministic mapped API documentation coverage report.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if documentation inputs are incomplete or cannot be read.
|
||||
pub fn write_documentation_report(root: &Path) -> Result<()> {
|
||||
let snapshot = inspect(root)?;
|
||||
fs::write(root.join(REPORT_PATH), render_report(&snapshot))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates public crate, mapped item, guide, link, snippet, and program docs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when any mapped public item lacks its exact C# concept ID,
|
||||
/// a public crate lacks publishable root documentation, a local link is broken,
|
||||
/// a required migration topic/program is absent, or the checked report is stale.
|
||||
pub fn audit_documentation(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 snapshot = inspect(root)?;
|
||||
let expected_report = render_report(&snapshot);
|
||||
let report = fs::read_to_string(root.join(REPORT_PATH))?;
|
||||
if report != expected_report {
|
||||
return Err(MatrixError::new(
|
||||
"documentation coverage report is stale; run documentation-report",
|
||||
));
|
||||
}
|
||||
let record = DocumentationEvidence {
|
||||
schema: 1,
|
||||
upstream_commit: UPSTREAM_COMMIT,
|
||||
public_crates: PUBLIC_CRATES.len(),
|
||||
mapped_public_types: snapshot.mapped_public_types,
|
||||
documented_public_types: snapshot.documented_public_types,
|
||||
mapped_members: snapshot.mapped_members,
|
||||
documented_members: snapshot.documented_members,
|
||||
tested_rust_snippets: snapshot.rust_snippets,
|
||||
linked_programs: PROGRAMS.len(),
|
||||
checked_local_links: snapshot.local_links,
|
||||
required_guide_sections: REQUIRED_GUIDE_SECTIONS.len(),
|
||||
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<Snapshot> {
|
||||
validate_crates(root)?;
|
||||
let guide = fs::read_to_string(root.join(GUIDE_PATH))?;
|
||||
validate_guide(&guide)?;
|
||||
let generated_docs = generated_doc_lines(root)?;
|
||||
let mapped_members = validate_member_mapping(root, &generated_docs)?;
|
||||
let mapped_public_types = validate_type_mapping(root, &generated_docs)?;
|
||||
let local_links = validate_markdown_links(root)?;
|
||||
let rust_snippets = guide.matches("```rust").count();
|
||||
if rust_snippets < 4 {
|
||||
return Err(MatrixError::new(
|
||||
"Rust API guide must contain at least four compiled snippets",
|
||||
));
|
||||
}
|
||||
Ok(Snapshot {
|
||||
mapped_public_types,
|
||||
documented_public_types: mapped_public_types,
|
||||
mapped_members,
|
||||
documented_members: mapped_members,
|
||||
rust_snippets,
|
||||
local_links,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_crates(root: &Path) -> Result<()> {
|
||||
for (name, relative) in PUBLIC_CRATES {
|
||||
let directory = root.join(relative);
|
||||
let manifest = fs::read_to_string(directory.join("Cargo.toml"))?;
|
||||
if !manifest.contains(&format!("name = \"{name}\""))
|
||||
|| !manifest.lines().any(|line| {
|
||||
line.trim_start()
|
||||
.strip_prefix("description = ")
|
||||
.is_some_and(|description| description.len() >= 24)
|
||||
})
|
||||
{
|
||||
return Err(MatrixError::new(format!(
|
||||
"public crate {name} lacks package documentation metadata"
|
||||
)));
|
||||
}
|
||||
let library = fs::read_to_string(directory.join("src/lib.rs"))?;
|
||||
let crate_doc_bytes: usize = library
|
||||
.lines()
|
||||
.take_while(|line| line.starts_with("//!") || line.trim().is_empty())
|
||||
.filter(|line| line.starts_with("//!"))
|
||||
.map(str::len)
|
||||
.sum();
|
||||
if crate_doc_bytes < 120 {
|
||||
return Err(MatrixError::new(format!(
|
||||
"public crate {name} needs a useful crate-level overview"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let core = fs::read_to_string(root.join("crates/libremetaverse/src/lib.rs"))?;
|
||||
if !core.contains("include_str!(\"../../../docs/rust-api-guide.md\")") {
|
||||
return Err(MatrixError::new(
|
||||
"the tested Rust API guide is not included in libremetaverse rustdoc",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_guide(guide: &str) -> Result<()> {
|
||||
if !guide.contains("https://github.com/cinderblocks/libremetaverse")
|
||||
|| !guide.contains(UPSTREAM_COMMIT)
|
||||
{
|
||||
return Err(MatrixError::new(
|
||||
"Rust API guide lacks the pinned C# source link",
|
||||
));
|
||||
}
|
||||
for section in REQUIRED_GUIDE_SECTIONS {
|
||||
if !guide.contains(section) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"Rust API guide lacks required section {section}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for program in PROGRAMS {
|
||||
if !guide.contains(&format!("`{program}`")) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"Rust API guide does not link program {program}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generated_doc_lines(root: &Path) -> Result<BTreeSet<String>> {
|
||||
let mut lines = BTreeSet::new();
|
||||
for (_, relative) in PUBLIC_CRATES {
|
||||
let generated = root.join(relative).join("src/generated.rs");
|
||||
if generated.exists() {
|
||||
lines.extend(
|
||||
fs::read_to_string(generated)?
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("///"))
|
||||
.map(str::to_owned),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn validate_member_mapping(root: &Path, docs: &BTreeSet<String>) -> Result<usize> {
|
||||
let (header, rows) = read_tsv(&root.join(MAPPING_PATH))?;
|
||||
let positions = positions(&header, &REQUIRED_MAPPING_FIELDS)?;
|
||||
let id_position = positions["csharp_id"];
|
||||
let mut ids = BTreeSet::new();
|
||||
for row in &rows {
|
||||
for field in REQUIRED_MAPPING_FIELDS {
|
||||
if row[positions[field]].trim().is_empty() {
|
||||
return Err(MatrixError::new(format!(
|
||||
"mapping row has empty documentation context field {field}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let id = &row[id_position];
|
||||
if !ids.insert(id.clone()) {
|
||||
return Err(MatrixError::new(format!("duplicate mapped member {id}")));
|
||||
}
|
||||
if !docs.contains(&format!("/// C# member: `{id}`.")) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"mapped member {id} lacks its C# concept documentation marker"
|
||||
)));
|
||||
}
|
||||
let signature = &row[positions["csharp_signature"]];
|
||||
if !docs.contains(&format!("/// C# signature: `{signature}`.")) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"mapped member {id} lacks its C# signature documentation"
|
||||
)));
|
||||
}
|
||||
let ownership = &row[positions["ownership"]];
|
||||
let asyncness = &row[positions["asyncness"]];
|
||||
if !docs.contains(&format!(
|
||||
"/// Mapping contract: ownership `{ownership}`; async `{asyncness}`;"
|
||||
)) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"mapped member {id} lacks ownership/async documentation"
|
||||
)));
|
||||
}
|
||||
let error_model = &row[positions["error_model"]];
|
||||
let overload = &row[positions["overload_decision"]];
|
||||
let mapping_kind = &row[positions["mapping_kind"]];
|
||||
if !docs.contains(&format!(
|
||||
"/// errors `{error_model}`; overload `{overload}`; kind `{mapping_kind}`."
|
||||
)) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"mapped member {id} lacks error/overload/kind documentation"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(rows.len())
|
||||
}
|
||||
|
||||
fn validate_type_mapping(root: &Path, docs: &BTreeSet<String>) -> Result<usize> {
|
||||
let (header, rows) = read_tsv(&root.join(TYPES_PATH))?;
|
||||
let required = [
|
||||
"csharp_type_id",
|
||||
"csharp_signature",
|
||||
"source_kind",
|
||||
"rust_crate",
|
||||
"rust_path",
|
||||
"mapping_decision",
|
||||
"status",
|
||||
];
|
||||
let positions = positions(&header, &required)?;
|
||||
let mut count = 0;
|
||||
let mut ids = BTreeSet::new();
|
||||
for row in &rows {
|
||||
for field in required {
|
||||
if row[positions[field]].trim().is_empty() {
|
||||
return Err(MatrixError::new(format!(
|
||||
"type row has empty documentation context field {field}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let id = &row[positions["csharp_type_id"]];
|
||||
if !ids.insert(id.clone()) {
|
||||
return Err(MatrixError::new(format!("duplicate mapped type {id}")));
|
||||
}
|
||||
if id.starts_with("T:LibreMetaverse")
|
||||
&& row[positions["source_kind"]] != "referenced_support_trait"
|
||||
{
|
||||
count += 1;
|
||||
if !docs.contains(&format!("/// C# type: `{id}`.")) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"mapped public type {id} lacks its C# concept documentation marker"
|
||||
)));
|
||||
}
|
||||
let signature = &row[positions["csharp_signature"]];
|
||||
if !docs.contains(&format!(
|
||||
"/// Native Rust mapping of C# `{signature}` using decision"
|
||||
)) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"mapped public type {id} lacks useful C# signature documentation"
|
||||
)));
|
||||
}
|
||||
let decision = &row[positions["mapping_decision"]];
|
||||
if !docs.contains(&format!(
|
||||
"/// `{decision}`. See the [pinned C# source](https://github.com/cinderblocks/libremetaverse/tree/{UPSTREAM_COMMIT})."
|
||||
)) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"mapped public type {id} lacks its mapping decision/source link"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn read_tsv(path: &Path) -> Result<(Vec<String>, Vec<Vec<String>>)> {
|
||||
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::<Vec<_>>();
|
||||
let rows = lines
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| line.split('\t').map(str::to_owned).collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>();
|
||||
if rows.iter().any(|row| row.len() != header.len()) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"{} contains a malformed row",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok((header, rows))
|
||||
}
|
||||
|
||||
fn positions<const N: usize>(
|
||||
header: &[String],
|
||||
required: &[&'static str; N],
|
||||
) -> Result<BTreeMap<&'static str, usize>> {
|
||||
let mut result = BTreeMap::new();
|
||||
for &field in required {
|
||||
let position = header
|
||||
.iter()
|
||||
.position(|candidate| candidate == field)
|
||||
.ok_or_else(|| MatrixError::new(format!("TSV lacks required column {field}")))?;
|
||||
result.insert(field, position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn markdown_files(root: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut files = vec![root.join("README.md"), root.join("programs/README.md")];
|
||||
for directory in [root.join("docs"), root.join("api"), root.join("crates")] {
|
||||
collect_markdown(&directory, &mut files)?;
|
||||
}
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files.retain(|path| path != &root.join(REPORT_PATH));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn collect_markdown(directory: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
|
||||
for entry in fs::read_dir(directory)? {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() {
|
||||
collect_markdown(&path, files)?;
|
||||
} else if path.extension().and_then(|value| value.to_str()) == Some("md") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_markdown_links(root: &Path) -> Result<usize> {
|
||||
let mut checked = 0;
|
||||
for file in markdown_files(root)? {
|
||||
let text = fs::read_to_string(&file)?;
|
||||
let mut remainder = text.as_str();
|
||||
while let Some(start) = remainder.find("](") {
|
||||
remainder = &remainder[start + 2..];
|
||||
let Some(end) = remainder.find(')') else {
|
||||
return Err(MatrixError::new(format!(
|
||||
"{} contains an unterminated Markdown link",
|
||||
file.display()
|
||||
)));
|
||||
};
|
||||
let raw = remainder[..end].trim().trim_matches(['<', '>']);
|
||||
remainder = &remainder[end + 1..];
|
||||
let target = raw.split_whitespace().next().unwrap_or_default();
|
||||
if target.is_empty()
|
||||
|| target.starts_with("http://")
|
||||
|| target.starts_with("https://")
|
||||
|| target.starts_with("mailto:")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (path, fragment) = target
|
||||
.split_once('#')
|
||||
.map_or((target, None), |(path, fragment)| (path, Some(fragment)));
|
||||
let resolved = if path.is_empty() {
|
||||
file.clone()
|
||||
} else {
|
||||
file.parent().unwrap_or(root).join(path)
|
||||
};
|
||||
if !resolved.exists() {
|
||||
return Err(MatrixError::new(format!(
|
||||
"broken local Markdown link {target} in {}",
|
||||
file.strip_prefix(root).unwrap_or(&file).display()
|
||||
)));
|
||||
}
|
||||
if let Some(fragment) = fragment.filter(|fragment| !fragment.is_empty())
|
||||
&& resolved.extension().and_then(|value| value.to_str()) == Some("md")
|
||||
{
|
||||
let destination = fs::read_to_string(&resolved)?;
|
||||
if !markdown_anchors(&destination).contains(fragment) {
|
||||
return Err(MatrixError::new(format!(
|
||||
"broken local Markdown anchor {target} in {}",
|
||||
file.strip_prefix(root).unwrap_or(&file).display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
Ok(checked)
|
||||
}
|
||||
|
||||
fn markdown_anchors(markdown: &str) -> BTreeSet<String> {
|
||||
let mut anchors = BTreeSet::new();
|
||||
let mut occurrences = BTreeMap::<String, usize>::new();
|
||||
let mut fenced = false;
|
||||
for line in markdown.lines() {
|
||||
if line.trim_start().starts_with("```") {
|
||||
fenced = !fenced;
|
||||
continue;
|
||||
}
|
||||
if fenced {
|
||||
continue;
|
||||
}
|
||||
let heading = line.trim_start_matches('#').trim();
|
||||
if heading.is_empty() || heading.len() == line.trim().len() {
|
||||
continue;
|
||||
}
|
||||
let base = heading
|
||||
.chars()
|
||||
.filter_map(|character| {
|
||||
if character.is_alphanumeric() || matches!(character, '-' | '_') {
|
||||
Some(character.to_ascii_lowercase())
|
||||
} else if character.is_whitespace() {
|
||||
Some('-')
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let occurrence = occurrences.entry(base.clone()).or_default();
|
||||
let anchor = if *occurrence == 0 {
|
||||
base.clone()
|
||||
} else {
|
||||
format!("{base}-{occurrence}")
|
||||
};
|
||||
*occurrence += 1;
|
||||
anchors.insert(anchor);
|
||||
}
|
||||
anchors
|
||||
}
|
||||
|
||||
fn render_report(snapshot: &Snapshot) -> String {
|
||||
format!(
|
||||
"# Documentation coverage\n\n\
|
||||
Generated by `metacrate-ci-matrix documentation-report`; do not edit by hand.\n\n\
|
||||
| Surface | Documented | Required | Coverage |\n\
|
||||
| --- | ---: | ---: | ---: |\n\
|
||||
| Publishable public crates | {crates} | {crates} | 100% |\n\
|
||||
| Pinned LibreMetaverse public types | {types_documented} | {types} | 100% |\n\
|
||||
| Mapped public members | {members_documented} | {members} | 100% |\n\
|
||||
| Compiled Rust guide snippets | {snippets} | 4 minimum | pass |\n\
|
||||
| Linked native programs | {programs} | {programs} | 100% |\n\
|
||||
| Checked local Markdown links | {links} | {links} | 100% |\n\n\
|
||||
Every mapped item is tied to its exact C# documentation ID and to the ownership, \
|
||||
asyncness, error, overload, mapping-kind, and Rust-signature decisions in \
|
||||
[`RUST-MAPPING.tsv`](RUST-MAPPING.tsv). Public types are tied to the corresponding \
|
||||
type mapping. The upstream source is pinned to \
|
||||
[`{commit}`](https://github.com/cinderblocks/libremetaverse/tree/{commit}).\n",
|
||||
crates = PUBLIC_CRATES.len(),
|
||||
types_documented = snapshot.documented_public_types,
|
||||
types = snapshot.mapped_public_types,
|
||||
members_documented = snapshot.documented_members,
|
||||
members = snapshot.mapped_members,
|
||||
snippets = snapshot.rust_snippets,
|
||||
programs = PROGRAMS.len(),
|
||||
links = snapshot.local_links,
|
||||
commit = UPSTREAM_COMMIT,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn markdown_anchor_generation_matches_linkable_headings() {
|
||||
let anchors = markdown_anchors(
|
||||
"# API guide\n\n## Live `OpenSim` setup\n## API guide\n```text\n# ignored\n```\n",
|
||||
);
|
||||
assert!(anchors.contains("api-guide"));
|
||||
assert!(anchors.contains("live-opensim-setup"));
|
||||
assert!(anchors.contains("api-guide-1"));
|
||||
assert!(!anchors.contains("ignored"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_contains_exact_documentation_totals() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let snapshot = inspect(&root).unwrap();
|
||||
assert_eq!(snapshot.mapped_members, 30_789);
|
||||
assert_eq!(snapshot.documented_members, snapshot.mapped_members);
|
||||
assert_eq!(snapshot.mapped_public_types, 3_066);
|
||||
assert_eq!(
|
||||
snapshot.documented_public_types,
|
||||
snapshot.mapped_public_types
|
||||
);
|
||||
assert!(snapshot.rust_snippets >= 4);
|
||||
assert!(snapshot.local_links > 0);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,10 @@ use std::process::{Command, ExitStatus, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
mod dependency;
|
||||
mod documentation;
|
||||
|
||||
pub use dependency::audit_dependencies;
|
||||
pub use documentation::{audit_documentation, write_documentation_report};
|
||||
|
||||
pub const MATRIX_PATH: &str = "ci/release-matrix.json";
|
||||
const WORKFLOW_PATH: &str = ".gitea/workflows/release-matrix.yml";
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use metacrate_ci_matrix::{audit, audit_dependencies, load, run, workspace_root};
|
||||
use metacrate_ci_matrix::{
|
||||
audit, audit_dependencies, audit_documentation, load, run, workspace_root,
|
||||
write_documentation_report,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
@@ -46,9 +49,27 @@ fn execute() -> Result<(), Box<dyn std::error::Error>> {
|
||||
audit_dependencies(&root, &evidence)?;
|
||||
println!("dependency policy: ok ({})", evidence.display());
|
||||
}
|
||||
Some("documentation-report") if arguments.next().is_none() => {
|
||||
write_documentation_report(&root)?;
|
||||
println!("documentation coverage report: updated");
|
||||
}
|
||||
Some("documentation-audit") => {
|
||||
let flag = arguments
|
||||
.next()
|
||||
.ok_or("documentation-audit requires --evidence FILE")?;
|
||||
let evidence = arguments
|
||||
.next()
|
||||
.ok_or("documentation-audit requires --evidence FILE")?;
|
||||
if flag != "--evidence" || arguments.next().is_some() {
|
||||
return Err("usage: ci-matrix documentation-audit --evidence FILE".into());
|
||||
}
|
||||
let evidence = absolute_or_rooted(&root, &evidence);
|
||||
audit_documentation(&root, &evidence)?;
|
||||
println!("documentation coverage: ok ({})", evidence.display());
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"usage: ci-matrix audit | run PROFILE --evidence FILE | dependency-audit --evidence FILE"
|
||||
"usage: ci-matrix audit | run PROFILE --evidence FILE | dependency-audit --evidence FILE | documentation-report | documentation-audit --evidence FILE"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3205,6 +3205,11 @@ VALUE_DERIVES = {
|
||||
"T:LibreMetaverse.WorldSettings": "Clone, Debug, Eq, PartialEq",
|
||||
}
|
||||
|
||||
PINNED_CSHARP_SOURCE = (
|
||||
"https://github.com/cinderblocks/libremetaverse/tree/"
|
||||
"2aa70bb68513b39795da5d13c88f31b86e85a3ba"
|
||||
)
|
||||
|
||||
# Native implementations occasionally need Rust-only bounds that have no
|
||||
# direct C# metadata equivalent. C#'s `where T : class` permits returning a
|
||||
# reference to the stored object; the Rust inventory store returns an owned
|
||||
@@ -3385,10 +3390,29 @@ def duplicate_enum_values(item: dict) -> bool:
|
||||
return len(values) != len(set(values))
|
||||
|
||||
|
||||
def render_enum(item: dict, rust_name: str) -> list[str]:
|
||||
def render_type_docs(item: dict, type_row: dict) -> list[str]:
|
||||
return [
|
||||
f"/// C# type: `{item['doc_id']}`.",
|
||||
"///",
|
||||
f"/// Native Rust mapping of C# `{item['signature']}` using decision",
|
||||
f"/// `{type_row['mapping_decision']}`. See the [pinned C# source]({PINNED_CSHARP_SOURCE}).",
|
||||
]
|
||||
|
||||
|
||||
def render_member_docs(row: dict[str, str], indent: str = "") -> list[str]:
|
||||
return [
|
||||
f"{indent}/// C# member: `{row['csharp_id']}`.",
|
||||
f"{indent}///",
|
||||
f"{indent}/// C# signature: `{row['csharp_signature']}`.",
|
||||
f"{indent}/// Mapping contract: ownership `{row['ownership']}`; async `{row['asyncness']}`;",
|
||||
f"{indent}/// errors `{row['error_model']}`; overload `{row['overload_decision']}`; kind `{row['mapping_kind']}`.",
|
||||
]
|
||||
|
||||
|
||||
def render_enum(item: dict, type_row: dict, rust_name: str, member_rows: dict[str, dict[str, str]]) -> list[str]:
|
||||
underlying = UNDERLYING[item["enum_underlying_type"]]
|
||||
values = [member for member in item["members"] if member["kind"] == "enum_value"]
|
||||
lines = [f"/// C# type: `{item['doc_id']}`."]
|
||||
lines = render_type_docs(item, type_row)
|
||||
if item["doc_id"] in OPEN_ENUM_IDS:
|
||||
lines += [
|
||||
"#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]",
|
||||
@@ -3398,10 +3422,10 @@ def render_enum(item: dict, rust_name: str) -> list[str]:
|
||||
f"impl {rust_name} {{",
|
||||
]
|
||||
for member in values:
|
||||
lines += [
|
||||
f" /// C# member: `{member['doc_id']}`.",
|
||||
f" pub const {mapping.pascal(member['name'])}: Self = Self({member['value']['value']});",
|
||||
]
|
||||
lines += render_member_docs(member_rows[member["doc_id"]], " ")
|
||||
lines.append(
|
||||
f" pub const {mapping.pascal(member['name'])}: Self = Self({member['value']['value']});"
|
||||
)
|
||||
lines.append("}")
|
||||
return lines
|
||||
if flags_enum(item) or duplicate_enum_values(item):
|
||||
@@ -3414,7 +3438,8 @@ def render_enum(item: dict, rust_name: str) -> list[str]:
|
||||
for member in values:
|
||||
name = mapping.snake(member["name"]).upper()
|
||||
value = member["value"]["value"]
|
||||
lines += [f" /// C# member: `{member['doc_id']}`.", f" pub const {name}: Self = Self({value});"]
|
||||
lines += render_member_docs(member_rows[member["doc_id"]], " ")
|
||||
lines.append(f" pub const {name}: Self = Self({value});")
|
||||
lines.append("}")
|
||||
return lines
|
||||
lines += [
|
||||
@@ -3423,7 +3448,8 @@ def render_enum(item: dict, rust_name: str) -> list[str]:
|
||||
f"pub enum {rust_name} {{",
|
||||
]
|
||||
for member in values:
|
||||
lines += [f" /// C# member: `{member['doc_id']}`.", f" {mapping.pascal(member['name'])} = {member['value']['value']},"]
|
||||
lines += render_member_docs(member_rows[member["doc_id"]], " ")
|
||||
lines.append(f" {mapping.pascal(member['name'])} = {member['value']['value']},")
|
||||
lines.append("}")
|
||||
return lines
|
||||
|
||||
@@ -3593,16 +3619,17 @@ def rust_constant_value(item: dict) -> str:
|
||||
def render_type(item: dict, type_row: dict, member_rows: dict[str, dict[str, str]], mapper: mapping.Mapper) -> str:
|
||||
rust_name = type_name(type_row)
|
||||
if native_path := NATIVE_TYPES.get(item["doc_id"]):
|
||||
lines = [f"/// C# type: `{item['doc_id']}`."]
|
||||
lines.extend(f"/// C# member: `{member['doc_id']}`." for member in item["members"])
|
||||
lines = render_type_docs(item, type_row)
|
||||
for member in item["members"]:
|
||||
lines.extend(render_member_docs(member_rows[member["doc_id"]]))
|
||||
lines.append(f"pub use {native_path} as {rust_name};")
|
||||
return "\n".join(lines)
|
||||
if item["kind"] == "enum":
|
||||
return "\n".join(render_enum(item, rust_name))
|
||||
return "\n".join(render_enum(item, type_row, rust_name, member_rows))
|
||||
names = generic_names(item)
|
||||
suffix = generic_suffix(names)
|
||||
trait = item["kind"] == "interface"
|
||||
lines = [f"/// C# type: `{item['doc_id']}`."]
|
||||
lines = render_type_docs(item, type_row)
|
||||
if trait:
|
||||
supertrait = TRAIT_SUPERTRAITS.get(item["doc_id"])
|
||||
inheritance = f": {supertrait}" if supertrait else ""
|
||||
@@ -3614,10 +3641,8 @@ def render_type(item: dict, type_row: dict, member_rows: dict[str, dict[str, str
|
||||
native_declaration = NATIVE_DECLARATIONS.get(item["doc_id"])
|
||||
if native_declaration:
|
||||
lines.append(f"pub use {native_declaration} as {rust_name};")
|
||||
lines.extend(
|
||||
f"/// C# member: `{field['doc_id']}`."
|
||||
for field in fields
|
||||
)
|
||||
for field in fields:
|
||||
lines.extend(render_member_docs(member_rows[field["doc_id"]]))
|
||||
elif derives := VALUE_DERIVES.get(item["doc_id"]):
|
||||
lines.append(f"#[derive({derives})]")
|
||||
if native_declaration:
|
||||
@@ -3648,7 +3673,8 @@ def render_type(item: dict, type_row: dict, member_rows: dict[str, dict[str, str
|
||||
raise ValueError(f"invalid mapped field signature: {field_signature}")
|
||||
field_name = field_name_match.group(1)
|
||||
field_type = field_name_match.group(2)
|
||||
lines += [f" /// C# member: `{field['doc_id']}`.", f" pub {field_name}: {field_type},"]
|
||||
lines += render_member_docs(member_rows[field["doc_id"]], " ")
|
||||
lines.append(f" pub {field_name}: {field_type},")
|
||||
for field_name, field_type in private_fields:
|
||||
lines.append(f" {field_name}: {field_type},")
|
||||
if names:
|
||||
@@ -3661,7 +3687,7 @@ def render_type(item: dict, type_row: dict, member_rows: dict[str, dict[str, str
|
||||
if member["kind"] in {"enum_value"} or (member["kind"] == "field" and not member.get("static")):
|
||||
continue
|
||||
row = member_rows[member["doc_id"]]
|
||||
lines.append(f" /// C# member: `{member['doc_id']}`.")
|
||||
lines.extend(render_member_docs(row, " "))
|
||||
if member["kind"] == "constant":
|
||||
lines.append(f" {row['rust_signature']} = {rust_constant_value(member)};")
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user