diff --git a/cstat-agent-use.md b/cstat-agent-use.md index f753593..3880374 100644 --- a/cstat-agent-use.md +++ b/cstat-agent-use.md @@ -35,7 +35,11 @@ remain available under `cstat advanced ...`. Each accepts `--json` for structured output. -- `cstat loc --json --path .` — line counts and directory breakdown. +- `cstat loc --explain --json` — machine-readable `loc` contract: modes, + `code_lines` rules, project JSON fields, and selected-file JSON fields. +- `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 deps --json --path .` — dependency edges, coupling, fan-in/fan-out, and cohesion. @@ -82,5 +86,8 @@ Use these only when the focused report points to a question they answer: test/benchmark roots can reach a function or edge. They are not runtime hit-count profiling. - Dependency edges come from source-level `use`/`mod` relationships. Generated code and macro expansion can hide edges. +- For exact `loc` `code_lines` rules and JSON fields, run + `cstat loc --explain` or `cstat loc --explain --json`; that command is the + canonical contract. - Prefer targeted reductions: remove dead code, split large files, move symbols across modules, then reduce per-function complexity. diff --git a/src/guide.rs b/src/guide.rs index 91cbdc9..b6fc0a8 100644 --- a/src/guide.rs +++ b/src/guide.rs @@ -64,30 +64,31 @@ when learning what the metrics mean.", fn topic_size() -> TopicContent { TopicContent { description: "\ -Size metrics measure the volume of code at file and function granularity. \ -Large files and functions are not inherently problematic, but they correlate \ -with higher defect density, harder navigation, and merge conflicts. The \ -distribution shape matters more than any single value.", +Size metrics measure the volume and distribution of Rust source. `cstat loc` owns the line-count contract; run `cstat loc --explain` for modes, code_lines rules, and JSON fields.", metrics: &[ - ("file LoC", "Total lines of code per file. High values may indicate a module doing too much."), - ("function LoC", "Lines per function body. Long functions tend to have higher cognitive load."), - ("mean / median", "Central tendency of the distribution. A large gap between mean and median indicates skew from outliers."), - ("std_dev", "Spread of the distribution. High values mean uneven file sizes."), - ("max", "The largest single file or function. Often the first place to investigate."), + ( + "file size shape", + "Project-level file-size distribution from `cstat loc --path .`.", + ), + ( + "selected-file line reachability", + "Projected static reachable/unreachable production spans from `cstat loc --path `.", + ), + ( + "distribution statistics", + "Mean, median, std_dev, min, and max summarize project file sizes.", + ), ], commands: &[ - "cstat loc — per-file LoC with bar charts and directory breakdown", - "cstat loc --json — structured file-level size data", + "cstat loc --explain — loc modes, code_lines rules, and JSON field contract", + "cstat loc --path . — project size-shape report", + "cstat loc --path src/lib.rs — selected-file projected line reachability", "cstat dist --metric loc — histogram and outlier analysis of LoC distribution", ], patterns: &[ - "A right-skewed LoC distribution (long tail) usually means a few files have \ -grown disproportionately. Check whether those files contain multiple concerns.", - "Files above 500 lines often contain function clusters that could be separate \ -modules. Cross-reference with `cstat deps` to see if the file's functions form \ -distinct groups with few cross-calls.", - "Uniform file sizes are not a goal — some modules are naturally larger. The \ -signal is when a file is large AND has low cohesion or high internal complexity.", + "Use `cstat loc --explain` as the canonical reference before consuming loc output.", + "A right-skewed project-mode code_lines distribution means a few files dominate size.", + "Selected-file loc is a static projection from function spans and test/benchmark reachability; do not read it as runtime coverage.", ], } } diff --git a/src/loc.rs b/src/loc.rs index 2d37ab2..cb28c55 100644 --- a/src/loc.rs +++ b/src/loc.rs @@ -377,11 +377,13 @@ pub fn render_loc(files: &[PathBuf], project_path: &Path, top_n: Option, if verbose { render::verbose_block(&[ - "Bar chart: files ranked by code lines (excluding blank lines and comments).", - "Bar length is proportional to LoC relative to the largest file.", + "Bar chart: files ranked by code_lines.", + "code_lines excludes blank lines, // comment-only lines, and block-comment-only regions.", + "Line classification is static text scanning, not semantic Rust parsing.", + "Bar length is proportional to code_lines relative to the largest file.", "Color gradient: red = top of the ranking (most lines), green = bottom.", + "Full loc reference: cstat loc --explain.", ]); - render::guide_ref("size"); } println!( @@ -477,6 +479,71 @@ pub fn render_loc_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Pa } } +pub fn render_loc_explain(json: bool) { + if json { + render_loc_explain_json(); + } else { + render_loc_explain_human(); + } +} + +fn render_loc_explain_human() { + render::section_header("loc reference"); + println!(" Project mode: cstat loc --path ."); + println!(" Project JSON: cstat loc --path . --json"); + println!(" Selected-file mode: cstat loc --path src/lib.rs"); + println!(" Selected-file JSON: cstat loc --path src/lib.rs --json"); + println!(" Machine contract: cstat loc --explain --json"); + println!(); + println!(" code_lines: static textual classification"); + println!(" excludes blank lines, // comment-only lines, and block-comment-only regions"); + println!(" not semantic Rust parsing"); + println!(); + println!(" Project JSON fields: cstat_version, files, aggregate, directory_breakdown"); + println!(" Selected-file JSON fields: file, total_lines, code_lines, projected_reachable_lines, projected_unreachable_lines, reachable_spans, unreachable_spans"); +} + +fn render_loc_explain_json() { + let output = serde_json::json!({ + "cstat_version": env!("CARGO_PKG_VERSION"), + "probe": "loc", + "commands": { + "project_human": "cstat loc --path .", + "project_json": "cstat loc --path . --json", + "selected_file_human": "cstat loc --path src/lib.rs", + "selected_file_json": "cstat loc --path src/lib.rs --json", + "explain_human": "cstat loc --explain", + "explain_json": "cstat loc --explain --json", + }, + "code_lines": { + "kind": "static textual classification", + "excludes": [ + "blank lines", + "// comment-only lines", + "block-comment-only regions via simple /* ... */ state tracking", + ], + "not": "semantic Rust parsing", + }, + "project_json_fields": { + "cstat_version": "cstat version string", + "files": ["path", "total_lines", "code_lines"], + "aggregate": ["total_files", "total_loc", "mean", "std_dev", "median", "min", "max"], + "directory_breakdown": ["directory", "code_lines"], + }, + "selected_file_json_fields": { + "file": "selected file path relative to the crate root", + "total_lines": "physical lines in the selected file", + "code_lines": "production function-span code lines after exclusions", + "projected_reachable_lines": "production code lines in statically reachable function spans", + "projected_unreachable_lines": "production code lines not in statically reachable function spans", + "reachable_spans": ["function", "line_start", "line_end"], + "unreachable_spans": ["function", "line_start", "line_end"], + }, + }); + + println!("{}", serde_json::to_string(&output).unwrap()); +} + pub fn analyze_file_projected_line_reachability_from_files( rs_files: &[PathBuf], project_path: &Path, @@ -511,8 +578,10 @@ pub fn render_file_projected_line_reachability_report( if verbose { render::verbose_block(&[ "Physical lines count every source line in the selected file.", - "Code lines exclude blank/comment-only lines and test/support/wrapper spans for selected-file production accounting.", + "code_lines is production function-span code after excluding tests, benches, test-support helpers, and selected binary wrapper main().", + "Line classification excludes blank lines, // comment-only lines, and block-comment-only regions with simple text scanning.", "Projected reachable lines are production code lines inside functions statically reached from project tests/benches.", + "Full loc reference: cstat loc --explain.", ]); } diff --git a/src/main.rs b/src/main.rs index 63b6733..822ab98 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,7 @@ struct Cli { #[command(subcommand)] command: Option, - /// Path to the Rust project directory (defaults to current directory) + /// Rust project directory or Rust source file to analyze (defaults to current directory) #[arg(long, default_value = ".", global = true)] path: PathBuf, @@ -56,11 +56,40 @@ enum Commands { Summary, /// Alias for the focused module metrics report Report, - /// Lines-of-code analysis with bar charts + #[command( + about = "Lines-of-code size-shape analysis", + long_about = r#"Lines-of-code size-shape analysis for Rust source. + +Project mode: + cstat loc --path . + cstat loc --path . --json + +Selected-file mode: + cstat loc --path src/lib.rs + cstat loc --path src/lib.rs --json + +Explain mode: + cstat loc --explain + cstat loc --explain --json + +Project mode reports total physical lines, code_lines, aggregate stats, per-file ranking, and directory breakdown for discovered Rust files. Selected-file mode reports projected static reachable/unreachable production line spans for one Rust source file. + +code_lines is a static textual classification: blank lines are excluded, // comment-only lines are excluded, and block-comment-only regions are excluded using simple /* ... */ state tracking. It is not semantic Rust parsing. + +Project JSON fields: cstat_version, files[{path,total_lines,code_lines}], aggregate{total_files,total_loc,mean,std_dev,median,min,max}, directory_breakdown[{directory,code_lines}]. +Selected-file JSON fields: file, total_lines, code_lines, projected_reachable_lines, projected_unreachable_lines, reachable_spans[{function,line_start,line_end}], unreachable_spans[{function,line_start,line_end}]. + +Use --explain to print this usage and JSON field contract without running analysis. --path, --top, and -v are ignored by --explain. + +--top only limits the project-mode per-file ranking (ignored for selected-file and --explain modes)."# + )] Loc { - /// Show only the top N files + /// Show only the top N files in project mode (ignored for selected-file and --explain modes) #[arg(long)] top: Option, + /// Print the loc usage and JSON field contract without running analysis + #[arg(long)] + explain: bool, }, /// Symbol counts by kind and file Symbols, @@ -162,6 +191,12 @@ fn main() { } let verbose = cli.verbose; + let command = cli.command.unwrap_or(Commands::Summary); + + if let Commands::Loc { explain: true, .. } = &command { + loc::render_loc_explain(json); + return; + } let target = match discovery::resolve_target(&cli.path) { Ok(target) => target, Err(e) => { @@ -177,8 +212,6 @@ fn main() { let project_path = &target.project_path; let rs_files = discovery::files_for_project(&target); - let command = cli.command.unwrap_or(Commands::Summary); - if let Some(file) = discovery::selected_file(&target) { let project_rs_files = discovery::files_for_project(&target); let target_rs_files = discovery::files_for_target(&target); @@ -307,7 +340,7 @@ fn main() { report::render_report(&rs_files, &project_path, verbose); } } - Commands::Loc { top } => { + Commands::Loc { top, .. } => { if json { loc::render_loc_json(&rs_files, &project_path); } else { diff --git a/tests/loc_cli.rs b/tests/loc_cli.rs new file mode 100644 index 0000000..b20b1df --- /dev/null +++ b/tests/loc_cli.rs @@ -0,0 +1,308 @@ +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +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-loc-cli-{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"), + r#" +pub fn live() { + helper(); +} + +/* +block comment only +*/ +fn helper() {} + +/* single-line block comment only */ +fn orphan() { + // comment-only +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn covers_live() { + live(); + } +} +"#, + ) + .unwrap(); + root +} + +fn run_cstat_raw(args: &[&str]) -> Output { + let bin = env!("CARGO_BIN_EXE_cstat"); + let mut command = Command::new(bin); + command.args(args); + command.output().expect("invoke cstat binary") +} + +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 span_array_contains_function(array: &Value, expected: &str) -> bool { + array + .as_array() + .expect("span array") + .iter() + .any(|item| item.get("function").and_then(Value::as_str) == Some(expected)) +} + +#[test] +fn loc_help_exposes_project_and_selected_file_modes() { + let output = run_cstat_raw(&["loc", "--help"]); + assert_success(&output); + + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in [ + "Rust project directory or Rust source file", + "Project mode:", + "Selected-file mode:", + "cstat loc --path .", + "cstat loc --path src/lib.rs", + "cstat loc --explain", + "cstat loc --explain --json", + "code_lines is a static textual classification", + "blank lines are excluded", + "// comment-only lines are excluded", + "block-comment-only regions are excluded", + "Project JSON fields:", + "Selected-file JSON fields:", + "--top only limits the project-mode", + "ignored for selected-file and --explain modes", + ] { + assert!(stdout.contains(expected), "missing {expected}: {stdout}"); + } +} + +#[test] +fn loc_explain_human_is_canonical_reference() { + let output = run_cstat_raw(&[ + "--path", + "/definitely/missing/cstat/path", + "loc", + "--explain", + ]); + assert_success(&output); + + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in [ + "loc reference", + "Project mode: cstat loc --path .", + "Machine contract: cstat loc --explain --json", + "code_lines: static textual classification", + "not semantic Rust parsing", + "Project JSON fields:", + "Selected-file JSON fields:", + ] { + assert!(stdout.contains(expected), "missing {expected}: {stdout}"); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("Error discovering files"), + "unexpected discovery error: {stderr}", + ); +} + +#[test] +fn loc_explain_json_exposes_machine_contract() { + let output = run_cstat_raw(&["--json", "loc", "--explain"]); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let value: Value = serde_json::from_str(&stdout).expect("parse loc explain JSON"); + assert_eq!(value["probe"], "loc"); + assert_eq!( + value["commands"]["explain_json"], + "cstat loc --explain --json", + ); + assert_eq!(value["code_lines"]["kind"], "static textual classification",); + assert!( + value["project_json_fields"]["files"] + .as_array() + .expect("project fields") + .iter() + .any(|field| field.as_str() == Some("code_lines")), + "json={stdout}", + ); + assert!( + value["selected_file_json_fields"]["reachable_spans"] + .as_array() + .expect("selected fields") + .iter() + .any(|field| field.as_str() == Some("function")), + "json={stdout}", + ); +} + +#[test] +fn loc_verbose_points_to_loc_explain_not_guide() { + let root = temp_project("verbose-loc-reference"); + let output = run_cstat(&root, &["-v", "loc"]); + assert_success(&output); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Full loc reference: cstat loc --explain"), + "stdout={stdout}", + ); + assert!( + !stdout.contains("cstat guide size"), + "stdout unexpectedly referenced guide: {stdout}", + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn loc_project_json_exposes_shape_contract() { + let root = temp_project("project-json"); + let output = run_cstat(&root, &["--json", "loc"]); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let value: Value = serde_json::from_str(&stdout).expect("parse loc JSON"); + assert_eq!(value["cstat_version"], env!("CARGO_PKG_VERSION")); + + let files = value["files"].as_array().expect("files array"); + assert!(!files.is_empty(), "json={stdout}"); + let lib = files + .iter() + .find(|file| file["path"] == "src/lib.rs") + .expect("src/lib.rs row"); + let total_lines = lib["total_lines"].as_u64().expect("total_lines"); + let code_lines = lib["code_lines"].as_u64().expect("code_lines"); + assert!( + total_lines >= code_lines && code_lines > 0, + "lib row={lib:?}", + ); + + let aggregate = &value["aggregate"]; + for field in [ + "total_files", + "total_loc", + "mean", + "std_dev", + "median", + "min", + "max", + ] { + assert!(aggregate.get(field).is_some(), "missing {field}: {stdout}"); + } + + let directories = value["directory_breakdown"] + .as_array() + .expect("directory_breakdown array"); + assert!( + directories.iter().any(|directory| { + directory["directory"] == "src" + && directory["code_lines"] + .as_u64() + .is_some_and(|code_lines| code_lines > 0) + }), + "directory_breakdown={directories:?}", + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn loc_selected_file_human_is_usable() { + let root = temp_project("selected-human"); + let file = root.join("src/lib.rs"); + let output = run_cstat(&file, &["loc"]); + assert_success(&output); + + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in [ + "Projected line reachability", + "file src/lib.rs", + "physical lines", + "production code lines", + "statically reachable lines", + "not statically reachable lines", + "reachable function spans:", + "unreachable function spans:", + "live", + "orphan", + ] { + assert!(stdout.contains(expected), "missing {expected}: {stdout}"); + } + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn loc_selected_file_json_exposes_projected_reachability_contract() { + let root = temp_project("selected-json"); + let file = root.join("src/lib.rs"); + let output = run_cstat(&file, &["--json", "loc"]); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let value: Value = serde_json::from_str(&stdout).expect("parse selected-file loc JSON"); + assert_eq!(value["file"], "src/lib.rs"); + let total_lines = value["total_lines"].as_u64().expect("total_lines"); + let code_lines = value["code_lines"].as_u64().expect("code_lines"); + let projected_reachable_lines = value["projected_reachable_lines"] + .as_u64() + .expect("projected_reachable_lines"); + let projected_unreachable_lines = value["projected_unreachable_lines"] + .as_u64() + .expect("projected_unreachable_lines"); + assert!(total_lines >= code_lines, "json={stdout}"); + assert_eq!( + projected_reachable_lines + projected_unreachable_lines, + code_lines, + "json={stdout}", + ); + assert!( + span_array_contains_function(&value["reachable_spans"], "live"), + "json={stdout}", + ); + assert!( + span_array_contains_function(&value["reachable_spans"], "helper"), + "json={stdout}", + ); + assert!( + span_array_contains_function(&value["unreachable_spans"], "orphan"), + "json={stdout}", + ); + + fs::remove_dir_all(root).unwrap(); +}