diff --git a/cstat-agent-use.md b/cstat-agent-use.md index 3880374..86cbe47 100644 --- a/cstat-agent-use.md +++ b/cstat-agent-use.md @@ -20,7 +20,8 @@ report. The JSON contains: - `line_counts`: total code lines, physical lines, and per-file counts. - `symbols`: total and per-file counts for functions, structs, enums, traits, - and impl blocks. + `trait_impls`, consts, statics, and parse-error state. `trait_impls` means + `impl Trait for Type` blocks only, not inherent `impl Type` blocks. - `dependencies`: module list, module interdependency matrix, edge count, and fan-in/fan-out degrees. - `dead_code`: functions not statically reachable from main, tests, or @@ -40,7 +41,8 @@ Each accepts `--json` for structured output. - `cstat loc --json --path .` — project size-shape data. - `cstat loc --json --path src/lib.rs` — selected-file projected static line reachability. -- `cstat symbols --json --path .` — symbol totals by kind and per file. +- `cstat symbols --json --path .` — symbol totals by kind and per file; pass a + Rust source file to `--path` for selected-file rows and line spans. - `cstat deps --json --path .` — dependency edges, coupling, fan-in/fan-out, and cohesion. - `cstat dead-code --json --path .` — static cold-function candidates. @@ -89,5 +91,10 @@ Use these only when the focused report points to a question they answer: - For exact `loc` `code_lines` rules and JSON fields, run `cstat loc --explain` or `cstat loc --explain --json`; that command is the canonical contract. +- Symbol counts come from `syn` Rust AST parsing. They are not semantic name + resolution, rustc integration, macro expansion, or proof of public API usage. + Parse errors are reported instead of ignored; selected-file mode is used when + `--path` points at a Rust source file under a crate's source, test, or bench + root. - Prefer targeted reductions: remove dead code, split large files, move symbols across modules, then reduce per-function complexity. diff --git a/src/ast_parser.rs b/src/ast_parser.rs index 706d7ea..56d97e1 100644 --- a/src/ast_parser.rs +++ b/src/ast_parser.rs @@ -33,6 +33,8 @@ pub struct StructInfo { pub field_count: usize, pub derive_count: usize, pub generic_param_count: usize, + pub line_start: usize, + pub line_end: usize, } /// Extracted information about an enum. @@ -44,6 +46,8 @@ pub struct EnumInfo { pub variant_count: usize, pub derive_count: usize, pub generic_param_count: usize, + pub line_start: usize, + pub line_end: usize, } /// Extracted information about a trait. @@ -54,6 +58,8 @@ pub struct TraitInfo { pub file: PathBuf, pub method_count: usize, pub generic_param_count: usize, + pub line_start: usize, + pub line_end: usize, } /// Extracted information about a const item. @@ -62,6 +68,8 @@ pub struct TraitInfo { pub struct ConstInfo { pub name: String, pub file: PathBuf, + pub line_start: usize, + pub line_end: usize, } /// Extracted information about a static item. @@ -70,6 +78,8 @@ pub struct ConstInfo { pub struct StaticInfo { pub name: String, pub file: PathBuf, + pub line_start: usize, + pub line_end: usize, } /// Extracted information about an impl block. @@ -78,6 +88,8 @@ pub struct StaticInfo { pub struct ImplInfo { pub target_type: String, pub file: PathBuf, + pub line_start: usize, + pub line_end: usize, pub method_count: usize, pub trait_name: Option, } @@ -218,6 +230,15 @@ impl SymbolExtractor { syn::Fields::Unnamed(f) => f.unnamed.len(), syn::Fields::Unit => 0, }; + let line_start = s.struct_token.span.start().line; + let line_end = match &s.fields { + syn::Fields::Named(fields) => fields.brace_token.span.close().end().line, + syn::Fields::Unnamed(fields) => fields.paren_token.span.close().end().line, + syn::Fields::Unit => s + .semi_token + .as_ref() + .map_or(line_start, |semi| semi.span.end().line), + }; let derive_count = count_derives(&s.attrs); let generic_param_count = s.generics.params.len(); self.structs.push(StructInfo { @@ -226,6 +247,8 @@ impl SymbolExtractor { field_count, derive_count, generic_param_count, + line_start, + line_end, }); } Item::Enum(e) => { @@ -237,6 +260,8 @@ impl SymbolExtractor { variant_count: e.variants.len(), derive_count, generic_param_count, + line_start: e.enum_token.span.start().line, + line_end: e.brace_token.span.close().end().line, }); } Item::Trait(t) => { @@ -251,18 +276,24 @@ impl SymbolExtractor { file: self.file_path.clone(), method_count, generic_param_count, + line_start: t.trait_token.span.start().line, + line_end: t.brace_token.span.close().end().line, }); } Item::Const(c) => { self.consts.push(ConstInfo { name: self.qualify_name(&c.ident.to_string()), file: self.file_path.clone(), + line_start: c.const_token.span.start().line, + line_end: c.semi_token.span.end().line, }); } Item::Static(s) => { self.statics.push(StaticInfo { name: self.qualify_name(&s.ident.to_string()), file: self.file_path.clone(), + line_start: s.static_token.span.start().line, + line_end: s.semi_token.span.end().line, }); } Item::Impl(imp) => { @@ -274,6 +305,8 @@ impl SymbolExtractor { .collect::>() .join("::") }); + let line_start = imp.impl_token.span.start().line; + let line_end = imp.brace_token.span.close().end().line; let mut method_count = 0; for impl_item in &imp.items { if let ImplItem::Fn(method) = impl_item { @@ -294,6 +327,8 @@ impl SymbolExtractor { self.impls.push(ImplInfo { target_type, file: self.file_path.clone(), + line_start, + line_end, method_count, trait_name, }); diff --git a/src/file_summary.rs b/src/file_summary.rs index bb6b1ae..228276f 100644 --- a/src/file_summary.rs +++ b/src/file_summary.rs @@ -280,14 +280,14 @@ fn render_symbols(report: &symbols::FileSymbolReport, file: &str, verbose: bool) if verbose { let full_section = format!("Full section: cstat symbols --path {file} -v"); render::verbose_block(&[ - "Counts are Rust AST items found in the selected file.", - "Functions includes free functions and impl methods.", + "Selected-file symbols use syn Rust AST item discovery.", + "Functions include free functions and impl methods once.", full_section.as_str(), ]); } println!( - " {} {} {} {} {} {} {} {} {} {} {} {} {} {}", + " {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}", "total".cyan(), report.total.to_string().bold(), "fn".cyan(), @@ -298,12 +298,23 @@ fn render_symbols(report: &symbols::FileSymbolReport, file: &str, verbose: bool) report.enums.to_string().bold(), "trait".cyan(), report.traits.to_string().bold(), + "trait_impl".cyan(), + report.trait_impls.to_string().bold(), "const".cyan(), report.consts.to_string().bold(), "static".cyan(), report.statics.to_string().bold(), ); + if report.parse_error { + println!( + " {}", + "parse error: selected file could not be parsed" + .yellow() + .bold() + ); + } + if !report.symbols.is_empty() { println!(); println!(" {:<10} {}", "kind".bold(), "symbol".bold()); @@ -465,6 +476,7 @@ fn file_symbol_kind_label(kind: &symbols::FileSymbolKind) -> &'static str { symbols::FileSymbolKind::Struct => "struct", symbols::FileSymbolKind::Enum => "enum", symbols::FileSymbolKind::Trait => "trait", + symbols::FileSymbolKind::TraitImpl => "trait_impl", symbols::FileSymbolKind::Const => "const", symbols::FileSymbolKind::Static => "static", } diff --git a/src/main.rs b/src/main.rs index 822ab98..6cd2cc1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,7 +91,7 @@ Use --explain to print this usage and JSON field contract without running analys #[arg(long)] explain: bool, }, - /// Symbol counts by kind and file + /// Rust AST symbol counts by kind, including trait impl blocks Symbols, /// Per-function and per-file complexity rankings Complexity, diff --git a/src/symbols.rs b/src/symbols.rs index 4e551ec..325729e 100644 --- a/src/symbols.rs +++ b/src/symbols.rs @@ -12,7 +12,9 @@ pub struct SymbolTotals { pub structs: usize, pub enums: usize, pub traits: usize, - pub impls: usize, + pub trait_impls: usize, + pub consts: usize, + pub statics: usize, pub parse_error_files: usize, } @@ -24,7 +26,9 @@ pub struct FileSymbolCounts { pub structs: usize, pub enums: usize, pub traits: usize, - pub impls: usize, + pub trait_impls: usize, + pub consts: usize, + pub statics: usize, pub parse_error: bool, } @@ -41,6 +45,7 @@ pub enum FileSymbolKind { Struct, Enum, Trait, + TraitImpl, Const, Static, } @@ -61,8 +66,10 @@ pub struct FileSymbolReport { pub structs: usize, pub enums: usize, pub traits: usize, + pub trait_impls: usize, pub consts: usize, pub statics: usize, + pub parse_error: bool, pub symbols: Vec, } @@ -75,8 +82,10 @@ pub fn analyze_symbols(symbols: &ProjectSymbols, project_path: &Path) -> SymbolR let structs = file.structs.len(); let enums = file.enums.len(); let traits = file.traits.len(); - let impls = file.impls.len(); - let total = functions + structs + enums + traits + impls; + let trait_impls = file.impls.iter().filter(|i| i.trait_name.is_some()).count(); + let consts = file.consts.len(); + let statics = file.statics.len(); + let total = functions + structs + enums + traits + trait_impls + consts + statics; FileSymbolCounts { path: strip_prefix(&file.path, project_path).display().to_string(), @@ -85,7 +94,9 @@ pub fn analyze_symbols(symbols: &ProjectSymbols, project_path: &Path) -> SymbolR structs, enums, traits, - impls, + trait_impls, + consts, + statics, parse_error: file.parse_error, } }) @@ -99,7 +110,9 @@ pub fn analyze_symbols(symbols: &ProjectSymbols, project_path: &Path) -> SymbolR acc.structs += file.structs; acc.enums += file.enums; acc.traits += file.traits; - acc.impls += file.impls; + acc.trait_impls += file.trait_impls; + acc.consts += file.consts; + acc.statics += file.statics; if file.parse_error { acc.parse_error_files += 1; } @@ -128,8 +141,10 @@ pub fn analyze_symbols_file( structs: 0, enums: 0, traits: 0, + trait_impls: 0, consts: 0, statics: 0, + parse_error: false, symbols: Vec::new(), }; }; @@ -147,40 +162,50 @@ pub fn analyze_symbols_file( rows.push(FileSymbolRow { kind: FileSymbolKind::Struct, symbol: item.name.clone(), - line_start: None, - line_end: None, + line_start: Some(item.line_start), + line_end: Some(item.line_end), }); } for item in &file_symbols.enums { rows.push(FileSymbolRow { kind: FileSymbolKind::Enum, symbol: item.name.clone(), - line_start: None, - line_end: None, + line_start: Some(item.line_start), + line_end: Some(item.line_end), }); } for item in &file_symbols.traits { rows.push(FileSymbolRow { kind: FileSymbolKind::Trait, symbol: item.name.clone(), - line_start: None, - line_end: None, + line_start: Some(item.line_start), + line_end: Some(item.line_end), }); } + for item in &file_symbols.impls { + if let Some(trait_name) = &item.trait_name { + rows.push(FileSymbolRow { + kind: FileSymbolKind::TraitImpl, + symbol: format!("<{} as {}>", item.target_type, trait_name), + line_start: Some(item.line_start), + line_end: Some(item.line_end), + }); + } + } for item in &file_symbols.consts { rows.push(FileSymbolRow { kind: FileSymbolKind::Const, symbol: item.name.clone(), - line_start: None, - line_end: None, + line_start: Some(item.line_start), + line_end: Some(item.line_end), }); } for item in &file_symbols.statics { rows.push(FileSymbolRow { kind: FileSymbolKind::Static, symbol: item.name.clone(), - line_start: None, - line_end: None, + line_start: Some(item.line_start), + line_end: Some(item.line_end), }); } @@ -188,9 +213,14 @@ pub fn analyze_symbols_file( let structs = file_symbols.structs.len(); let enums = file_symbols.enums.len(); let traits = file_symbols.traits.len(); + let trait_impls = file_symbols + .impls + .iter() + .filter(|item| item.trait_name.is_some()) + .count(); let consts = file_symbols.consts.len(); let statics = file_symbols.statics.len(); - let total = functions + structs + enums + traits + consts + statics; + let total = functions + structs + enums + traits + trait_impls + consts + statics; FileSymbolReport { file: file_display, @@ -199,8 +229,10 @@ pub fn analyze_symbols_file( structs, enums, traits, + trait_impls, consts, statics, + parse_error: file_symbols.parse_error, symbols: rows, } } @@ -210,14 +242,16 @@ pub fn render_symbol_report(report: &SymbolReport, verbose: bool) { if verbose { render::verbose_block(&[ - "Counts are Rust AST items found in parseable files.", - "Functions includes free functions and impl methods.", - "Parse-error files are listed with zero symbols so the report stays deterministic.", + "symbols uses syn Rust AST item discovery.", + "It does not perform semantic name resolution, rustc analysis, macro expansion, or public API usage proof.", + "Functions include free functions and methods in any impl block; methods are counted once as functions.", + "trait_impls counts only impl Trait for Type blocks; inherent impl Type containers are not counted separately.", + "Parse-error files are surfaced with zero counts and parse_error=true in JSON.", ]); } println!( - " {} {} {} {} {} {} {} {} {} {} {} {}", + " {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}", "total".cyan(), report.totals.total.to_string().bold(), "fn".cyan(), @@ -228,8 +262,12 @@ pub fn render_symbol_report(report: &SymbolReport, verbose: bool) { report.totals.enums.to_string().bold(), "trait".cyan(), report.totals.traits.to_string().bold(), - "impl".cyan(), - report.totals.impls.to_string().bold(), + "trait_impl".cyan(), + report.totals.trait_impls.to_string().bold(), + "const".cyan(), + report.totals.consts.to_string().bold(), + "static".cyan(), + report.totals.statics.to_string().bold(), ); if report.totals.parse_error_files > 0 { @@ -242,26 +280,30 @@ pub fn render_symbol_report(report: &SymbolReport, verbose: bool) { println!(); println!( - " {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>5}", + " {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>11} {:>6} {:>6}", "file".bold(), "total".bold(), "fn".bold(), "struct".bold(), "enum".bold(), "trait".bold(), - "impl".bold() + "trait_impl".bold(), + "const".bold(), + "static".bold() ); for file in &report.files { let marker = if file.parse_error { " !" } else { "" }; println!( - " {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>5}", + " {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>11} {:>6} {:>6}", truncate(&format!("{}{}", file.path, marker), 48), file.total, file.functions, file.structs, file.enums, file.traits, - file.impls, + file.trait_impls, + file.consts, + file.statics, ); } } @@ -293,14 +335,16 @@ pub fn render_file_symbol_report(report: &FileSymbolReport, verbose: bool) { if verbose { render::verbose_block(&[ - "Counts are Rust AST items found in the selected file.", - "Functions includes free functions and impl methods.", - "Line spans are available for functions; other symbol kinds show lines -.", + "Selected-file mode is used when --path points at a Rust source file.", + "symbols uses syn Rust AST item discovery; it does not perform semantic name resolution, rustc analysis, macro expansion, or public API usage proof.", + "Functions include free functions and methods in any impl block; methods are counted once as functions.", + "trait_impls counts only impl Trait for Type blocks; inherent impl Type containers are not counted separately.", + "Line spans come from syn/proc-macro2 token spans for Rust item rows.", ]); } println!( - " {} {} {} {} {} {} {} {} {} {} {} {} {} {}", + " {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}", "total".cyan(), report.total.to_string().bold(), "fn".cyan(), @@ -311,12 +355,23 @@ pub fn render_file_symbol_report(report: &FileSymbolReport, verbose: bool) { report.enums.to_string().bold(), "trait".cyan(), report.traits.to_string().bold(), + "trait_impl".cyan(), + report.trait_impls.to_string().bold(), "const".cyan(), report.consts.to_string().bold(), "static".cyan(), report.statics.to_string().bold(), ); + if report.parse_error { + println!( + " {}", + "parse error: selected file could not be parsed" + .yellow() + .bold() + ); + } + println!(); println!( " {:<10} {:<48} {}", @@ -344,6 +399,7 @@ fn file_symbol_kind_label(kind: &FileSymbolKind) -> &'static str { FileSymbolKind::Struct => "struct", FileSymbolKind::Enum => "enum", FileSymbolKind::Trait => "trait", + FileSymbolKind::TraitImpl => "trait_impl", FileSymbolKind::Const => "const", FileSymbolKind::Static => "static", } diff --git a/tests/symbols_cli.rs b/tests/symbols_cli.rs new file mode 100644 index 0000000..eb879aa --- /dev/null +++ b/tests/symbols_cli.rs @@ -0,0 +1,296 @@ +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const LIB_RS: &str = r#"const LIMIT: usize = 10; +static NAME: &str = "fixture"; + +struct Parser; + +enum Mode { + Fast, +} + +trait Parse { + fn parse(&self); +} + +impl Parser { + fn new() -> Self { + Self + } +} + +impl Parse for Parser { + fn parse(&self) {} +} + +fn free() { + let _ = LIMIT; +} + +mod inline { + pub struct Inner; + + pub const FLAG: bool = true; + + pub fn nested() {} +} +"#; + +fn temp_project(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("cstat-symbols-{name}-{unique}")); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + fs::write(root.join("src/lib.rs"), LIB_RS).unwrap(); + fs::write(root.join("src/broken.rs"), "fn broken(\n").unwrap(); + root +} + +fn run_cstat(path: &Path, args: &[&str]) -> Output { + let bin = env!("CARGO_BIN_EXE_cstat"); + let mut command = Command::new(bin); + command.args(["--no-color", "--path"]); + command.arg(path); + command.args(args); + command.output().expect("invoke cstat binary") +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "cstat failed: status={:?}\nstderr={}\nstdout={}", + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); +} + +fn parse_json(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).expect("parse symbols JSON") +} + +fn find_file<'a>(value: &'a Value, path: &str) -> &'a Value { + value["files"] + .as_array() + .expect("files array") + .iter() + .find(|entry| entry["path"] == path) + .unwrap_or_else(|| panic!("missing file row {path}: {value}")) +} + +fn assert_zero_symbol_counts(value: &Value) { + for field in [ + "total", + "functions", + "structs", + "enums", + "traits", + "trait_impls", + "consts", + "statics", + ] { + assert_eq!(value[field], 0, "expected zero {field}: {value}"); + } +} + +fn find_symbol<'a>(rows: &'a [Value], kind: &str, symbol: &str) -> &'a Value { + rows.iter() + .find(|row| row["kind"] == kind && row["symbol"] == symbol) + .unwrap_or_else(|| panic!("missing {kind} symbol {symbol}: {rows:?}")) +} + +#[test] +fn project_json_reports_trait_impl_contract_and_parse_errors() { + let root = temp_project("project-json"); + let output = run_cstat(&root, &["--json", "symbols"]); + assert_success(&output); + + let value = parse_json(&output); + assert!(value.get("totals").is_some(), "json={value}"); + assert!(value.get("files").is_some(), "json={value}"); + + let totals = &value["totals"]; + assert_eq!(totals["total"], 12); + assert_eq!(totals["functions"], 4); + assert_eq!(totals["structs"], 2); + assert_eq!(totals["enums"], 1); + assert_eq!(totals["traits"], 1); + assert_eq!(totals["trait_impls"], 1); + assert_eq!(totals["consts"], 2); + assert_eq!(totals["statics"], 1); + assert_eq!(totals["parse_error_files"], 1); + assert!( + totals.get("impls").is_none(), + "old impls key present: {totals}" + ); + + let lib = find_file(&value, "src/lib.rs"); + assert_eq!(lib["total"], 12); + assert_eq!(lib["functions"], 4); + assert_eq!(lib["structs"], 2); + assert_eq!(lib["enums"], 1); + assert_eq!(lib["traits"], 1); + assert_eq!(lib["trait_impls"], 1); + assert_eq!(lib["consts"], 2); + assert_eq!(lib["statics"], 1); + assert_eq!(lib["parse_error"], false); + assert!(lib.get("impls").is_none(), "old impls key present: {lib}"); + + let broken = find_file(&value, "src/broken.rs"); + assert_eq!(broken["parse_error"], true); + assert_zero_symbol_counts(broken); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn selected_file_json_reports_rows_spans_and_schema() { + let root = temp_project("selected-json"); + let file = root.join("src/lib.rs"); + let output = run_cstat(&file, &["--json", "symbols"]); + assert_success(&output); + + let value = parse_json(&output); + for field in [ + "file", + "total", + "functions", + "structs", + "enums", + "traits", + "trait_impls", + "consts", + "statics", + "parse_error", + "symbols", + ] { + assert!(value.get(field).is_some(), "missing {field}: {value}"); + } + assert_eq!(value["file"], "src/lib.rs"); + assert_eq!(value["parse_error"], false); + assert_eq!(value["total"], 12); + assert_eq!(value["functions"], 4); + assert_eq!(value["structs"], 2); + assert_eq!(value["enums"], 1); + assert_eq!(value["traits"], 1); + assert_eq!(value["trait_impls"], 1); + assert_eq!(value["consts"], 2); + assert_eq!(value["statics"], 1); + assert!( + value.get("impls").is_none(), + "old impls key present: {value}" + ); + + let rows = value["symbols"].as_array().expect("symbols array"); + let fn_rows = rows.iter().filter(|row| row["kind"] == "fn").count(); + assert_eq!(fn_rows, 4, "rows={rows:?}"); + + let free = find_symbol(rows, "fn", "free"); + assert_eq!(free["line_start"], 24); + assert_eq!(free["line_end"], 26); + + let inherent_method = find_symbol(rows, "fn", "Parser::new"); + assert_eq!(inherent_method["line_start"], 15); + assert_eq!(inherent_method["line_end"], 17); + + find_symbol(rows, "fn", "inline::nested"); + + let trait_impl_rows: Vec<&Value> = rows + .iter() + .filter(|row| row["kind"] == "trait_impl") + .collect(); + assert_eq!(trait_impl_rows.len(), 1, "rows={rows:?}"); + let trait_impl = trait_impl_rows[0]; + assert_eq!(trait_impl["symbol"], ""); + assert_eq!(trait_impl["line_start"], 20); + assert_eq!(trait_impl["line_end"], 22); + + find_symbol(rows, "const", "inline::FLAG"); + find_symbol(rows, "static", "NAME"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn selected_file_json_surfaces_parse_error() { + let root = temp_project("parse-error"); + let file = root.join("src/broken.rs"); + let output = run_cstat(&file, &["--json", "symbols"]); + assert_success(&output); + + let value = parse_json(&output); + assert_eq!(value["file"], "src/broken.rs"); + assert_eq!(value["parse_error"], true); + assert_zero_symbol_counts(&value); + assert!( + value["symbols"] + .as_array() + .expect("symbols array") + .is_empty(), + "json={value}" + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn symbols_human_verbose_explains_contract() { + let root = temp_project("human-verbose"); + let output = run_cstat(&root, &["-v", "symbols"]); + assert_success(&output); + + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in [ + "Rust AST", + "syn", + "semantic name resolution", + "public API usage", + "trait_impl", + "inherent", + "Parse-error", + "const", + "static", + ] { + assert!(stdout.contains(expected), "missing {expected}: {stdout}"); + } + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn symbols_help_mentions_file_path_and_trait_impls() { + let root = temp_project("help"); + + let root_help = run_cstat(&root, &["--help"]); + assert_success(&root_help); + let root_stdout = String::from_utf8_lossy(&root_help.stdout); + assert!( + root_stdout.contains("Rust project directory or Rust source file"), + "stdout={root_stdout}" + ); + + let symbols_help = run_cstat(&root, &["symbols", "--help"]); + assert_success(&symbols_help); + let symbols_stdout = String::from_utf8_lossy(&symbols_help.stdout); + assert!( + symbols_stdout.contains("Rust AST symbol counts"), + "stdout={symbols_stdout}" + ); + assert!( + symbols_stdout.contains("trait impl"), + "stdout={symbols_stdout}" + ); + + fs::remove_dir_all(root).unwrap(); +}