use std::borrow::Cow;
use std::fmt::Write as FmtWrite;
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(test)]
use std::path::PathBuf;
use anyhow::Result;
use serde::Serialize;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tokmd_settings::ScanOptions;
use tokmd_types::{
ExportArgs, ExportArgsMeta, ExportData, ExportFormat, ExportReceipt, FileKind, FileRow,
LangArgs, LangArgsMeta, LangReceipt, LangReport, ModuleArgs, ModuleArgsMeta, ModuleReceipt,
ModuleReport, RedactMode, ScanArgs, ScanStatus, TableFormat, ToolInfo,
};
pub mod analysis;
pub mod badge;
pub mod export_tree;
#[cfg(feature = "fun")]
pub mod fun;
pub mod redact;
pub mod scan_args;
pub use badge::badge_svg;
pub use export_tree::{render_analysis_tree, render_handoff_tree};
pub use redact::{redact_path, short_hash};
pub use scan_args::{normalize_scan_input, scan_args};
fn redact_module_roots(roots: &[String], redact: RedactMode) -> Vec<String> {
if redact == RedactMode::All {
roots.iter().map(|r| short_hash(r)).collect()
} else {
roots.to_vec()
}
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
pub fn write_lang_report_to<W: Write>(
mut out: W,
report: &LangReport,
global: &ScanOptions,
args: &LangArgs,
) -> Result<()> {
match args.format {
TableFormat::Md => {
out.write_all(render_lang_md(report).as_bytes())?;
}
TableFormat::Tsv => {
out.write_all(render_lang_tsv(report).as_bytes())?;
}
TableFormat::Json => {
let receipt = LangReceipt {
schema_version: tokmd_types::SCHEMA_VERSION,
generated_at_ms: now_ms(),
tool: ToolInfo::current(),
mode: "lang".to_string(),
status: ScanStatus::Complete,
warnings: vec![],
scan: scan_args(&args.paths, global, None),
args: LangArgsMeta {
format: "json".to_string(),
top: report.top,
with_files: report.with_files,
children: report.children,
},
report: report.clone(),
};
writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
}
}
Ok(())
}
pub fn print_lang_report(report: &LangReport, global: &ScanOptions, args: &LangArgs) -> Result<()> {
let stdout = io::stdout();
let out = stdout.lock();
write_lang_report_to(out, report, global, args)
}
fn render_lang_md(report: &LangReport) -> String {
let mut s = String::with_capacity((report.rows.len() + 3) * 80);
if report.with_files {
s.push_str("|Lang|Code|Lines|Files|Bytes|Tokens|Avg|\n");
s.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
for r in &report.rows {
let _ = writeln!(
s,
"|{}|{}|{}|{}|{}|{}|{}|",
r.lang, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
);
}
let _ = writeln!(
s,
"|**Total**|{}|{}|{}|{}|{}|{}|",
report.total.code,
report.total.lines,
report.total.files,
report.total.bytes,
report.total.tokens,
report.total.avg_lines
);
} else {
s.push_str("|Lang|Code|Lines|Bytes|Tokens|\n");
s.push_str("|---|---:|---:|---:|---:|\n");
for r in &report.rows {
let _ = writeln!(
s,
"|{}|{}|{}|{}|{}|",
r.lang, r.code, r.lines, r.bytes, r.tokens
);
}
let _ = writeln!(
s,
"|**Total**|{}|{}|{}|{}|",
report.total.code, report.total.lines, report.total.bytes, report.total.tokens
);
}
s
}
fn render_lang_tsv(report: &LangReport) -> String {
let mut s = String::with_capacity((report.rows.len() + 2) * 64);
if report.with_files {
s.push_str("Lang\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n");
for r in &report.rows {
let _ = writeln!(
s,
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
r.lang, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
);
}
let _ = writeln!(
s,
"Total\t{}\t{}\t{}\t{}\t{}\t{}",
report.total.code,
report.total.lines,
report.total.files,
report.total.bytes,
report.total.tokens,
report.total.avg_lines
);
} else {
s.push_str("Lang\tCode\tLines\tBytes\tTokens\n");
for r in &report.rows {
let _ = writeln!(
s,
"{}\t{}\t{}\t{}\t{}",
r.lang, r.code, r.lines, r.bytes, r.tokens
);
}
let _ = writeln!(
s,
"Total\t{}\t{}\t{}\t{}",
report.total.code, report.total.lines, report.total.bytes, report.total.tokens
);
}
s
}
pub fn write_module_report_to<W: Write>(
mut out: W,
report: &ModuleReport,
global: &ScanOptions,
args: &ModuleArgs,
) -> Result<()> {
match args.format {
TableFormat::Md => {
out.write_all(render_module_md(report).as_bytes())?;
}
TableFormat::Tsv => {
out.write_all(render_module_tsv(report).as_bytes())?;
}
TableFormat::Json => {
let receipt = ModuleReceipt {
schema_version: tokmd_types::SCHEMA_VERSION,
generated_at_ms: now_ms(),
tool: ToolInfo::current(),
mode: "module".to_string(),
status: ScanStatus::Complete,
warnings: vec![],
scan: scan_args(&args.paths, global, None),
args: ModuleArgsMeta {
format: "json".to_string(),
top: report.top,
module_roots: report.module_roots.clone(),
module_depth: report.module_depth,
children: report.children,
},
report: report.clone(),
};
writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
}
}
Ok(())
}
pub fn print_module_report(
report: &ModuleReport,
global: &ScanOptions,
args: &ModuleArgs,
) -> Result<()> {
let stdout = io::stdout();
let out = stdout.lock();
write_module_report_to(out, report, global, args)
}
fn render_module_md(report: &ModuleReport) -> String {
let mut s = String::with_capacity((report.rows.len() + 3) * 80);
s.push_str("|Module|Code|Lines|Files|Bytes|Tokens|Avg|\n");
s.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
for r in &report.rows {
let _ = writeln!(
s,
"|{}|{}|{}|{}|{}|{}|{}|",
r.module, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
);
}
let _ = writeln!(
s,
"|**Total**|{}|{}|{}|{}|{}|{}|",
report.total.code,
report.total.lines,
report.total.files,
report.total.bytes,
report.total.tokens,
report.total.avg_lines
);
s
}
fn render_module_tsv(report: &ModuleReport) -> String {
let mut s = String::with_capacity((report.rows.len() + 2) * 64);
s.push_str("Module\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n");
for r in &report.rows {
let _ = writeln!(
s,
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
r.module, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
);
}
let _ = writeln!(
s,
"Total\t{}\t{}\t{}\t{}\t{}\t{}",
report.total.code,
report.total.lines,
report.total.files,
report.total.bytes,
report.total.tokens,
report.total.avg_lines
);
s
}
#[derive(Debug, Clone, Serialize)]
struct ExportMeta {
#[serde(rename = "type")]
ty: &'static str,
schema_version: u32,
generated_at_ms: u128,
tool: ToolInfo,
mode: String,
status: ScanStatus,
warnings: Vec<String>,
scan: ScanArgs,
args: ExportArgsMeta,
}
#[derive(Debug, Clone, Serialize)]
struct JsonlRow<'a> {
#[serde(rename = "type")]
ty: &'static str,
#[serde(flatten)]
row: &'a FileRow,
}
pub fn write_export(export: &ExportData, global: &ScanOptions, args: &ExportArgs) -> Result<()> {
match &args.output {
Some(path) => {
let file = File::create(path)?;
let mut out = BufWriter::new(file);
write_export_to(&mut out, export, global, args)?;
out.flush()?;
}
None => {
let stdout = io::stdout();
let mut out = stdout.lock();
write_export_to(&mut out, export, global, args)?;
out.flush()?;
}
}
Ok(())
}
fn write_export_to<W: Write>(
out: &mut W,
export: &ExportData,
global: &ScanOptions,
args: &ExportArgs,
) -> Result<()> {
match args.format {
ExportFormat::Csv => write_export_csv(out, export, args),
ExportFormat::Jsonl => write_export_jsonl(out, export, global, args),
ExportFormat::Json => write_export_json(out, export, global, args),
ExportFormat::Cyclonedx => write_export_cyclonedx(out, export, args.redact),
}
}
fn write_export_csv<W: Write>(out: &mut W, export: &ExportData, args: &ExportArgs) -> Result<()> {
let mut wtr = csv::WriterBuilder::new().has_headers(true).from_writer(out);
wtr.write_record([
"path", "module", "lang", "kind", "code", "comments", "blanks", "lines", "bytes", "tokens",
])?;
for r in redact_rows(&export.rows, args.redact) {
let code = r.code.to_string();
let comments = r.comments.to_string();
let blanks = r.blanks.to_string();
let lines = r.lines.to_string();
let bytes = r.bytes.to_string();
let tokens = r.tokens.to_string();
let kind = match r.kind {
FileKind::Parent => "parent",
FileKind::Child => "child",
};
wtr.write_record([
r.path.as_str(),
r.module.as_str(),
r.lang.as_str(),
kind,
&code,
&comments,
&blanks,
&lines,
&bytes,
&tokens,
])?;
}
wtr.flush()?;
Ok(())
}
fn write_export_jsonl<W: Write>(
out: &mut W,
export: &ExportData,
global: &ScanOptions,
args: &ExportArgs,
) -> Result<()> {
let module_roots = redact_module_roots(&export.module_roots, args.redact);
if args.meta {
let should_redact = args.redact == RedactMode::Paths || args.redact == RedactMode::All;
let strip_prefix_redacted = should_redact && args.strip_prefix.is_some();
let meta = ExportMeta {
ty: "meta",
schema_version: tokmd_types::SCHEMA_VERSION,
generated_at_ms: now_ms(),
tool: ToolInfo::current(),
mode: "export".to_string(),
status: ScanStatus::Complete,
warnings: vec![],
scan: scan_args(&args.paths, global, Some(args.redact)),
args: ExportArgsMeta {
format: args.format,
module_roots: module_roots.clone(),
module_depth: export.module_depth,
children: export.children,
min_code: args.min_code,
max_rows: args.max_rows,
redact: args.redact,
strip_prefix: if should_redact {
args.strip_prefix
.as_ref()
.map(|p| redact_path(&p.display().to_string().replace('\\', "/")))
} else {
args.strip_prefix
.as_ref()
.map(|p| p.display().to_string().replace('\\', "/"))
},
strip_prefix_redacted,
},
};
writeln!(out, "{}", serde_json::to_string(&meta)?)?;
}
for row in redact_rows(&export.rows, args.redact) {
let wrapper = JsonlRow {
ty: "row",
row: &row,
};
writeln!(out, "{}", serde_json::to_string(&wrapper)?)?;
}
Ok(())
}
fn write_export_json<W: Write>(
out: &mut W,
export: &ExportData,
global: &ScanOptions,
args: &ExportArgs,
) -> Result<()> {
let module_roots = redact_module_roots(&export.module_roots, args.redact);
if args.meta {
let should_redact = args.redact == RedactMode::Paths || args.redact == RedactMode::All;
let strip_prefix_redacted = should_redact && args.strip_prefix.is_some();
let receipt = ExportReceipt {
schema_version: tokmd_types::SCHEMA_VERSION,
generated_at_ms: now_ms(),
tool: ToolInfo::current(),
mode: "export".to_string(),
status: ScanStatus::Complete,
warnings: vec![],
scan: scan_args(&args.paths, global, Some(args.redact)),
args: ExportArgsMeta {
format: args.format,
module_roots: module_roots.clone(),
module_depth: export.module_depth,
children: export.children,
min_code: args.min_code,
max_rows: args.max_rows,
redact: args.redact,
strip_prefix: if should_redact {
args.strip_prefix
.as_ref()
.map(|p| redact_path(&p.display().to_string().replace('\\', "/")))
} else {
args.strip_prefix
.as_ref()
.map(|p| p.display().to_string().replace('\\', "/"))
},
strip_prefix_redacted,
},
data: ExportData {
rows: redact_rows(&export.rows, args.redact)
.map(|c| c.into_owned())
.collect(),
module_roots: module_roots.clone(),
module_depth: export.module_depth,
children: export.children,
},
};
writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
} else {
writeln!(
out,
"{}",
serde_json::to_string(&redact_rows(&export.rows, args.redact).collect::<Vec<_>>())?
)?;
}
Ok(())
}
fn redact_rows(rows: &[FileRow], mode: RedactMode) -> impl Iterator<Item = Cow<'_, FileRow>> {
rows.iter().map(move |r| match mode {
RedactMode::None => Cow::Borrowed(r),
RedactMode::Paths => Cow::Owned(FileRow {
path: redact_path(&r.path),
module: r.module.clone(),
lang: r.lang.clone(),
kind: r.kind,
code: r.code,
comments: r.comments,
blanks: r.blanks,
lines: r.lines,
bytes: r.bytes,
tokens: r.tokens,
}),
RedactMode::All => Cow::Owned(FileRow {
path: redact_path(&r.path),
module: short_hash(&r.module),
lang: r.lang.clone(),
kind: r.kind,
code: r.code,
comments: r.comments,
blanks: r.blanks,
lines: r.lines,
bytes: r.bytes,
tokens: r.tokens,
}),
})
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CycloneDxBom {
bom_format: &'static str,
spec_version: &'static str,
serial_number: String,
version: u32,
metadata: CycloneDxMetadata,
components: Vec<CycloneDxComponent>,
}
#[derive(Debug, Clone, Serialize)]
struct CycloneDxMetadata {
timestamp: String,
tools: Vec<CycloneDxTool>,
}
#[derive(Debug, Clone, Serialize)]
struct CycloneDxTool {
vendor: &'static str,
name: &'static str,
version: String,
}
#[derive(Debug, Clone, Serialize)]
struct CycloneDxComponent {
#[serde(rename = "type")]
ty: &'static str,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
properties: Vec<CycloneDxProperty>,
}
#[derive(Debug, Clone, Serialize)]
struct CycloneDxProperty {
name: String,
value: String,
}
fn write_export_cyclonedx<W: Write>(
out: &mut W,
export: &ExportData,
redact: RedactMode,
) -> Result<()> {
write_export_cyclonedx_impl(out, export, redact, None, None)
}
fn write_export_cyclonedx_impl<W: Write>(
out: &mut W,
export: &ExportData,
redact: RedactMode,
serial_number: Option<String>,
timestamp: Option<String>,
) -> Result<()> {
let timestamp = timestamp.unwrap_or_else(|| {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
});
let components: Vec<CycloneDxComponent> = redact_rows(&export.rows, redact)
.map(|row| {
let mut properties = vec![
CycloneDxProperty {
name: "tokmd:lang".to_string(),
value: row.lang.clone(),
},
CycloneDxProperty {
name: "tokmd:code".to_string(),
value: row.code.to_string(),
},
CycloneDxProperty {
name: "tokmd:comments".to_string(),
value: row.comments.to_string(),
},
CycloneDxProperty {
name: "tokmd:blanks".to_string(),
value: row.blanks.to_string(),
},
CycloneDxProperty {
name: "tokmd:lines".to_string(),
value: row.lines.to_string(),
},
CycloneDxProperty {
name: "tokmd:bytes".to_string(),
value: row.bytes.to_string(),
},
CycloneDxProperty {
name: "tokmd:tokens".to_string(),
value: row.tokens.to_string(),
},
];
if row.kind == FileKind::Child {
properties.push(CycloneDxProperty {
name: "tokmd:kind".to_string(),
value: "child".to_string(),
});
}
CycloneDxComponent {
ty: "file",
name: row.path.clone(),
group: if row.module.is_empty() {
None
} else {
Some(row.module.clone())
},
properties,
}
})
.collect();
let bom = CycloneDxBom {
bom_format: "CycloneDX",
spec_version: "1.6",
serial_number: serial_number
.unwrap_or_else(|| format!("urn:uuid:{}", uuid::Uuid::new_v4())),
version: 1,
metadata: CycloneDxMetadata {
timestamp,
tools: vec![CycloneDxTool {
vendor: "tokmd",
name: "tokmd",
version: env!("CARGO_PKG_VERSION").to_string(),
}],
},
components,
};
writeln!(out, "{}", serde_json::to_string_pretty(&bom)?)?;
Ok(())
}
pub fn write_lang_json_to_file(
path: &Path,
report: &LangReport,
scan: &ScanArgs,
args_meta: &LangArgsMeta,
) -> Result<()> {
let receipt = LangReceipt {
schema_version: tokmd_types::SCHEMA_VERSION,
generated_at_ms: now_ms(),
tool: ToolInfo::current(),
mode: "lang".to_string(),
status: ScanStatus::Complete,
warnings: vec![],
scan: scan.clone(),
args: args_meta.clone(),
report: report.clone(),
};
let file = File::create(path)?;
serde_json::to_writer(file, &receipt)?;
Ok(())
}
pub fn write_module_json_to_file(
path: &Path,
report: &ModuleReport,
scan: &ScanArgs,
args_meta: &ModuleArgsMeta,
redact: RedactMode,
) -> Result<()> {
let mut final_args = args_meta.clone();
let mut final_report = report.clone();
if redact == RedactMode::All {
final_args.module_roots = redact_module_roots(&final_args.module_roots, redact);
final_report.module_roots = redact_module_roots(&final_report.module_roots, redact);
for row in &mut final_report.rows {
row.module = short_hash(&row.module);
}
}
let receipt = ModuleReceipt {
schema_version: tokmd_types::SCHEMA_VERSION,
generated_at_ms: now_ms(),
tool: ToolInfo::current(),
mode: "module".to_string(),
status: ScanStatus::Complete,
warnings: vec![],
scan: scan.clone(),
args: final_args,
report: final_report,
};
let file = File::create(path)?;
serde_json::to_writer(file, &receipt)?;
Ok(())
}
pub fn write_export_jsonl_to_file(
path: &Path,
export: &ExportData,
scan: &ScanArgs,
args_meta: &ExportArgsMeta,
) -> Result<()> {
let file = File::create(path)?;
let mut out = BufWriter::new(file);
let mut final_args = args_meta.clone();
final_args.module_roots = redact_module_roots(&final_args.module_roots, args_meta.redact);
let meta = ExportMeta {
ty: "meta",
schema_version: tokmd_types::SCHEMA_VERSION,
generated_at_ms: now_ms(),
tool: ToolInfo::current(),
mode: "export".to_string(),
status: ScanStatus::Complete,
warnings: vec![],
scan: scan.clone(),
args: final_args,
};
writeln!(out, "{}", serde_json::to_string(&meta)?)?;
for row in redact_rows(&export.rows, args_meta.redact) {
let wrapper = JsonlRow {
ty: "row",
row: &row,
};
writeln!(out, "{}", serde_json::to_string(&wrapper)?)?;
}
out.flush()?;
Ok(())
}
use tokmd_types::{DiffReceipt, DiffRow, DiffTotals, LangRow};
pub fn compute_diff_rows(from_report: &LangReport, to_report: &LangReport) -> Vec<DiffRow> {
let mut all_langs: Vec<String> = from_report
.rows
.iter()
.chain(to_report.rows.iter())
.map(|r| r.lang.clone())
.collect();
all_langs.sort();
all_langs.dedup();
all_langs
.into_iter()
.filter_map(|lang_name| {
let old_row = from_report.rows.iter().find(|r| r.lang == lang_name);
let new_row = to_report.rows.iter().find(|r| r.lang == lang_name);
let old = old_row.cloned().unwrap_or_else(|| LangRow {
lang: lang_name.clone(),
code: 0,
lines: 0,
files: 0,
bytes: 0,
tokens: 0,
avg_lines: 0,
});
let new = new_row.cloned().unwrap_or_else(|| LangRow {
lang: lang_name.clone(),
code: 0,
lines: 0,
files: 0,
bytes: 0,
tokens: 0,
avg_lines: 0,
});
if old.code == new.code
&& old.lines == new.lines
&& old.files == new.files
&& old.bytes == new.bytes
&& old.tokens == new.tokens
{
return None;
}
Some(DiffRow {
lang: lang_name,
old_code: old.code,
new_code: new.code,
delta_code: new.code as i64 - old.code as i64,
old_lines: old.lines,
new_lines: new.lines,
delta_lines: new.lines as i64 - old.lines as i64,
old_files: old.files,
new_files: new.files,
delta_files: new.files as i64 - old.files as i64,
old_bytes: old.bytes,
new_bytes: new.bytes,
delta_bytes: new.bytes as i64 - old.bytes as i64,
old_tokens: old.tokens,
new_tokens: new.tokens,
delta_tokens: new.tokens as i64 - old.tokens as i64,
})
})
.collect()
}
pub fn compute_diff_totals(rows: &[DiffRow]) -> DiffTotals {
let mut totals = DiffTotals {
old_code: 0,
new_code: 0,
delta_code: 0,
old_lines: 0,
new_lines: 0,
delta_lines: 0,
old_files: 0,
new_files: 0,
delta_files: 0,
old_bytes: 0,
new_bytes: 0,
delta_bytes: 0,
old_tokens: 0,
new_tokens: 0,
delta_tokens: 0,
};
for row in rows {
totals.old_code += row.old_code;
totals.new_code += row.new_code;
totals.delta_code += row.delta_code;
totals.old_lines += row.old_lines;
totals.new_lines += row.new_lines;
totals.delta_lines += row.delta_lines;
totals.old_files += row.old_files;
totals.new_files += row.new_files;
totals.delta_files += row.delta_files;
totals.old_bytes += row.old_bytes;
totals.new_bytes += row.new_bytes;
totals.delta_bytes += row.delta_bytes;
totals.old_tokens += row.old_tokens;
totals.new_tokens += row.new_tokens;
totals.delta_tokens += row.delta_tokens;
}
totals
}
fn format_delta(delta: i64) -> String {
if delta > 0 {
format!("+{}", delta)
} else {
delta.to_string()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffColorMode {
Off,
Ansi,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiffRenderOptions {
pub compact: bool,
pub color: DiffColorMode,
}
impl Default for DiffRenderOptions {
fn default() -> Self {
Self {
compact: false,
color: DiffColorMode::Off,
}
}
}
fn format_delta_colored(delta: i64, mode: DiffColorMode) -> String {
let raw = format_delta(delta);
if mode == DiffColorMode::Off {
return raw;
}
if delta > 0 {
format!("\x1b[32m{}\x1b[0m", raw)
} else if delta < 0 {
format!("\x1b[31m{}\x1b[0m", raw)
} else {
format!("\x1b[33m{}\x1b[0m", raw)
}
}
fn format_pct_delta_colored(delta_pct: f64, mode: DiffColorMode) -> String {
let raw = format!("{:+.1}%", delta_pct);
if mode == DiffColorMode::Off {
return raw;
}
if delta_pct > 0.0 {
format!("\x1b[32m{}\x1b[0m", raw)
} else if delta_pct < 0.0 {
format!("\x1b[31m{}\x1b[0m", raw)
} else {
format!("\x1b[33m{}\x1b[0m", raw)
}
}
fn percent_change(old: usize, new: usize) -> f64 {
if old > 0 {
((new as f64 - old as f64) / old as f64) * 100.0
} else if new > 0 {
100.0
} else {
0.0
}
}
pub fn render_diff_md_with_options(
from_source: &str,
to_source: &str,
rows: &[DiffRow],
totals: &DiffTotals,
options: DiffRenderOptions,
) -> String {
let mut s = String::with_capacity((rows.len() + 20) * 80);
let _ = writeln!(s, "## Diff: {} → {}", from_source, to_source);
s.push('\n');
let languages_added = rows
.iter()
.filter(|r| r.old_code == 0 && r.new_code > 0)
.count();
let languages_removed = rows
.iter()
.filter(|r| r.old_code > 0 && r.new_code == 0)
.count();
let languages_modified = rows
.len()
.saturating_sub(languages_added + languages_removed);
if options.compact {
s.push_str("### Summary\n\n");
s.push_str("|Metric|Value|\n");
s.push_str("|---|---:|\n");
let _ = writeln!(s, "|From LOC|{}|", totals.old_code);
let _ = writeln!(s, "|To LOC|{}|", totals.new_code);
let _ = writeln!(
s,
"|Delta LOC|{}|",
format_delta_colored(totals.delta_code, options.color)
);
let _ = writeln!(
s,
"|LOC Change|{}|",
format_pct_delta_colored(
percent_change(totals.old_code, totals.new_code),
options.color
)
);
let _ = writeln!(
s,
"|Delta Lines|{}|",
format_delta_colored(totals.delta_lines, options.color)
);
let _ = writeln!(
s,
"|Delta Files|{}|",
format_delta_colored(totals.delta_files, options.color)
);
let _ = writeln!(
s,
"|Delta Bytes|{}|",
format_delta_colored(totals.delta_bytes, options.color)
);
let _ = writeln!(
s,
"|Delta Tokens|{}|",
format_delta_colored(totals.delta_tokens, options.color)
);
let _ = writeln!(s, "|Languages changed|{}|", rows.len());
let _ = writeln!(s, "|Languages added|{}|", languages_added);
let _ = writeln!(s, "|Languages removed|{}|", languages_removed);
let _ = writeln!(s, "|Languages modified|{}|", languages_modified);
return s;
}
s.push_str("### Summary\n\n");
s.push_str("|Metric|From|To|Delta|Change|\n");
s.push_str("|---|---:|---:|---:|---:|\n");
let _ = writeln!(
s,
"|LOC|{}|{}|{}|{}|",
totals.old_code,
totals.new_code,
format_delta_colored(totals.delta_code, options.color),
format_pct_delta_colored(
percent_change(totals.old_code, totals.new_code),
options.color
)
);
let _ = writeln!(
s,
"|Lines|{}|{}|{}|{}|",
totals.old_lines,
totals.new_lines,
format_delta_colored(totals.delta_lines, options.color),
format_pct_delta_colored(
percent_change(totals.old_lines, totals.new_lines),
options.color
)
);
let _ = writeln!(
s,
"|Files|{}|{}|{}|{}|",
totals.old_files,
totals.new_files,
format_delta_colored(totals.delta_files, options.color),
format_pct_delta_colored(
percent_change(totals.old_files, totals.new_files),
options.color
)
);
let _ = writeln!(
s,
"|Bytes|{}|{}|{}|{}|",
totals.old_bytes,
totals.new_bytes,
format_delta_colored(totals.delta_bytes, options.color),
format_pct_delta_colored(
percent_change(totals.old_bytes, totals.new_bytes),
options.color
)
);
let _ = writeln!(
s,
"|Tokens|{}|{}|{}|{}|",
totals.old_tokens,
totals.new_tokens,
format_delta_colored(totals.delta_tokens, options.color),
format_pct_delta_colored(
percent_change(totals.old_tokens, totals.new_tokens),
options.color
)
);
s.push('\n');
s.push_str("### Language Movement\n\n");
s.push_str("|Type|Count|\n");
s.push_str("|---|---:|\n");
let _ = writeln!(s, "|Changed|{}|", rows.len());
let _ = writeln!(s, "|Added|{}|", languages_added);
let _ = writeln!(s, "|Removed|{}|", languages_removed);
let _ = writeln!(s, "|Modified|{}|", languages_modified);
s.push('\n');
s.push_str("### Language Breakdown\n\n");
s.push_str("|Language|Old LOC|New LOC|Delta|\n");
s.push_str("|---|---:|---:|---:|\n");
for row in rows {
let _ = writeln!(
s,
"|{}|{}|{}|{}|",
row.lang,
row.old_code,
row.new_code,
format_delta_colored(row.delta_code, options.color)
);
}
let _ = writeln!(
s,
"|**Total**|{}|{}|{}|",
totals.old_code,
totals.new_code,
format_delta_colored(totals.delta_code, options.color)
);
s
}
pub fn render_diff_md(
from_source: &str,
to_source: &str,
rows: &[DiffRow],
totals: &DiffTotals,
) -> String {
render_diff_md_with_options(
from_source,
to_source,
rows,
totals,
DiffRenderOptions::default(),
)
}
pub fn create_diff_receipt(
from_source: &str,
to_source: &str,
rows: Vec<DiffRow>,
totals: DiffTotals,
) -> DiffReceipt {
DiffReceipt {
schema_version: tokmd_types::SCHEMA_VERSION,
generated_at_ms: now_ms(),
tool: ToolInfo::current(),
mode: "diff".to_string(),
from_source: from_source.to_string(),
to_source: to_source.to_string(),
diff_rows: rows,
totals,
}
}
#[doc(hidden)]
pub fn write_export_csv_to<W: Write>(
out: &mut W,
export: &ExportData,
args: &ExportArgs,
) -> Result<()> {
write_export_csv(out, export, args)
}
#[doc(hidden)]
pub fn write_export_jsonl_to<W: Write>(
out: &mut W,
export: &ExportData,
global: &ScanOptions,
args: &ExportArgs,
) -> Result<()> {
write_export_jsonl(out, export, global, args)
}
#[doc(hidden)]
pub fn write_export_json_to<W: Write>(
out: &mut W,
export: &ExportData,
global: &ScanOptions,
args: &ExportArgs,
) -> Result<()> {
write_export_json(out, export, global, args)
}
#[doc(hidden)]
pub fn write_export_cyclonedx_to<W: Write>(
out: &mut W,
export: &ExportData,
redact: RedactMode,
) -> Result<()> {
write_export_cyclonedx(out, export, redact)
}
#[doc(hidden)]
pub fn write_export_cyclonedx_with_options<W: Write>(
out: &mut W,
export: &ExportData,
redact: RedactMode,
serial_number: Option<String>,
timestamp: Option<String>,
) -> Result<()> {
write_export_cyclonedx_impl(out, export, redact, serial_number, timestamp)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use tokmd_settings::ChildrenMode;
use tokmd_types::{LangRow, ModuleRow, Totals};
fn sample_lang_report(with_files: bool) -> LangReport {
LangReport {
rows: vec![
LangRow {
lang: "Rust".to_string(),
code: 1000,
lines: 1200,
files: 10,
bytes: 50000,
tokens: 2500,
avg_lines: 120,
},
LangRow {
lang: "TOML".to_string(),
code: 50,
lines: 60,
files: 2,
bytes: 1000,
tokens: 125,
avg_lines: 30,
},
],
total: Totals {
code: 1050,
lines: 1260,
files: 12,
bytes: 51000,
tokens: 2625,
avg_lines: 105,
},
with_files,
children: ChildrenMode::Collapse,
top: 0,
}
}
fn sample_module_report() -> ModuleReport {
ModuleReport {
rows: vec![
ModuleRow {
module: "crates/foo".to_string(),
code: 800,
lines: 950,
files: 8,
bytes: 40000,
tokens: 2000,
avg_lines: 119,
},
ModuleRow {
module: "crates/bar".to_string(),
code: 200,
lines: 250,
files: 2,
bytes: 10000,
tokens: 500,
avg_lines: 125,
},
],
total: Totals {
code: 1000,
lines: 1200,
files: 10,
bytes: 50000,
tokens: 2500,
avg_lines: 120,
},
module_roots: vec!["crates".to_string()],
module_depth: 2,
children: tokmd_settings::ChildIncludeMode::Separate,
top: 0,
}
}
fn sample_file_rows() -> Vec<FileRow> {
vec![
FileRow {
path: "src/lib.rs".to_string(),
module: "src".to_string(),
lang: "Rust".to_string(),
kind: FileKind::Parent,
code: 100,
comments: 20,
blanks: 10,
lines: 130,
bytes: 1000,
tokens: 250,
},
FileRow {
path: "tests/test.rs".to_string(),
module: "tests".to_string(),
lang: "Rust".to_string(),
kind: FileKind::Parent,
code: 50,
comments: 5,
blanks: 5,
lines: 60,
bytes: 500,
tokens: 125,
},
]
}
#[test]
fn render_lang_md_without_files() {
let report = sample_lang_report(false);
let output = render_lang_md(&report);
assert!(output.contains("|Lang|Code|Lines|Bytes|Tokens|"));
assert!(!output.contains("|Files|"));
assert!(!output.contains("|Avg|"));
assert!(output.contains("|Rust|1000|1200|50000|2500|"));
assert!(output.contains("|TOML|50|60|1000|125|"));
assert!(output.contains("|**Total**|1050|1260|51000|2625|"));
}
#[test]
fn render_lang_md_with_files() {
let report = sample_lang_report(true);
let output = render_lang_md(&report);
assert!(output.contains("|Lang|Code|Lines|Files|Bytes|Tokens|Avg|"));
assert!(output.contains("|Rust|1000|1200|10|50000|2500|120|"));
assert!(output.contains("|TOML|50|60|2|1000|125|30|"));
assert!(output.contains("|**Total**|1050|1260|12|51000|2625|105|"));
}
#[test]
fn render_lang_md_table_structure() {
let report = sample_lang_report(true);
let output = render_lang_md(&report);
let lines: Vec<&str> = output.lines().collect();
assert!(lines.len() >= 4);
assert!(lines[1].contains("|---|"));
assert!(lines[1].contains(":")); }
#[test]
fn render_lang_tsv_without_files() {
let report = sample_lang_report(false);
let output = render_lang_tsv(&report);
assert!(output.starts_with("Lang\tCode\tLines\tBytes\tTokens\n"));
assert!(!output.contains("\tFiles\t"));
assert!(!output.contains("\tAvg"));
assert!(output.contains("Rust\t1000\t1200\t50000\t2500"));
assert!(output.contains("TOML\t50\t60\t1000\t125"));
assert!(output.contains("Total\t1050\t1260\t51000\t2625"));
}
#[test]
fn render_lang_tsv_with_files() {
let report = sample_lang_report(true);
let output = render_lang_tsv(&report);
assert!(output.starts_with("Lang\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n"));
assert!(output.contains("Rust\t1000\t1200\t10\t50000\t2500\t120"));
assert!(output.contains("TOML\t50\t60\t2\t1000\t125\t30"));
}
#[test]
fn render_lang_tsv_tab_separated() {
let report = sample_lang_report(false);
let output = render_lang_tsv(&report);
for line in output.lines().skip(1) {
if line.starts_with("Total") || line.starts_with("Rust") || line.starts_with("TOML") {
assert_eq!(line.matches('\t').count(), 4);
}
}
}
#[test]
fn render_module_md_structure() {
let report = sample_module_report();
let output = render_module_md(&report);
assert!(output.contains("|Module|Code|Lines|Files|Bytes|Tokens|Avg|"));
assert!(output.contains("|crates/foo|800|950|8|40000|2000|119|"));
assert!(output.contains("|crates/bar|200|250|2|10000|500|125|"));
assert!(output.contains("|**Total**|1000|1200|10|50000|2500|120|"));
}
#[test]
fn render_module_md_table_format() {
let report = sample_module_report();
let output = render_module_md(&report);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 5);
assert!(lines[1].contains("---:"));
}
#[test]
fn render_module_tsv_structure() {
let report = sample_module_report();
let output = render_module_tsv(&report);
assert!(output.starts_with("Module\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n"));
assert!(output.contains("crates/foo\t800\t950\t8\t40000\t2000\t119"));
assert!(output.contains("crates/bar\t200\t250\t2\t10000\t500\t125"));
assert!(output.contains("Total\t1000\t1200\t10\t50000\t2500\t120"));
}
#[test]
fn render_module_tsv_tab_count() {
let report = sample_module_report();
let output = render_module_tsv(&report);
for line in output.lines() {
assert_eq!(line.matches('\t').count(), 6);
}
}
#[test]
fn redact_rows_none_mode() {
let rows = sample_file_rows();
let redacted: Vec<_> = redact_rows(&rows, RedactMode::None).collect();
assert_eq!(redacted.len(), rows.len());
assert_eq!(redacted[0].path, "src/lib.rs");
assert_eq!(redacted[0].module, "src");
}
#[test]
fn redact_rows_paths_mode() {
let rows = sample_file_rows();
let redacted: Vec<_> = redact_rows(&rows, RedactMode::Paths).collect();
assert_ne!(redacted[0].path, "src/lib.rs");
assert!(redacted[0].path.ends_with(".rs"));
assert_eq!(redacted[0].path.len(), 16 + 3);
assert_eq!(redacted[0].module, "src");
}
#[test]
fn redact_rows_all_mode() {
let rows = sample_file_rows();
let redacted: Vec<_> = redact_rows(&rows, RedactMode::All).collect();
assert_ne!(redacted[0].path, "src/lib.rs");
assert!(redacted[0].path.ends_with(".rs"));
assert_ne!(redacted[0].module, "src");
assert_eq!(redacted[0].module.len(), 16);
}
#[test]
fn redact_rows_preserves_other_fields() {
let rows = sample_file_rows();
let redacted: Vec<_> = redact_rows(&rows, RedactMode::All).collect();
assert_eq!(redacted[0].lang, "Rust");
assert_eq!(redacted[0].kind, FileKind::Parent);
assert_eq!(redacted[0].code, 100);
assert_eq!(redacted[0].comments, 20);
assert_eq!(redacted[0].blanks, 10);
assert_eq!(redacted[0].lines, 130);
assert_eq!(redacted[0].bytes, 1000);
assert_eq!(redacted[0].tokens, 250);
}
#[test]
fn normalize_scan_input_forward_slash() {
let p = Path::new("src/lib.rs");
let normalized = normalize_scan_input(p);
assert_eq!(normalized, "src/lib.rs");
}
#[test]
fn normalize_scan_input_backslash_to_forward() {
let p = Path::new("src\\lib.rs");
let normalized = normalize_scan_input(p);
assert_eq!(normalized, "src/lib.rs");
}
#[test]
fn normalize_scan_input_strips_dot_slash() {
let p = Path::new("./src/lib.rs");
let normalized = normalize_scan_input(p);
assert_eq!(normalized, "src/lib.rs");
}
#[test]
fn normalize_scan_input_current_dir() {
let p = Path::new(".");
let normalized = normalize_scan_input(p);
assert_eq!(normalized, ".");
}
proptest! {
#[test]
fn normalize_scan_input_no_backslash(s in "[a-zA-Z0-9_/\\\\.]+") {
let p = Path::new(&s);
let normalized = normalize_scan_input(p);
prop_assert!(!normalized.contains('\\'), "Should not contain backslash: {}", normalized);
}
#[test]
fn normalize_scan_input_no_leading_dot_slash(s in "[a-zA-Z0-9_/\\\\.]+") {
let p = Path::new(&s);
let normalized = normalize_scan_input(p);
prop_assert!(!normalized.starts_with("./"), "Should not start with ./: {}", normalized);
}
#[test]
fn redact_rows_preserves_count(
code in 0usize..10000,
comments in 0usize..1000,
blanks in 0usize..500
) {
let rows = vec![FileRow {
path: "test/file.rs".to_string(),
module: "test".to_string(),
lang: "Rust".to_string(),
kind: FileKind::Parent,
code,
comments,
blanks,
lines: code + comments + blanks,
bytes: 1000,
tokens: 250,
}];
for mode in [RedactMode::None, RedactMode::Paths, RedactMode::All] {
let redacted: Vec<_> = redact_rows(&rows, mode).collect();
prop_assert_eq!(redacted.len(), 1);
prop_assert_eq!(redacted[0].code, code);
prop_assert_eq!(redacted[0].comments, comments);
prop_assert_eq!(redacted[0].blanks, blanks);
}
}
#[test]
fn redact_rows_paths_end_with_extension(ext in "[a-z]{1,4}") {
let path = format!("some/path/file.{}", ext);
let rows = vec![FileRow {
path: path.clone(),
module: "some".to_string(),
lang: "Test".to_string(),
kind: FileKind::Parent,
code: 100,
comments: 10,
blanks: 5,
lines: 115,
bytes: 1000,
tokens: 250,
}];
let redacted: Vec<_> = redact_rows(&rows, RedactMode::Paths).collect();
prop_assert!(redacted[0].path.ends_with(&format!(".{}", ext)),
"Redacted path '{}' should end with .{}", redacted[0].path, ext);
}
}
#[test]
fn snapshot_lang_md_with_files() {
let report = sample_lang_report(true);
let output = render_lang_md(&report);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_lang_md_without_files() {
let report = sample_lang_report(false);
let output = render_lang_md(&report);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_lang_tsv_with_files() {
let report = sample_lang_report(true);
let output = render_lang_tsv(&report);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_module_md() {
let report = sample_module_report();
let output = render_module_md(&report);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_module_tsv() {
let report = sample_module_report();
let output = render_module_tsv(&report);
insta::assert_snapshot!(output);
}
#[test]
fn test_render_diff_md_smoke() {
let from = LangReport {
rows: vec![LangRow {
lang: "Rust".to_string(),
code: 10,
lines: 10,
files: 1,
bytes: 100,
tokens: 20,
avg_lines: 10,
}],
total: Totals {
code: 10,
lines: 10,
files: 1,
bytes: 100,
tokens: 20,
avg_lines: 10,
},
with_files: false,
children: ChildrenMode::Collapse,
top: 0,
};
let to = LangReport {
rows: vec![LangRow {
lang: "Rust".to_string(),
code: 12,
lines: 12,
files: 1,
bytes: 120,
tokens: 24,
avg_lines: 12,
}],
total: Totals {
code: 12,
lines: 12,
files: 1,
bytes: 120,
tokens: 24,
avg_lines: 12,
},
with_files: false,
children: ChildrenMode::Collapse,
top: 0,
};
let rows = compute_diff_rows(&from, &to);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].lang, "Rust");
assert_eq!(rows[0].delta_code, 2);
let totals = compute_diff_totals(&rows);
assert_eq!(totals.delta_code, 2);
let md = render_diff_md("from", "to", &rows, &totals);
assert!(!md.trim().is_empty(), "diff markdown must not be empty");
assert!(md.contains("from"));
assert!(md.contains("to"));
assert!(md.contains("Rust"));
assert!(md.contains("|LOC|"));
assert!(md.contains("|Lines|"));
assert!(md.contains("|Files|"));
assert!(md.contains("|Bytes|"));
assert!(md.contains("|Tokens|"));
assert!(md.contains("### Language Movement"));
}
#[test]
fn test_render_diff_md_compact_includes_movement_counts() {
let from = LangReport {
rows: vec![LangRow {
lang: "Rust".to_string(),
code: 10,
lines: 10,
files: 1,
bytes: 100,
tokens: 20,
avg_lines: 10,
}],
total: Totals {
code: 10,
lines: 10,
files: 1,
bytes: 100,
tokens: 20,
avg_lines: 10,
},
with_files: false,
children: ChildrenMode::Collapse,
top: 0,
};
let to = LangReport {
rows: vec![
LangRow {
lang: "Rust".to_string(),
code: 12,
lines: 12,
files: 1,
bytes: 120,
tokens: 24,
avg_lines: 12,
},
LangRow {
lang: "Python".to_string(),
code: 8,
lines: 8,
files: 1,
bytes: 80,
tokens: 16,
avg_lines: 8,
},
],
total: Totals {
code: 20,
lines: 20,
files: 2,
bytes: 200,
tokens: 40,
avg_lines: 10,
},
with_files: false,
children: ChildrenMode::Collapse,
top: 0,
};
let rows = compute_diff_rows(&from, &to);
let totals = compute_diff_totals(&rows);
let md = render_diff_md_with_options(
"from",
"to",
&rows,
&totals,
DiffRenderOptions {
compact: true,
color: DiffColorMode::Off,
},
);
assert!(md.contains("|Delta Lines|"));
assert!(md.contains("|Delta Files|"));
assert!(md.contains("|Delta Bytes|"));
assert!(md.contains("|Delta Tokens|"));
assert!(md.contains("|Languages added|1|"));
assert!(md.contains("|Languages modified|1|"));
}
#[test]
fn test_compute_diff_rows_language_added() {
let from = LangReport {
rows: vec![],
total: Totals {
code: 0,
lines: 0,
files: 0,
bytes: 0,
tokens: 0,
avg_lines: 0,
},
with_files: false,
children: ChildrenMode::Collapse,
top: 0,
};
let to = LangReport {
rows: vec![LangRow {
lang: "Python".to_string(),
code: 100,
lines: 120,
files: 5,
bytes: 5000,
tokens: 250,
avg_lines: 24,
}],
total: Totals {
code: 100,
lines: 120,
files: 5,
bytes: 5000,
tokens: 250,
avg_lines: 24,
},
with_files: false,
children: ChildrenMode::Collapse,
top: 0,
};
let rows = compute_diff_rows(&from, &to);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].lang, "Python");
assert_eq!(rows[0].old_code, 0);
assert_eq!(rows[0].new_code, 100);
assert_eq!(rows[0].delta_code, 100);
}
#[test]
fn test_compute_diff_rows_language_removed() {
let from = LangReport {
rows: vec![LangRow {
lang: "Go".to_string(),
code: 50,
lines: 60,
files: 2,
bytes: 2000,
tokens: 125,
avg_lines: 30,
}],
total: Totals {
code: 50,
lines: 60,
files: 2,
bytes: 2000,
tokens: 125,
avg_lines: 30,
},
with_files: false,
children: ChildrenMode::Collapse,
top: 0,
};
let to = LangReport {
rows: vec![],
total: Totals {
code: 0,
lines: 0,
files: 0,
bytes: 0,
tokens: 0,
avg_lines: 0,
},
with_files: false,
children: ChildrenMode::Collapse,
top: 0,
};
let rows = compute_diff_rows(&from, &to);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].lang, "Go");
assert_eq!(rows[0].old_code, 50);
assert_eq!(rows[0].new_code, 0);
assert_eq!(rows[0].delta_code, -50);
}
#[test]
fn test_compute_diff_rows_unchanged_excluded() {
let report = LangReport {
rows: vec![LangRow {
lang: "Rust".to_string(),
code: 100,
lines: 100,
files: 1,
bytes: 1000,
tokens: 250,
avg_lines: 100,
}],
total: Totals {
code: 100,
lines: 100,
files: 1,
bytes: 1000,
tokens: 250,
avg_lines: 100,
},
with_files: false,
children: ChildrenMode::Collapse,
top: 0,
};
let rows = compute_diff_rows(&report, &report);
assert!(rows.is_empty(), "unchanged languages should be excluded");
}
#[test]
fn test_format_delta() {
assert_eq!(format_delta(5), "+5");
assert_eq!(format_delta(0), "0");
assert_eq!(format_delta(-3), "-3");
}
fn sample_global_args() -> ScanOptions {
ScanOptions::default()
}
fn sample_lang_args(format: TableFormat) -> LangArgs {
LangArgs {
paths: vec![PathBuf::from(".")],
format,
top: 0,
files: false,
children: ChildrenMode::Collapse,
}
}
fn sample_module_args(format: TableFormat) -> ModuleArgs {
ModuleArgs {
paths: vec![PathBuf::from(".")],
format,
top: 0,
module_roots: vec!["crates".to_string()],
module_depth: 2,
children: tokmd_settings::ChildIncludeMode::Separate,
}
}
#[test]
fn write_lang_report_to_md_writes_content() {
let report = sample_lang_report(true);
let global = sample_global_args();
let args = sample_lang_args(TableFormat::Md);
let mut buf = Vec::new();
write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(!output.is_empty(), "output must not be empty");
assert!(output.contains("|Lang|"), "must contain markdown header");
assert!(output.contains("|Rust|"), "must contain Rust row");
assert!(output.contains("|**Total**|"), "must contain total row");
}
#[test]
fn write_lang_report_to_tsv_writes_content() {
let report = sample_lang_report(false);
let global = sample_global_args();
let args = sample_lang_args(TableFormat::Tsv);
let mut buf = Vec::new();
write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(!output.is_empty(), "output must not be empty");
assert!(output.contains("Lang\t"), "must contain TSV header");
assert!(output.contains("Rust\t"), "must contain Rust row");
assert!(output.contains("Total\t"), "must contain total row");
}
#[test]
fn write_lang_report_to_json_writes_receipt() {
let report = sample_lang_report(true);
let global = sample_global_args();
let args = sample_lang_args(TableFormat::Json);
let mut buf = Vec::new();
write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(!output.is_empty(), "output must not be empty");
let receipt: LangReceipt = serde_json::from_str(&output).unwrap();
assert_eq!(receipt.mode, "lang");
assert_eq!(receipt.report.rows.len(), 2);
assert_eq!(receipt.report.total.code, 1050);
}
#[test]
fn write_module_report_to_md_writes_content() {
let report = sample_module_report();
let global = sample_global_args();
let args = sample_module_args(TableFormat::Md);
let mut buf = Vec::new();
write_module_report_to(&mut buf, &report, &global, &args).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(!output.is_empty(), "output must not be empty");
assert!(output.contains("|Module|"), "must contain markdown header");
assert!(output.contains("|crates/foo|"), "must contain module row");
assert!(output.contains("|**Total**|"), "must contain total row");
}
#[test]
fn write_module_report_to_tsv_writes_content() {
let report = sample_module_report();
let global = sample_global_args();
let args = sample_module_args(TableFormat::Tsv);
let mut buf = Vec::new();
write_module_report_to(&mut buf, &report, &global, &args).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(!output.is_empty(), "output must not be empty");
assert!(output.contains("Module\t"), "must contain TSV header");
assert!(output.contains("crates/foo\t"), "must contain module row");
assert!(output.contains("Total\t"), "must contain total row");
}
#[test]
fn write_module_report_to_json_writes_receipt() {
let report = sample_module_report();
let global = sample_global_args();
let args = sample_module_args(TableFormat::Json);
let mut buf = Vec::new();
write_module_report_to(&mut buf, &report, &global, &args).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(!output.is_empty(), "output must not be empty");
let receipt: ModuleReceipt = serde_json::from_str(&output).unwrap();
assert_eq!(receipt.mode, "module");
assert_eq!(receipt.report.rows.len(), 2);
assert_eq!(receipt.report.total.code, 1000);
}
}
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
pub mod readme_doctests {}