diff --git a/Cargo.lock b/Cargo.lock index 8aabe08..472365e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1869,6 +1869,7 @@ dependencies = [ "vajra-essence", "vajra-fingerprint", "vajra-query", + "vajra-report", "vajra-source", "vajra-stats", "vajra-types", @@ -2032,6 +2033,16 @@ dependencies = [ "vajra-types", ] +[[package]] +name = "vajra-report" +version = "0.4.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "vajra-types", +] + [[package]] name = "vajra-source" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index d4f4063..16ff1cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "vajra-domain-source", "vajra-domain-encoding", "vajra-domain-github", + "vajra-report", ] [workspace.package] @@ -99,6 +100,7 @@ vajra-source = { path = "vajra-source" } vajra-domain-source = { path = "vajra-domain-source" } vajra-domain-encoding = { path = "vajra-domain-encoding" } vajra-domain-github = { path = "vajra-domain-github" } +vajra-report = { path = "vajra-report" } # Tree-sitter tree-sitter = "0.26" diff --git a/vajra-cli/Cargo.toml b/vajra-cli/Cargo.toml index 4e39ab9..474af30 100644 --- a/vajra-cli/Cargo.toml +++ b/vajra-cli/Cargo.toml @@ -28,6 +28,7 @@ vajra-domain-github = { workspace = true, optional = true } vajra-source = { workspace = true, optional = true } vajra-query = { workspace = true } vajra-cascade = { workspace = true } +vajra-report = { workspace = true } clap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/vajra-cli/src/main.rs b/vajra-cli/src/main.rs index d3629c9..09d0aaf 100644 --- a/vajra-cli/src/main.rs +++ b/vajra-cli/src/main.rs @@ -254,6 +254,38 @@ enum Command { /// Path to JSON file containing commit data, or `-` for stdin input: String, }, + /// Generate an HTML analysis report from pre-computed JSON files + Report { + /// Directory containing analysis JSON files (stats.json, anomalies.json, etc.) + input: String, + /// Report title + #[arg(long, default_value = "Analysis Report")] + title: String, + /// Output HTML file path + #[arg(long, default_value = "report.html")] + output: String, + /// Repository name (e.g. 'facebook/react') + #[arg(long)] + repo_name: Option, + }, + /// Cross-repo comparison: multi-project benchmarking + Compare { + /// Two or more JSON file paths to compare + #[arg(required = true, num_args = 2..)] + inputs: Vec, + /// Comma-separated labels for each dataset (default: filenames) + #[arg(long)] + labels: Option, + /// JSONPath to the author field (e.g. '$.author') + #[arg(long, default_value = "$.author")] + author_field: String, + /// JSONPath to the timestamp field (e.g. '$.date') + #[arg(long, default_value = "$.date")] + time_field: String, + /// JSONPath to the commit message field (e.g. '$.subject') + #[arg(long, default_value = "$.subject")] + message_field: String, + }, } // --------------------------------------------------------------------------- @@ -336,6 +368,26 @@ fn main() { commit_limit, } => cmd_ingest_github(repo, output, *pr_limit, *issue_limit, *commit_limit, &cli), Command::CoreTeam { input } => cmd_core_team(input, &cli), + Command::Report { + input, + title, + output, + repo_name, + } => cmd_report(input, title, output, repo_name.as_deref(), &cli), + Command::Compare { + inputs, + labels, + author_field, + time_field, + message_field, + } => cmd_compare( + inputs, + labels.as_deref(), + author_field, + time_field, + message_field, + &cli, + ), }; if let Err(e) = result { @@ -2573,3 +2625,427 @@ fn cmd_core_team(input: &str, cli: &Cli) -> Result<()> { Ok(()) } + +// --------------------------------------------------------------------------- +// compare command +// --------------------------------------------------------------------------- + +/// Per-dataset metrics for cross-repo comparison. +#[derive(Debug, Clone, Serialize)] +struct DatasetMetrics { + label: String, + total_records: usize, + author_entropy: f64, + author_cardinality: usize, + fix_ratio: f64, + one_commit_rate: f64, +} + +/// A single pairwise drift observation between two datasets. +#[derive(Debug, Clone, Serialize)] +struct PairwiseDrift { + a: String, + b: String, + severity: String, + similarity: f64, +} + +/// Full comparison result for JSON output. +#[derive(Debug, Clone, Serialize)] +struct CompareResult { + datasets: Vec, + pairwise_drift: Vec, +} + +/// Parse comma-separated labels, or derive labels from file paths. +fn parse_labels_or_filenames(labels: Option<&str>, inputs: &[String]) -> Vec { + if let Some(label_str) = labels { + let parsed: Vec = label_str.split(',').map(|s| s.trim().to_owned()).collect(); + if parsed.len() == inputs.len() { + return parsed; + } + // Fall through to filenames if count doesn't match + } + inputs + .iter() + .map(|input| { + Path::new(input) + .file_stem() + .map_or_else(|| input.clone(), |s| s.to_string_lossy().into_owned()) + }) + .collect() +} + +/// Compute per-dataset metrics from a JSON array of records. +fn compute_dataset_metrics( + records: &[serde_json::Value], + label: &str, + author_field: &str, + message_field: &str, +) -> DatasetMetrics { + let total_records = records.len(); + + // Author distribution + let mut author_counts: BTreeMap = BTreeMap::new(); + for record in records { + if let Some(author_val) = extract_json_path(record, author_field) { + if let Some(name) = author_val.as_str() { + *author_counts.entry(name.to_owned()).or_insert(0) += 1; + } + } + } + let author_cardinality = author_counts.len(); + let counts_vec: Vec = author_counts.values().copied().collect(); + let author_entropy = shannon_entropy_from_counts(&counts_vec); + + // Fix ratio: count messages containing fix-related patterns + let fix_patterns = ["fix", "bug", "hotfix", "patch", "revert"]; + let mut total_msgs = 0_u64; + let mut fix_msgs = 0_u64; + for record in records { + if let Some(msg_val) = extract_json_path(record, message_field) { + if let Some(msg) = msg_val.as_str() { + total_msgs += 1; + let lower = msg.to_lowercase(); + if fix_patterns.iter().any(|p| lower.contains(p)) { + fix_msgs += 1; + } + } + } + } + #[allow(clippy::cast_precision_loss)] // counts well within f64 range + let fix_ratio = if total_msgs > 0 { + fix_msgs as f64 / total_msgs as f64 + } else { + 0.0 + }; + + // One-commit rate: authors with exactly 1 commit / total authors + #[allow(clippy::cast_precision_loss)] // counts well within f64 range + let one_commit_rate = if author_cardinality > 0 { + let one_commit_authors = author_counts.values().filter(|&&c| c == 1).count(); + one_commit_authors as f64 / author_cardinality as f64 + } else { + 0.0 + }; + + DatasetMetrics { + label: label.to_owned(), + total_records, + author_entropy, + author_cardinality, + fix_ratio, + one_commit_rate, + } +} + +fn cmd_report( + input: &str, + title: &str, + output: &str, + repo_name: Option<&str>, + cli: &Cli, +) -> Result<()> { + let input_path = Path::new(input); + let repo = repo_name.unwrap_or("unknown"); + + if !cli.quiet { + eprintln!("vajra: generating report from {}", input_path.display()); + } + + let data = vajra_report::load_report_data(input_path, title, repo) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let html = vajra_report::generate_html(&data); + + std::fs::write(output, &html).with_context(|| format!("Failed to write report to {output}"))?; + + if !cli.quiet { + let sources_count = data.config.data_sources.len(); + eprintln!( + "vajra: report written to {} ({} data sources, {} bytes)", + output, + sources_count, + html.len() + ); + } + + Ok(()) +} + +#[allow(clippy::too_many_lines)] +fn cmd_compare( + inputs: &[String], + labels: Option<&str>, + author_field: &str, + _time_field: &str, + message_field: &str, + cli: &Cli, +) -> Result<()> { + if inputs.len() < 2 { + anyhow::bail!( + "compare requires at least 2 input files, got {}", + inputs.len() + ); + } + + let labels = parse_labels_or_filenames(labels, inputs); + + // Load all datasets + let mut datasets: Vec = Vec::with_capacity(inputs.len()); + let mut record_sets: Vec> = Vec::with_capacity(inputs.len()); + for (i, input) in inputs.iter().enumerate() { + let doc = load_document(input, cli) + .with_context(|| format!("failed to load dataset '{}' ({})", labels[i], input))?; + let records = match doc.value().as_array() { + Some(arr) => arr.clone(), + None => { + anyhow::bail!( + "compare expects each input to be a JSON array of records, but '{}' ({}) is not an array", + labels[i], + input + ); + } + }; + if records.is_empty() { + anyhow::bail!( + "compare received an empty array for '{}' ({})", + labels[i], + input + ); + } + record_sets.push(records); + datasets.push(doc); + } + + // Per-dataset metrics + let metrics: Vec = record_sets + .iter() + .zip(labels.iter()) + .map(|(records, label)| { + compute_dataset_metrics(records, label, author_field, message_field) + }) + .collect(); + + // Pairwise drift + let mut drift_pairs: Vec = Vec::new(); + for i in 0..datasets.len() { + for j in (i + 1)..datasets.len() { + let report = full_drift(&datasets[i], &datasets[j]); + drift_pairs.push(PairwiseDrift { + a: labels[i].clone(), + b: labels[j].clone(), + severity: format!("{:?}", report.severity), + similarity: report.structural_similarity, + }); + } + } + + let result = CompareResult { + datasets: metrics, + pairwise_drift: drift_pairs, + }; + + match cli.format { + Format::Json => { + let out = serde_json::to_string_pretty(&result).context("JSON serialization failed")?; + let out = maybe_redact(&out, cli); + println!("{out}"); + } + Format::CompactAi => { + let out = serde_json::to_string(&result).context("JSON serialization failed")?; + let out = maybe_redact(&out, cli); + println!("{out}"); + } + Format::Markdown => { + let md = compare_markdown(&result); + let md = maybe_redact(&md, cli); + print!("{md}"); + } + Format::Text => { + let txt = compare_text(&result); + let txt = maybe_redact(&txt, cli); + print!("{txt}"); + } + } + + Ok(()) +} + +fn compare_markdown(result: &CompareResult) -> String { + use std::fmt::Write; + let mut out = String::new(); + + let _ = writeln!(out, "## Project Comparison\n"); + + // Header row + let _ = write!(out, "| Metric |"); + for ds in &result.datasets { + let _ = write!(out, " {} |", ds.label); + } + let _ = writeln!(out); + + // Separator row + let _ = write!(out, "|---|"); + for _ in &result.datasets { + let _ = write!(out, "---|"); + } + let _ = writeln!(out); + + // Records row + let _ = write!(out, "| Records |"); + for ds in &result.datasets { + let _ = write!(out, " {} |", ds.total_records); + } + let _ = writeln!(out); + + // Author entropy row + let _ = write!(out, "| Author entropy |"); + for ds in &result.datasets { + let _ = write!(out, " {:.2} |", ds.author_entropy); + } + let _ = writeln!(out); + + // Author cardinality row + let _ = write!(out, "| Author cardinality |"); + for ds in &result.datasets { + let _ = write!(out, " {} |", ds.author_cardinality); + } + let _ = writeln!(out); + + // Fix ratio row + let _ = write!(out, "| Fix ratio |"); + for ds in &result.datasets { + let _ = write!(out, " {:.1}% |", ds.fix_ratio * 100.0); + } + let _ = writeln!(out); + + // One-commit rate row + let _ = write!(out, "| One-commit rate |"); + for ds in &result.datasets { + let _ = write!(out, " {:.1}% |", ds.one_commit_rate * 100.0); + } + let _ = writeln!(out); + + // Pairwise drift section + let _ = writeln!(out); + let _ = writeln!(out, "## Pairwise Drift\n"); + let _ = writeln!(out, "| Pair | Severity | Similarity |"); + let _ = writeln!(out, "|---|---|---|"); + for dp in &result.pairwise_drift { + let _ = writeln!( + out, + "| {} vs {} | {} | {:.1} |", + dp.a, dp.b, dp.severity, dp.similarity + ); + } + + out +} + +fn compare_text(result: &CompareResult) -> String { + use std::fmt::Write; + let mut out = String::new(); + + let _ = writeln!(out, "=== Project Comparison ===\n"); + + // Find column widths + let label_width = result + .datasets + .iter() + .map(|ds| ds.label.len()) + .max() + .unwrap_or(5) + .max(10); + + // Header + let _ = write!(out, " {:<20}", "Metric"); + for ds in &result.datasets { + let _ = write!(out, " {:>w$}", ds.label, w = label_width); + } + let _ = writeln!(out); + + // Separator + let _ = write!(out, " {:<20}", "------"); + for _ in &result.datasets { + let _ = write!(out, " {:>w$}", "------", w = label_width); + } + let _ = writeln!(out); + + // Records + let _ = write!(out, " {:<20}", "Records"); + for ds in &result.datasets { + let _ = write!(out, " {:>w$}", ds.total_records, w = label_width); + } + let _ = writeln!(out); + + // Author entropy + let _ = write!(out, " {:<20}", "Author entropy"); + for ds in &result.datasets { + let _ = write!(out, " {:>w$.2}", ds.author_entropy, w = label_width); + } + let _ = writeln!(out); + + // Author cardinality + let _ = write!(out, " {:<20}", "Author cardinality"); + for ds in &result.datasets { + let _ = write!(out, " {:>w$}", ds.author_cardinality, w = label_width); + } + let _ = writeln!(out); + + // Fix ratio + let _ = write!(out, " {:<20}", "Fix ratio"); + for ds in &result.datasets { + let _ = write!( + out, + " {:>w$.1}%", + ds.fix_ratio * 100.0, + w = label_width - 1 + ); + } + let _ = writeln!(out); + + // One-commit rate + let _ = write!(out, " {:<20}", "One-commit rate"); + for ds in &result.datasets { + let _ = write!( + out, + " {:>w$.1}%", + ds.one_commit_rate * 100.0, + w = label_width - 1 + ); + } + let _ = writeln!(out); + + // Pairwise drift + let _ = writeln!(out); + let _ = writeln!(out, "=== Pairwise Drift ===\n"); + let pair_width = result + .pairwise_drift + .iter() + .map(|dp| dp.a.len() + dp.b.len() + 4) + .max() + .unwrap_or(15) + .max(15); + let _ = writeln!( + out, + " {:10} {:>10}", + "PAIR", + "SEVERITY", + "SIMILARITY", + pw = pair_width + ); + for dp in &result.pairwise_drift { + let pair_label = format!("{} vs {}", dp.a, dp.b); + let _ = writeln!( + out, + " {:10} {:>10.4}", + pair_label, + dp.severity, + dp.similarity, + pw = pair_width + ); + } + + out +} diff --git a/vajra-cli/tests/compare.rs b/vajra-cli/tests/compare.rs new file mode 100644 index 0000000..fc3aef2 --- /dev/null +++ b/vajra-cli/tests/compare.rs @@ -0,0 +1,419 @@ +//! Integration tests for `vajra compare` — cross-repo comparison. + +use std::io::Write; +use std::process::Command; + +use tempfile::NamedTempFile; + +fn write_temp_json(content: &str) -> Result> { + let mut file = NamedTempFile::new()?; + file.write_all(content.as_bytes())?; + file.flush()?; + Ok(file) +} + +fn vajra_bin() -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.pop(); + path.push("target"); + path.push("debug"); + path.push("vajra"); + path +} + +/// Build a synthetic dataset: an array of commit-like records. +fn make_dataset(authors: &[&str], messages: &[&str]) -> serde_json::Value { + let mut records = Vec::new(); + for (i, (author, msg)) in authors.iter().zip(messages.iter()).enumerate() { + records.push(serde_json::json!({ + "author": author, + "date": format!("2025-01-{:02}T00:00:00Z", (i % 28) + 1), + "subject": msg, + })); + } + serde_json::Value::Array(records) +} + +// --------------------------------------------------------------------------- +// Basic 2-dataset comparison +// --------------------------------------------------------------------------- + +#[test] +fn compare_two_datasets_json() -> Result<(), Box> { + let ds1 = make_dataset( + &["alice", "bob", "alice", "charlie"], + &["add feature", "fix bug", "update docs", "fix tests"], + ); + let ds2 = make_dataset( + &["dave", "dave", "dave", "eve"], + &["add feature", "add feature", "fix bug", "refactor"], + ); + + let tmp1 = write_temp_json(&ds1.to_string())?; + let tmp2 = write_temp_json(&ds2.to_string())?; + let p1 = tmp1.path().to_str().ok_or("bad path")?; + let p2 = tmp2.path().to_str().ok_or("bad path")?; + + let output = Command::new(vajra_bin()) + .args([ + "compare", + p1, + p2, + "--author-field", + "$.author", + "--message-field", + "$.subject", + "--format", + "json", + ]) + .output()?; + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout)?; + + // Should have 2 datasets + let datasets = json["datasets"].as_array().ok_or("missing datasets")?; + assert_eq!(datasets.len(), 2); + + // Each dataset has 4 records + assert_eq!(datasets[0]["total_records"].as_u64(), Some(4)); + assert_eq!(datasets[1]["total_records"].as_u64(), Some(4)); + + // Entropy should be positive for both + let e1 = datasets[0]["author_entropy"] + .as_f64() + .ok_or("missing entropy")?; + let e2 = datasets[1]["author_entropy"] + .as_f64() + .ok_or("missing entropy")?; + assert!(e1 > 0.0, "dataset 1 entropy should be positive, got {e1}"); + assert!(e2 > 0.0, "dataset 2 entropy should be positive, got {e2}"); + + // Dataset 1 has 3 distinct authors, dataset 2 has 2 + assert_eq!(datasets[0]["author_cardinality"].as_u64(), Some(3)); + assert_eq!(datasets[1]["author_cardinality"].as_u64(), Some(2)); + + // Fix ratio: ds1 has 2/4 = 0.5, ds2 has 1/4 = 0.25 + let fr1 = datasets[0]["fix_ratio"] + .as_f64() + .ok_or("missing fix_ratio")?; + let fr2 = datasets[1]["fix_ratio"] + .as_f64() + .ok_or("missing fix_ratio")?; + assert!((fr1 - 0.5).abs() < 1e-6, "expected 0.5, got {fr1}"); + assert!((fr2 - 0.25).abs() < 1e-6, "expected 0.25, got {fr2}"); + + // Pairwise drift: exactly 1 pair + let pairs = json["pairwise_drift"].as_array().ok_or("missing pairs")?; + assert_eq!(pairs.len(), 1); + + // Pair has severity and similarity + assert!(pairs[0]["severity"].as_str().is_some()); + assert!(pairs[0]["similarity"].as_f64().is_some()); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 3-dataset comparison produces 3 pairs +// --------------------------------------------------------------------------- + +#[test] +fn compare_three_datasets_three_pairs() -> Result<(), Box> { + let ds1 = make_dataset(&["a", "b"], &["add x", "fix y"]); + let ds2 = make_dataset(&["c", "d"], &["add z", "add w"]); + let ds3 = make_dataset(&["e", "e"], &["fix a", "fix b"]); + + let tmp1 = write_temp_json(&ds1.to_string())?; + let tmp2 = write_temp_json(&ds2.to_string())?; + let tmp3 = write_temp_json(&ds3.to_string())?; + let p1 = tmp1.path().to_str().ok_or("bad path")?; + let p2 = tmp2.path().to_str().ok_or("bad path")?; + let p3 = tmp3.path().to_str().ok_or("bad path")?; + + let output = Command::new(vajra_bin()) + .args(["compare", p1, p2, p3, "--format", "json"]) + .output()?; + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout)?; + let datasets = json["datasets"].as_array().ok_or("missing datasets")?; + assert_eq!(datasets.len(), 3); + + let pairs = json["pairwise_drift"].as_array().ok_or("missing pairs")?; + // 3 choose 2 = 3 pairs + assert_eq!(pairs.len(), 3); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Single dataset -> error +// --------------------------------------------------------------------------- + +#[test] +fn compare_single_dataset_errors() -> Result<(), Box> { + let ds = make_dataset(&["a", "b"], &["add x", "fix y"]); + let tmp = write_temp_json(&ds.to_string())?; + let p = tmp.path().to_str().ok_or("bad path")?; + + // clap enforces num_args = 2.., so passing 1 arg should fail + let output = Command::new(vajra_bin()) + .args(["compare", p, "--format", "json"]) + .output()?; + + assert!( + !output.status.success(), + "should fail with a single dataset" + ); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Labels: comma-separated +// --------------------------------------------------------------------------- + +#[test] +fn compare_with_labels() -> Result<(), Box> { + let ds1 = make_dataset(&["a", "b"], &["add x", "fix y"]); + let ds2 = make_dataset(&["c", "d"], &["add z", "add w"]); + + let tmp1 = write_temp_json(&ds1.to_string())?; + let tmp2 = write_temp_json(&ds2.to_string())?; + let p1 = tmp1.path().to_str().ok_or("bad path")?; + let p2 = tmp2.path().to_str().ok_or("bad path")?; + + let output = Command::new(vajra_bin()) + .args([ + "compare", + p1, + p2, + "--labels", + "React,Effect-TS", + "--format", + "json", + ]) + .output()?; + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout)?; + let datasets = json["datasets"].as_array().ok_or("missing datasets")?; + assert_eq!(datasets[0]["label"].as_str(), Some("React")); + assert_eq!(datasets[1]["label"].as_str(), Some("Effect-TS")); + + // Drift pairs should use the labels + let pairs = json["pairwise_drift"].as_array().ok_or("missing pairs")?; + assert_eq!(pairs[0]["a"].as_str(), Some("React")); + assert_eq!(pairs[0]["b"].as_str(), Some("Effect-TS")); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Filename fallback for labels +// --------------------------------------------------------------------------- + +#[test] +fn compare_filename_fallback_labels() -> Result<(), Box> { + let ds1 = make_dataset(&["a", "b"], &["add x", "fix y"]); + let ds2 = make_dataset(&["c", "d"], &["add z", "add w"]); + + let tmp1 = write_temp_json(&ds1.to_string())?; + let tmp2 = write_temp_json(&ds2.to_string())?; + let p1 = tmp1.path().to_str().ok_or("bad path")?; + let p2 = tmp2.path().to_str().ok_or("bad path")?; + + let output = Command::new(vajra_bin()) + .args(["compare", p1, p2, "--format", "json"]) + .output()?; + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout)?; + let datasets = json["datasets"].as_array().ok_or("missing datasets")?; + + // Labels should be derived from filenames (tempfile names are random but non-empty) + let label0 = datasets[0]["label"].as_str().ok_or("missing label")?; + let label1 = datasets[1]["label"].as_str().ok_or("missing label")?; + assert!(!label0.is_empty(), "label should not be empty"); + assert!(!label1.is_empty(), "label should not be empty"); + assert_ne!( + label0, label1, + "labels should be distinct for different files" + ); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Markdown output verification +// --------------------------------------------------------------------------- + +#[test] +fn compare_markdown_output() -> Result<(), Box> { + let ds1 = make_dataset( + &["alice", "bob", "alice"], + &["add feature", "fix bug", "update docs"], + ); + let ds2 = make_dataset( + &["charlie", "charlie", "dave"], + &["fix test", "fix lint", "add CI"], + ); + + let tmp1 = write_temp_json(&ds1.to_string())?; + let tmp2 = write_temp_json(&ds2.to_string())?; + let p1 = tmp1.path().to_str().ok_or("bad path")?; + let p2 = tmp2.path().to_str().ok_or("bad path")?; + + let output = Command::new(vajra_bin()) + .args([ + "compare", + p1, + p2, + "--labels", + "ProjectA,ProjectB", + "--format", + "markdown", + ]) + .output()?; + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let text = String::from_utf8_lossy(&output.stdout); + + // Check markdown structure + assert!(text.contains("## Project Comparison"), "missing header"); + assert!(text.contains("| Metric |"), "missing table header"); + assert!(text.contains("| Records |"), "missing records row"); + assert!(text.contains("| Author entropy |"), "missing entropy row"); + assert!( + text.contains("| Author cardinality |"), + "missing cardinality row" + ); + assert!(text.contains("| Fix ratio |"), "missing fix ratio row"); + assert!( + text.contains("| One-commit rate |"), + "missing one-commit row" + ); + assert!(text.contains("## Pairwise Drift"), "missing drift section"); + assert!( + text.contains("| Pair | Severity | Similarity |"), + "missing drift header" + ); + assert!(text.contains("ProjectA vs ProjectB"), "missing pair label"); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Text output verification +// --------------------------------------------------------------------------- + +#[test] +fn compare_text_output() -> Result<(), Box> { + let ds1 = make_dataset(&["a", "b"], &["add x", "fix y"]); + let ds2 = make_dataset(&["c", "d"], &["add z", "add w"]); + + let tmp1 = write_temp_json(&ds1.to_string())?; + let tmp2 = write_temp_json(&ds2.to_string())?; + let p1 = tmp1.path().to_str().ok_or("bad path")?; + let p2 = tmp2.path().to_str().ok_or("bad path")?; + + let output = Command::new(vajra_bin()) + .args([ + "compare", + p1, + p2, + "--labels", + "Alpha,Beta", + "--format", + "text", + ]) + .output()?; + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let text = String::from_utf8_lossy(&output.stdout); + assert!( + text.contains("=== Project Comparison ==="), + "missing header" + ); + assert!( + text.contains("=== Pairwise Drift ==="), + "missing drift section" + ); + assert!(text.contains("Alpha"), "missing label Alpha"); + assert!(text.contains("Beta"), "missing label Beta"); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// One-commit rate calculation +// --------------------------------------------------------------------------- + +#[test] +fn compare_one_commit_rate() -> Result<(), Box> { + // ds1: 4 authors, all unique (one-commit rate = 100%) + let ds1 = make_dataset(&["a", "b", "c", "d"], &["add x", "add y", "add z", "add w"]); + // ds2: 2 authors, "x" has 3 commits, "y" has 1 => one_commit_rate = 1/2 = 50% + let ds2 = make_dataset(&["x", "x", "x", "y"], &["add a", "add b", "add c", "add d"]); + + let tmp1 = write_temp_json(&ds1.to_string())?; + let tmp2 = write_temp_json(&ds2.to_string())?; + let p1 = tmp1.path().to_str().ok_or("bad path")?; + let p2 = tmp2.path().to_str().ok_or("bad path")?; + + let output = Command::new(vajra_bin()) + .args(["compare", p1, p2, "--format", "json"]) + .output()?; + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout)?; + let datasets = json["datasets"].as_array().ok_or("missing datasets")?; + + let ocr1 = datasets[0]["one_commit_rate"] + .as_f64() + .ok_or("missing ocr")?; + let ocr2 = datasets[1]["one_commit_rate"] + .as_f64() + .ok_or("missing ocr")?; + + assert!((ocr1 - 1.0).abs() < 1e-6, "expected 1.0, got {ocr1}"); + assert!((ocr2 - 0.5).abs() < 1e-6, "expected 0.5, got {ocr2}"); + + Ok(()) +} diff --git a/vajra-report/Cargo.toml b/vajra-report/Cargo.toml new file mode 100644 index 0000000..d783df9 --- /dev/null +++ b/vajra-report/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "vajra-report" +version.workspace = true +edition.workspace = true + +[dependencies] +vajra-types = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/vajra-report/src/css.rs b/vajra-report/src/css.rs new file mode 100644 index 0000000..5093f44 --- /dev/null +++ b/vajra-report/src/css.rs @@ -0,0 +1,358 @@ +//! Inlined CSS for the vajra HTML report. +//! +//! Extracted from the design system in `effect-report/report.py`. + +/// Returns the full CSS stylesheet as a string constant. +pub fn report_css() -> &'static str { + r##" +@page { + size: A4; + margin: 20mm 18mm 25mm 18mm; + @top-right { + content: "CONFIDENTIAL"; + font-size: 7pt; + color: #94a3b8; + font-family: 'Inter', system-ui, sans-serif; + } + @bottom-center { + content: counter(page) " / " counter(pages); + font-size: 8pt; + color: #64748b; + font-family: 'Inter', system-ui, sans-serif; + } + @bottom-left { + content: "vajra deterministic analysis"; + font-size: 7pt; + color: #94a3b8; + font-family: 'Inter', system-ui, sans-serif; + } +} + +@page :first { + margin-top: 0; + @top-right { content: none; } + @bottom-center { content: none; } + @bottom-left { content: none; } +} + +:root { + --purple: #8b5cf6; + --blue: #3b82f6; + --amber: #f59e0b; + --red: #ef4444; + --green: #22c55e; + --slate-50: #f8fafc; + --slate-100: #f1f5f9; + --slate-200: #e2e8f0; + --slate-300: #cbd5e1; + --slate-400: #94a3b8; + --slate-500: #64748b; + --slate-600: #475569; + --slate-700: #334155; + --slate-800: #1e293b; + --slate-900: #0f172a; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + font-size: 9pt; + line-height: 1.5; + color: var(--slate-800); + background: white; +} + +/* === COVER PAGE === */ +.cover { + page-break-after: always; + height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 40%, #312e81 100%); + color: white; + margin: -20mm -18mm 0 -18mm; + padding: 40mm 30mm; +} + +.cover h1 { + font-size: 36pt; + font-weight: 800; + letter-spacing: -0.03em; + background: linear-gradient(135deg, #c084fc, #60a5fa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin-bottom: 12px; +} +.cover h2 { + font-size: 14pt; + font-weight: 400; + color: #94a3b8; + margin-bottom: 40px; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.cover-meta { + font-size: 9pt; + color: #64748b; + line-height: 2; +} +.cover-meta strong { color: #94a3b8; } +.cover-divider { + width: 120px; + height: 2px; + background: linear-gradient(90deg, #8b5cf6, #3b82f6); + margin: 30px auto; + border-radius: 1px; +} +.cover-badge { + display: inline-block; + padding: 4px 14px; + border: 1px solid #4c1d95; + border-radius: 20px; + font-size: 7.5pt; + color: #a78bfa; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-top: 20px; +} + +/* === SECTIONS === */ +.section { page-break-before: always; } +.section:first-of-type { page-break-before: auto; } +.section-continue { page-break-before: avoid; } + +table { page-break-inside: auto; } +tr { page-break-inside: avoid; } +thead { display: table-header-group; } +.callout { page-break-inside: avoid; } +.stat-grid { page-break-inside: avoid; } +.health-item { page-break-inside: avoid; } + +h1 { + font-size: 20pt; + font-weight: 800; + color: var(--slate-900); + letter-spacing: -0.02em; + margin: 0 0 6px 0; + padding-bottom: 8px; + border-bottom: 3px solid; + border-image: linear-gradient(90deg, var(--purple), var(--blue)) 1; +} + +h2 { + font-size: 13pt; + font-weight: 700; + color: var(--slate-800); + margin: 18px 0 8px 0; + padding-bottom: 4px; + border-bottom: 1px solid var(--slate-200); +} + +h3 { + font-size: 10pt; + font-weight: 700; + color: var(--slate-700); + margin: 14px 0 6px 0; +} + +p { margin: 6px 0; color: var(--slate-700); } + +/* === TABLES === */ +table { + width: 100%; + border-collapse: collapse; + margin: 10px 0 16px 0; + font-size: 8pt; +} + +thead th { + background: var(--slate-800); + color: white; + font-weight: 600; + font-size: 7pt; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 6px 8px; + text-align: left; + border: none; +} + +tbody td { + padding: 5px 8px; + border-bottom: 1px solid var(--slate-100); + vertical-align: middle; +} + +tbody tr:nth-child(even) { background: var(--slate-50); } +tbody tr:hover { background: #ede9fe; } + +.num { text-align: right; font-variant-numeric: tabular-nums; } +.mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 7.5pt; } +.dim { color: var(--slate-400); } +.label-cell { font-weight: 500; max-width: 200px; } + +/* === GRADE COLORS === */ +.grade-a { color: var(--green); } +.grade-b { color: var(--blue); } +.grade-c { color: var(--amber); } +.grade-f { color: var(--red); } +.winner { font-weight: 700; } + +/* === TAGS === */ +.tag { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 6.5pt; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* === BARS === */ +.bar { + height: 10px; + background: linear-gradient(90deg, var(--purple), var(--blue)); + border-radius: 2px; + min-width: 2px; +} + +/* === CALLOUT BOXES === */ +.callout { + padding: 12px 16px; + border-radius: 6px; + margin: 12px 0; + font-size: 8.5pt; +} +.callout-critical { + background: #fef2f2; + border-left: 4px solid var(--red); +} +.callout-warning { + background: #fffbeb; + border-left: 4px solid var(--amber); +} +.callout-info { + background: #eff6ff; + border-left: 4px solid var(--blue); +} +.callout-good { + background: #f0fdf4; + border-left: 4px solid var(--green); +} +.callout strong { display: block; margin-bottom: 4px; } + +/* === HEALTH CARD === */ +.health-card { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 12px 0; +} +.health-item { + padding: 10px 14px; + border-radius: 6px; + background: var(--slate-50); + border: 1px solid var(--slate-200); +} +.health-item .metric { + font-size: 22pt; + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1.1; +} +.health-item .label { + font-size: 7pt; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--slate-500); + margin-top: 2px; +} + +/* === STAT ROW === */ +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + margin: 12px 0; +} +.stat-box { + text-align: center; + padding: 10px; + background: var(--slate-50); + border-radius: 6px; + border: 1px solid var(--slate-200); +} +.stat-box .val { + font-size: 18pt; + font-weight: 800; + letter-spacing: -0.02em; +} +.stat-box .desc { + font-size: 6.5pt; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--slate-500); + margin-top: 2px; +} + +/* === COLUMNS === */ +.two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +/* === CHART-LIKE === */ +.hbar-row { + display: flex; + align-items: center; + margin: 3px 0; + font-size: 7.5pt; +} +.hbar-label { + width: 120px; + text-align: right; + padding-right: 8px; + font-weight: 500; + color: var(--slate-600); + white-space: nowrap; + overflow: hidden; +} +.hbar-track { + flex: 1; + height: 14px; + background: var(--slate-100); + border-radius: 3px; + overflow: hidden; +} +.hbar-fill { + height: 100%; + border-radius: 3px; + display: flex; + align-items: center; + padding-left: 6px; + font-size: 6.5pt; + font-weight: 600; + color: white; +} +.hbar-fill.purple { background: linear-gradient(90deg, #8b5cf6, #a78bfa); } +.hbar-fill.blue { background: linear-gradient(90deg, #3b82f6, #60a5fa); } +.hbar-fill.amber { background: linear-gradient(90deg, #f59e0b, #fbbf24); } +.hbar-fill.red { background: linear-gradient(90deg, #ef4444, #f87171); } +.hbar-fill.green { background: linear-gradient(90deg, #22c55e, #4ade80); } +.hbar-fill.slate { background: linear-gradient(90deg, #64748b, #94a3b8); } + +.page-break { page-break-before: always; } +.no-orphan { page-break-inside: avoid; } + +/* Tighten for print */ +@media print { + body { font-size: 8.5pt; } +} +"## +} diff --git a/vajra-report/src/lib.rs b/vajra-report/src/lib.rs new file mode 100644 index 0000000..46e1dd7 --- /dev/null +++ b/vajra-report/src/lib.rs @@ -0,0 +1,459 @@ +//! HTML report generation for vajra analysis results. +//! +//! This crate reads pre-computed JSON analysis files (stats, anomalies, +//! governance, score, temporal, cascade) and produces a self-contained HTML +//! report with all CSS inlined. Users who need PDF output can render the +//! HTML via WeasyPrint: `weasyprint report.html report.pdf`. + +mod css; +mod sections; +mod template; + +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// Configuration for report generation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReportConfig { + pub title: String, + pub subtitle: String, + pub repo_name: String, + pub analysis_date: String, + pub data_sources: Vec, +} + +impl Default for ReportConfig { + fn default() -> Self { + Self { + title: "Analysis Report".to_string(), + subtitle: "Health \u{00b7} Governance \u{00b7} Performance".to_string(), + repo_name: "unknown".to_string(), + analysis_date: "unknown".to_string(), + data_sources: Vec::new(), + } + } +} + +/// Describes a single data source used in the analysis. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DataSourceInfo { + pub name: String, + pub records: usize, + pub method: String, +} + +/// All data that feeds into the report. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ReportData { + pub config: ReportConfig, + pub stats: Option, + pub anomalies: Option, + pub governance: Option, + pub score: Option, + pub temporal: Option, + pub cascade: Option, +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Load report data from a directory of pre-computed JSON files. +/// +/// Reads whichever files exist among: `stats.json`, `anomalies.json`, +/// `governance.json`, `score.json`, `temporal.json`, `cascade.json`. +/// +/// # Errors +/// +/// Returns an error only if the directory itself cannot be read. +pub fn load_report_data(dir: &Path, title: &str, repo_name: &str) -> Result { + if !dir.is_dir() { + return Err(format!("Input path is not a directory: {}", dir.display())); + } + + let mut data = ReportData { + config: ReportConfig { + title: title.to_string(), + repo_name: repo_name.to_string(), + analysis_date: current_date(), + ..ReportConfig::default() + }, + ..ReportData::default() + }; + + let mut sources = Vec::new(); + + if let Some(v) = try_load_json(dir, "stats.json") { + let count = count_records(&v); + sources.push(DataSourceInfo { + name: "Statistics".to_string(), + records: count, + method: "vajra stats".to_string(), + }); + data.stats = Some(v); + } + if let Some(v) = try_load_json(dir, "anomalies.json") { + let count = count_records(&v); + sources.push(DataSourceInfo { + name: "Anomalies".to_string(), + records: count, + method: "vajra anomalies".to_string(), + }); + data.anomalies = Some(v); + } + if let Some(v) = try_load_json(dir, "governance.json") { + let count = count_records(&v); + sources.push(DataSourceInfo { + name: "Governance".to_string(), + records: count, + method: "vajra governance".to_string(), + }); + data.governance = Some(v); + } + if let Some(v) = try_load_json(dir, "score.json") { + let count = count_records(&v); + sources.push(DataSourceInfo { + name: "Health Score".to_string(), + records: count, + method: "vajra score".to_string(), + }); + data.score = Some(v); + } + if let Some(v) = try_load_json(dir, "temporal.json") { + let count = count_records(&v); + sources.push(DataSourceInfo { + name: "Temporal Analysis".to_string(), + records: count, + method: "vajra stats --window".to_string(), + }); + data.temporal = Some(v); + } + if let Some(v) = try_load_json(dir, "cascade.json") { + let count = count_records(&v); + sources.push(DataSourceInfo { + name: "Cascade Analysis".to_string(), + records: count, + method: "vajra cascade".to_string(), + }); + data.cascade = Some(v); + } + + data.config.data_sources = sources; + Ok(data) +} + +/// Generate a self-contained HTML report from [`ReportData`]. +pub fn generate_html(data: &ReportData) -> String { + template::render(data) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn try_load_json(dir: &Path, filename: &str) -> Option { + let path = dir.join(filename); + let content = fs::read_to_string(&path).ok()?; + serde_json::from_str(&content).ok() +} + +fn count_records(v: &serde_json::Value) -> usize { + match v { + serde_json::Value::Array(arr) => arr.len(), + serde_json::Value::Object(obj) => obj.len(), + _ => 1, + } +} + +fn current_date() -> String { + // Simple approach: read from environment or fall back to a placeholder. + // We avoid pulling in chrono just for this. + std::env::var("VAJRA_REPORT_DATE").unwrap_or_else(|_| { + // Try to get date from system command as a fallback. + // If that fails, use a placeholder. + std::process::Command::new("date") + .arg("+%Y-%m-%d") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_generate_report_with_full_data() { + let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir failed: {e}")); + + // Write sample JSON files + let stats = serde_json::json!({ + "total_records": 100, + "fields": { + "author": { + "type_distribution": {"string": 100}, + "entropy": 3.45, + "unique_values": 20 + } + } + }); + let anomalies = serde_json::json!([ + {"path": "$.author", "kind": "outlier", "z_mad": 5.2, "description": "Unusual author distribution"}, + {"path": "$.lines", "kind": "outlier", "z_mad": 3.1, "description": "High line count"} + ]); + let governance = serde_json::json!({ + "bus_factor_80": 3, + "top_author": "alice", + "top_author_share": 0.35, + "gini_coefficient": 0.72, + "unique_authors": 15, + "total_commits": 200, + "churn": {"one_commit_authors": 8, "total_authors": 15, "one_commit_rate": 0.53} + }); + let score = serde_json::json!({ + "overall_grade": "B", + "overall_score": 72.5, + "dimensions": [ + {"name": "Bus Factor", "grade": "C", "score": 55.0, "detail": "bus_factor=3"}, + {"name": "Commit Entropy", "grade": "B", "score": 75.0, "detail": "entropy=3.45"} + ] + }); + let temporal = serde_json::json!([ + {"window": "2025-01", "record_count": 50, "fields": {}}, + {"window": "2025-02", "record_count": 45, "fields": {}} + ]); + let cascade = serde_json::json!({ + "chains": [ + {"trigger": "deploy", "response": "fix", "count": 5, "avg_delay_hours": 2.3} + ] + }); + + fs::write( + dir.path().join("stats.json"), + serde_json::to_string(&stats).unwrap_or_default(), + ) + .unwrap_or_else(|e| panic!("write failed: {e}")); + fs::write( + dir.path().join("anomalies.json"), + serde_json::to_string(&anomalies).unwrap_or_default(), + ) + .unwrap_or_else(|e| panic!("write failed: {e}")); + fs::write( + dir.path().join("governance.json"), + serde_json::to_string(&governance).unwrap_or_default(), + ) + .unwrap_or_else(|e| panic!("write failed: {e}")); + fs::write( + dir.path().join("score.json"), + serde_json::to_string(&score).unwrap_or_default(), + ) + .unwrap_or_else(|e| panic!("write failed: {e}")); + fs::write( + dir.path().join("temporal.json"), + serde_json::to_string(&temporal).unwrap_or_default(), + ) + .unwrap_or_else(|e| panic!("write failed: {e}")); + fs::write( + dir.path().join("cascade.json"), + serde_json::to_string(&cascade).unwrap_or_default(), + ) + .unwrap_or_else(|e| panic!("write failed: {e}")); + + let data = load_report_data(dir.path(), "Test Report", "test/repo") + .unwrap_or_else(|e| panic!("load failed: {e}")); + let html = generate_html(&data); + + // Verify HTML structure + assert!(html.contains(""), "missing doctype"); + assert!(html.contains("Test Report"), "missing title"); + assert!(html.contains("test/repo"), "missing repo name"); + assert!(html.contains(" + + + +"#, + ); + + // 1. Cover page + html.push_str(§ions::render_cover(data)); + html.push('\n'); + + // 2. Executive Summary + html.push_str(§ions::render_executive_summary(data)); + html.push('\n'); + + // 3. Author Distribution + html.push_str(§ions::render_author_distribution(data)); + html.push('\n'); + + // 4. Governance + html.push_str(§ions::render_governance(data)); + html.push('\n'); + + // 5. Anomalies + html.push_str(§ions::render_anomalies(data)); + html.push('\n'); + + // 6. Methodology (always present) + html.push_str(§ions::render_methodology(data)); + html.push('\n'); + + // Close document + html.push_str( + r#" + +"#, + ); + + html +}