use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde_json::json;
use syn::spanned::Spanned;
use syn::visit::{self, Visit};
use syn::{
Attribute, ImplItemFn, ItemConst, ItemEnum, ItemFn, ItemImpl, ItemMod, ItemStatic, ItemStruct,
ItemTrait, ItemType, Visibility,
};
use crate::finding::{Finding, Location, Origin, Severity};
use crate::functions::{has_test_attr, qualified_item_path, read_and_parse_source, type_name};
use crate::ingest::CrateInfo;
pub const UNDOCUMENTED_PUBLIC_ITEM_RULE: &str = "undocumented-public-item";
pub const UNDOCUMENTED_PUBLIC_ITEM_RULE_REVISION: u32 = 1;
pub const SEMVER_HAZARD_RULE: &str = "semver-hazard";
pub const SEMVER_HAZARD_RULE_REVISION: u32 = 2;
#[derive(Debug)]
pub enum ApiSurfaceError {
Io(PathBuf, std::io::Error),
Parse(PathBuf, syn::Error),
}
impl std::fmt::Display for ApiSurfaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(path, err) => write!(f, "{}: failed to read file: {err}", path.display()),
Self::Parse(path, err) => write!(f, "{}: failed to parse: {err}", path.display()),
}
}
}
impl std::error::Error for ApiSurfaceError {}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ApiSurfaceSize {
pub per_crate: HashMap<String, usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrateSizeTrend {
pub crate_name: String,
pub item_count: usize,
pub delta: Option<i64>,
}
pub fn size_trend(
current: &ApiSurfaceSize,
baseline_size: Option<&HashMap<String, usize>>,
) -> Vec<CrateSizeTrend> {
let mut trend: Vec<CrateSizeTrend> = current
.per_crate
.iter()
.map(|(crate_name, &item_count)| {
let delta = baseline_size
.and_then(|baseline| baseline.get(crate_name))
.map(|&previous| item_count as i64 - previous as i64);
CrateSizeTrend {
crate_name: crate_name.clone(),
item_count,
delta,
}
})
.collect();
trend.sort_by(|a, b| a.crate_name.cmp(&b.crate_name));
trend
}
#[derive(Debug, Default)]
pub struct WorkspaceApiSurface {
pub findings: Vec<Finding>,
pub errors: Vec<ApiSurfaceError>,
pub excluded_generated: usize,
pub api_surface_size: ApiSurfaceSize,
}
fn analyze_file_inner(
path: &Path,
) -> Result<(Vec<Finding>, usize, Vec<PubFnCandidate>), ApiSurfaceError> {
let (_, ast) = read_and_parse_source(
path,
|err| ApiSurfaceError::Io(path.to_path_buf(), err),
|err| ApiSurfaceError::Parse(path.to_path_buf(), err),
)?;
let mut visitor = ApiSurfaceVisitor {
file: path,
path: Vec::new(),
findings: Vec::new(),
cfg_test_depth: 0,
in_trait_impl: Vec::new(),
item_count: 0,
pub_fn_candidates: Vec::new(),
};
visitor.visit_file(&ast);
Ok((
visitor.findings,
visitor.item_count,
visitor.pub_fn_candidates,
))
}
pub fn analyze_file(path: &Path) -> Result<Vec<Finding>, ApiSurfaceError> {
analyze_file_inner(path).map(|(findings, _, _)| findings)
}
#[cfg_attr(not(feature = "deep"), allow(dead_code))]
pub(crate) fn pub_fn_candidates(path: &Path) -> Result<Vec<PubFnCandidate>, ApiSurfaceError> {
analyze_file_inner(path).map(|(_, _, candidates)| candidates)
}
pub fn analyze_workspace<'a>(
crates: impl IntoIterator<Item = &'a CrateInfo>,
include_generated: bool,
) -> WorkspaceApiSurface {
let mut report = WorkspaceApiSurface::default();
for krate in crates {
let mut crate_item_count = 0;
for file in &krate.source_files {
if !include_generated && !file.kind.is_locally_reportable() {
report.excluded_generated += 1;
continue;
}
match analyze_file_inner(&file.path) {
Ok((mut findings, item_count, _pub_fn_candidates)) => {
report.findings.append(&mut findings);
crate_item_count += item_count;
}
Err(err) => report.errors.push(err),
}
}
report
.api_surface_size
.per_crate
.insert(krate.name.clone(), crate_item_count);
}
report
}
#[derive(Debug, Clone)]
#[cfg_attr(not(feature = "deep"), allow(dead_code))]
pub struct PubFnCandidate {
pub item_path: String,
pub file: PathBuf,
pub ident_span: proc_macro2::Span,
}
struct ApiSurfaceVisitor<'a> {
file: &'a Path,
path: Vec<String>,
findings: Vec<Finding>,
cfg_test_depth: usize,
in_trait_impl: Vec<bool>,
item_count: usize,
#[cfg_attr(not(feature = "deep"), allow(dead_code))]
pub_fn_candidates: Vec<PubFnCandidate>,
}
impl ApiSurfaceVisitor<'_> {
fn current_item_path(&self) -> String {
qualified_item_path(self.file, &self.path)
}
fn current_in_trait_impl(&self) -> bool {
self.in_trait_impl.last().copied().unwrap_or(false)
}
fn record(
&mut self,
rule_id: &str,
span: proc_macro2::Span,
evidence: Option<serde_json::Value>,
) {
let item_path = self.current_item_path();
self.findings.push(build_finding(
self.file,
rule_id,
span,
Severity::Info,
item_path,
evidence,
));
}
fn is_checkable_pub_item(&self, vis: &Visibility, attrs: &[Attribute]) -> bool {
if self.cfg_test_depth > 0 || attrs_have_cfg_test(attrs) {
return false;
}
matches!(vis, Visibility::Public(_))
}
fn enter_scope(&mut self, attrs: &[Attribute], path_segment: String) -> bool {
let gated = attrs_have_cfg_test(attrs);
if gated {
self.cfg_test_depth += 1;
}
self.path.push(path_segment);
gated
}
fn exit_scope(&mut self, gated: bool) {
self.path.pop();
if gated {
self.cfg_test_depth -= 1;
}
}
fn check_doc(&mut self, vis: &Visibility, attrs: &[Attribute], span: proc_macro2::Span) {
if !self.is_checkable_pub_item(vis, attrs) {
return;
}
self.item_count += 1;
if has_doc_comment(attrs) {
return;
}
self.record(UNDOCUMENTED_PUBLIC_ITEM_RULE, span, None);
}
fn check_doc_fn(
&mut self,
vis: &Visibility,
attrs: &[Attribute],
span: proc_macro2::Span,
ident_span: proc_macro2::Span,
) {
if has_test_attr(attrs) {
return;
}
if self.is_checkable_pub_item(vis, attrs) {
self.pub_fn_candidates.push(PubFnCandidate {
item_path: self.current_item_path(),
file: self.file.to_path_buf(),
ident_span,
});
}
self.check_doc(vis, attrs, span);
}
fn check_semver_hazard_enum(&mut self, node: &ItemEnum) {
if !self.is_checkable_pub_item(&node.vis, &node.attrs) {
return;
}
if node.variants.len() < 2 || has_non_exhaustive(&node.attrs) {
return;
}
self.record(
SEMVER_HAZARD_RULE,
node.span(),
Some(json!({
"kind": "missing_non_exhaustive_enum",
"variant_count": node.variants.len(),
})),
);
}
fn check_semver_hazard_struct(&mut self, node: &ItemStruct) {
if !self.is_checkable_pub_item(&node.vis, &node.attrs) {
return;
}
if has_non_exhaustive(&node.attrs) {
return;
}
let pub_field_count = node
.fields
.iter()
.filter(|field| matches!(field.vis, Visibility::Public(_)))
.count();
if pub_field_count == 0 {
return;
}
self.record(
SEMVER_HAZARD_RULE,
node.span(),
Some(json!({
"kind": "missing_non_exhaustive_struct_fields",
"pub_field_count": pub_field_count,
})),
);
}
}
pub(crate) fn has_doc_comment(attrs: &[Attribute]) -> bool {
attrs.iter().any(|attr| attr.path().is_ident("doc"))
}
pub(crate) fn build_finding(
file: &Path,
rule_id: &str,
span: proc_macro2::Span,
severity: Severity,
item_path: String,
evidence: Option<serde_json::Value>,
) -> Finding {
let start = span.start();
let rule = crate::finding::RuleId::from(rule_id);
let evidence_class = crate::finding::evidence_class_for_rule(&rule);
Finding {
id: format!("{rule}:{}:{}:{}", file.display(), start.line, start.column).into(),
rule,
severity,
location: Location {
file: file.to_path_buf(),
line: crate::finding::OneBasedLine::new(start.line)
.expect("proc-macro2 span lines are 1-based"),
item_path,
},
evidence_class,
origin: Origin::Code,
evidence,
limitations: None,
caused_by: Vec::new(),
causes: Vec::new(),
}
}
fn has_non_exhaustive(attrs: &[Attribute]) -> bool {
attrs
.iter()
.any(|attr| attr.path().is_ident("non_exhaustive"))
}
fn attrs_have_cfg_test(attrs: &[Attribute]) -> bool {
attrs.iter().any(|attr| {
if !attr.path().is_ident("cfg") {
return false;
}
let syn::Meta::List(list) = &attr.meta else {
return false;
};
list.tokens
.to_string()
.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|word| word == "test")
})
}
macro_rules! visit_documented_scoped_item {
($method:ident, $node:ty, $traverse:ident) => {
fn $method(&mut self, node: &'ast $node) {
let gated = attrs_have_cfg_test(&node.attrs);
if gated {
self.cfg_test_depth += 1;
}
self.path.push(node.ident.to_string());
self.check_doc(&node.vis, &node.attrs, node.span());
visit::$traverse(self, node);
self.path.pop();
if gated {
self.cfg_test_depth -= 1;
}
}
};
($method:ident, $node:ty, $traverse:ident, $extra:ident) => {
fn $method(&mut self, node: &'ast $node) {
let gated = attrs_have_cfg_test(&node.attrs);
if gated {
self.cfg_test_depth += 1;
}
self.path.push(node.ident.to_string());
self.check_doc(&node.vis, &node.attrs, node.span());
self.$extra(node);
visit::$traverse(self, node);
self.path.pop();
if gated {
self.cfg_test_depth -= 1;
}
}
};
}
macro_rules! visit_documented_leaf_item {
($method:ident, $node:ty, $traverse:ident) => {
fn $method(&mut self, node: &'ast $node) {
let gated = attrs_have_cfg_test(&node.attrs);
if gated {
self.cfg_test_depth += 1;
}
self.path.push(node.ident.to_string());
self.check_doc(&node.vis, &node.attrs, node.span());
self.path.pop();
visit::$traverse(self, node);
if gated {
self.cfg_test_depth -= 1;
}
}
};
}
impl<'ast> Visit<'ast> for ApiSurfaceVisitor<'_> {
fn visit_item_mod(&mut self, node: &'ast ItemMod) {
let gated = attrs_have_cfg_test(&node.attrs);
if gated {
self.cfg_test_depth += 1;
}
if node.content.is_some() {
self.path.push(node.ident.to_string());
visit::visit_item_mod(self, node);
self.path.pop();
} else {
visit::visit_item_mod(self, node);
}
if gated {
self.cfg_test_depth -= 1;
}
}
fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
let gated = self.enter_scope(&node.attrs, type_name(&node.self_ty));
self.in_trait_impl.push(node.trait_.is_some());
visit::visit_item_impl(self, node);
self.in_trait_impl.pop();
self.exit_scope(gated);
}
visit_documented_scoped_item!(visit_item_trait, ItemTrait, visit_item_trait);
visit_documented_scoped_item!(
visit_item_struct,
ItemStruct,
visit_item_struct,
check_semver_hazard_struct
);
visit_documented_scoped_item!(
visit_item_enum,
ItemEnum,
visit_item_enum,
check_semver_hazard_enum
);
visit_documented_leaf_item!(visit_item_const, ItemConst, visit_item_const);
visit_documented_leaf_item!(visit_item_static, ItemStatic, visit_item_static);
visit_documented_leaf_item!(visit_item_type, ItemType, visit_item_type);
fn visit_item_fn(&mut self, node: &'ast ItemFn) {
let gated = self.enter_scope(&node.attrs, node.sig.ident.to_string());
self.check_doc_fn(&node.vis, &node.attrs, node.span(), node.sig.ident.span());
visit::visit_item_fn(self, node);
self.exit_scope(gated);
}
fn visit_impl_item_fn(&mut self, node: &'ast ImplItemFn) {
let gated = self.enter_scope(&node.attrs, node.sig.ident.to_string());
if !self.current_in_trait_impl() {
self.check_doc_fn(&node.vis, &node.attrs, node.span(), node.sig.ident.span());
}
visit::visit_impl_item_fn(self, node);
self.exit_scope(gated);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ingest::SourceFile;
use crate::test_util::TempDir;
fn write_and_analyze(dir: &TempDir, source: &str) -> Vec<Finding> {
let file = dir.join("lib.rs");
std::fs::write(&file, source).unwrap();
analyze_file(&file).unwrap()
}
#[test]
fn pub_fn_without_doc_comment_is_flagged() {
let dir = TempDir::new("api-surface-pub-fn");
let findings = write_and_analyze(&dir, "pub fn undocumented() {}\n");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule, UNDOCUMENTED_PUBLIC_ITEM_RULE);
assert_eq!(findings[0].severity, Severity::Info);
assert_eq!(
findings[0].evidence_class,
crate::finding::EvidenceClass::DerivedFact
);
}
#[test]
fn undocumented_public_item_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(UNDOCUMENTED_PUBLIC_ITEM_RULE)
.expect("undocumented-public-item has a registry entry")
.example
.expect("undocumented-public-item has a curated example")
.before;
let dir = TempDir::new("api-surface-undocumented-public-item-registry-example");
let findings = write_and_analyze(&dir, example);
assert_eq!(
findings
.iter()
.filter(|f| f.rule == UNDOCUMENTED_PUBLIC_ITEM_RULE)
.count(),
1
);
}
#[test]
fn pub_struct_and_pub_enum_without_doc_comment_are_flagged() {
let dir = TempDir::new("api-surface-pub-struct-enum");
let findings = write_and_analyze(&dir, "pub struct Foo;\n\npub enum Bar { A, B }\n");
let undocumented: Vec<_> = findings
.iter()
.filter(|f| f.rule == UNDOCUMENTED_PUBLIC_ITEM_RULE)
.collect();
assert_eq!(undocumented.len(), 2);
}
#[test]
fn pub_item_with_doc_comment_is_not_flagged() {
let dir = TempDir::new("api-surface-documented");
let findings = write_and_analyze(
&dir,
r#"
/// Does the thing.
pub fn documented() {}
/// A documented struct.
pub struct Documented;
"#,
);
assert!(findings.is_empty(), "unexpected findings: {findings:?}");
}
#[test]
fn non_pub_item_is_not_flagged() {
let dir = TempDir::new("api-surface-non-pub");
let findings = write_and_analyze(
&dir,
"pub(crate) fn crate_visible() {}\n\nfn private() {}\n",
);
assert!(findings.is_empty(), "unexpected findings: {findings:?}");
}
#[test]
fn method_in_trait_impl_without_own_doc_comment_is_not_flagged() {
let dir = TempDir::new("api-surface-trait-impl");
let findings = write_and_analyze(
&dir,
r#"
pub struct Foo;
pub trait Greet {
fn hi(&self);
}
impl Greet for Foo {
fn hi(&self) {}
}
"#,
);
assert_eq!(findings.len(), 2);
assert!(
findings.iter().all(|f| f.location.item_path != "Foo::hi"),
"trait-impl method must not be flagged: {findings:?}"
);
}
#[test]
fn pub_method_in_inherent_impl_without_doc_comment_is_flagged() {
let dir = TempDir::new("api-surface-inherent-impl");
let findings = write_and_analyze(
&dir,
r#"
pub struct Foo;
impl Foo {
pub fn bar(&self) {}
}
"#,
);
assert!(
findings.iter().any(|f| f.location.item_path == "Foo::bar"),
"inherent-impl pub method must be flagged: {findings:?}"
);
}
#[test]
fn test_attributed_function_is_not_flagged() {
let dir = TempDir::new("api-surface-test-fn");
let findings = write_and_analyze(
&dir,
r#"
#[test]
pub fn a_test() {
assert!(true);
}
"#,
);
assert!(findings.is_empty(), "unexpected findings: {findings:?}");
}
#[test]
fn cfg_test_gated_item_is_not_flagged() {
let dir = TempDir::new("api-surface-cfg-test");
let findings = write_and_analyze(
&dir,
r#"
#[cfg(test)]
mod tests {
pub fn helper() {}
}
"#,
);
assert!(findings.is_empty(), "unexpected findings: {findings:?}");
}
fn authored(path: PathBuf) -> SourceFile {
SourceFile {
path,
kind: crate::ingest::SourceKind::Authored,
}
}
fn test_crate(dir: &TempDir, name: &str, source_files: Vec<SourceFile>) -> CrateInfo {
CrateInfo {
name: name.to_string(),
version: "0.1.0".to_string(),
manifest_path: dir.join("Cargo.toml"),
root: dir.to_path_buf(),
source_files,
entry_points: Vec::new(),
dependencies: Vec::new(),
}
}
#[test]
fn analyze_workspace_skips_generated_files_unless_included() {
let dir = TempDir::new("api-surface-generated");
let authored_file = dir.join("lib.rs");
let generated_file = dir.join("schema.rs");
std::fs::write(&authored_file, "pub fn ok() {}\n").unwrap();
std::fs::write(&generated_file, "pub fn also_ok() {}\n").unwrap();
let krate = test_crate(
&dir,
"fixture",
vec![
authored(authored_file),
SourceFile {
path: generated_file,
kind: crate::ingest::SourceKind::Generated,
},
],
);
let excluded = analyze_workspace([&krate], false);
assert_eq!(excluded.findings.len(), 1);
assert_eq!(excluded.excluded_generated, 1);
assert_eq!(excluded.api_surface_size.per_crate["fixture"], 1);
let included = analyze_workspace([&krate], true);
assert_eq!(included.findings.len(), 2);
assert_eq!(included.excluded_generated, 0);
assert_eq!(included.api_surface_size.per_crate["fixture"], 2);
}
#[test]
fn analyze_workspace_counts_public_items_per_crate() {
let dir = TempDir::new("api-surface-size");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"/// Doc.\npub fn a() {}\n\n/// Doc.\npub fn b() {}\n\nfn private() {}\n",
)
.unwrap();
let krate = test_crate(&dir, "fixture", vec![authored(file)]);
let report = analyze_workspace([&krate], false);
assert!(
report.findings.is_empty(),
"unexpected findings: {:?}",
report.findings
);
assert_eq!(report.api_surface_size.per_crate.len(), 1);
assert_eq!(report.api_surface_size.per_crate["fixture"], 2);
}
#[test]
fn analyze_workspace_counts_each_crate_separately() {
let dir_a = TempDir::new("api-surface-size-crate-a");
let file_a = dir_a.join("lib.rs");
std::fs::write(&file_a, "/// Doc.\npub fn a() {}\n").unwrap();
let krate_a = test_crate(&dir_a, "crate-a", vec![authored(file_a)]);
let dir_b = TempDir::new("api-surface-size-crate-b");
let file_b = dir_b.join("lib.rs");
std::fs::write(
&file_b,
"/// Doc.\npub fn a() {}\n\n/// Doc.\npub fn b() {}\n\n/// Doc.\npub fn c() {}\n",
)
.unwrap();
let krate_b = test_crate(&dir_b, "crate-b", vec![authored(file_b)]);
let report = analyze_workspace([&krate_a, &krate_b], false);
assert_eq!(report.api_surface_size.per_crate.len(), 2);
assert_eq!(report.api_surface_size.per_crate["crate-a"], 1);
assert_eq!(report.api_surface_size.per_crate["crate-b"], 3);
}
#[test]
fn size_trend_reports_delta_against_a_baseline() {
let mut current = ApiSurfaceSize::default();
current.per_crate.insert("fixture".to_string(), 5);
let baseline = HashMap::from([("fixture".to_string(), 3)]);
let trend = size_trend(¤t, Some(&baseline));
assert_eq!(trend.len(), 1);
assert_eq!(trend[0].crate_name, "fixture");
assert_eq!(trend[0].item_count, 5);
assert_eq!(trend[0].delta, Some(2));
}
#[test]
fn size_trend_is_not_comparable_without_a_baseline_size() {
let mut current = ApiSurfaceSize::default();
current.per_crate.insert("fixture".to_string(), 5);
let trend = size_trend(¤t, None);
assert_eq!(trend.len(), 1);
assert_eq!(trend[0].item_count, 5);
assert_eq!(trend[0].delta, None);
}
#[test]
fn analyze_file_reports_parse_errors() {
let dir = TempDir::new("api-surface-parse-error");
let file = dir.join("broken.rs");
std::fs::write(&file, "pub fn broken( {").unwrap();
let err = analyze_file(&file).unwrap_err();
match err {
ApiSurfaceError::Parse(path, _) => assert_eq!(path, file),
other => panic!("expected a parse error, got {other:?}"),
}
}
fn semver_hazard_findings(findings: &[Finding]) -> Vec<&Finding> {
findings
.iter()
.filter(|f| f.rule == SEMVER_HAZARD_RULE)
.collect()
}
#[test]
fn pub_enum_with_two_variants_and_no_non_exhaustive_is_flagged() {
let dir = TempDir::new("api-surface-semver-enum-flagged");
let findings = write_and_analyze(
&dir,
r#"
/// Doc.
pub enum Bar {
A,
B,
}
"#,
);
let hits = semver_hazard_findings(&findings);
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].evidence.as_ref().unwrap()["kind"],
"missing_non_exhaustive_enum"
);
assert_eq!(hits[0].evidence.as_ref().unwrap()["variant_count"], 2);
assert_eq!(hits[0].severity, Severity::Info);
assert_eq!(
hits[0].evidence_class,
crate::finding::EvidenceClass::DerivedFact
);
}
#[test]
fn semver_hazard_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(SEMVER_HAZARD_RULE)
.expect("semver-hazard has a registry entry")
.example
.expect("semver-hazard has a curated example")
.before;
let dir = TempDir::new("api-surface-semver-hazard-registry-example");
let findings = write_and_analyze(&dir, example);
assert_eq!(semver_hazard_findings(&findings).len(), 1);
}
#[test]
fn pub_enum_with_non_exhaustive_is_not_flagged() {
let dir = TempDir::new("api-surface-semver-enum-exempt");
let findings = write_and_analyze(
&dir,
r#"
/// Doc.
#[non_exhaustive]
pub enum Bar {
A,
B,
}
"#,
);
assert!(semver_hazard_findings(&findings).is_empty());
}
#[test]
fn pub_enum_with_single_variant_is_not_flagged() {
let dir = TempDir::new("api-surface-semver-enum-single-variant");
let findings = write_and_analyze(
&dir,
r#"
/// Doc.
pub enum Bar {
A,
}
"#,
);
assert!(semver_hazard_findings(&findings).is_empty());
}
#[test]
fn pub_struct_with_pub_field_and_no_non_exhaustive_is_flagged() {
let dir = TempDir::new("api-surface-semver-struct-flagged");
let findings = write_and_analyze(
&dir,
r#"
/// Doc.
pub struct Foo {
pub value: i32,
}
"#,
);
let hits = semver_hazard_findings(&findings);
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].evidence.as_ref().unwrap()["kind"],
"missing_non_exhaustive_struct_fields"
);
assert_eq!(hits[0].evidence.as_ref().unwrap()["pub_field_count"], 1);
}
#[test]
fn pub_struct_with_non_exhaustive_is_not_flagged() {
let dir = TempDir::new("api-surface-semver-struct-exempt");
let findings = write_and_analyze(
&dir,
r#"
/// Doc.
#[non_exhaustive]
pub struct Foo {
pub value: i32,
}
"#,
);
assert!(semver_hazard_findings(&findings).is_empty());
}
#[test]
fn pub_struct_with_only_private_fields_is_not_flagged() {
let dir = TempDir::new("api-surface-semver-struct-private-fields");
let findings = write_and_analyze(
&dir,
r#"
/// Doc.
pub struct Foo {
value: i32,
}
"#,
);
assert!(semver_hazard_findings(&findings).is_empty());
}
#[test]
fn tuple_and_unit_structs_are_not_flagged() {
let dir = TempDir::new("api-surface-semver-struct-tuple-unit");
let findings = write_and_analyze(
&dir,
r#"
/// Doc.
pub struct Tuple(i32);
/// Doc.
pub struct Unit;
"#,
);
assert!(semver_hazard_findings(&findings).is_empty());
}
#[test]
fn analyze_workspace_hides_semver_hazard_in_generated_files_by_default() {
let dir = TempDir::new("api-surface-semver-generated");
let generated_file = dir.join("schema.rs");
std::fs::write(
&generated_file,
r#"
/// Doc.
pub enum Bar {
A,
B,
}
"#,
)
.unwrap();
let krate = test_crate(
&dir,
"fixture",
vec![SourceFile {
path: generated_file,
kind: crate::ingest::SourceKind::Generated,
}],
);
let excluded = analyze_workspace([&krate], false);
assert!(semver_hazard_findings(&excluded.findings).is_empty());
assert_eq!(excluded.excluded_generated, 1);
let included = analyze_workspace([&krate], true);
assert_eq!(semver_hazard_findings(&included.findings).len(), 1);
}
}