1use std::borrow::Cow;
21use std::fmt::Write as FmtWrite;
22use std::fs::File;
23use std::io::{self, BufWriter, Write};
24use std::path::Path;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27#[cfg(test)]
28use std::path::PathBuf;
29
30use anyhow::Result;
31use serde::Serialize;
32use time::OffsetDateTime;
33use time::format_description::well_known::Rfc3339;
34
35use tokmd_settings::ScanOptions;
36use tokmd_types::{
37 ExportArgs, ExportArgsMeta, ExportData, ExportFormat, ExportReceipt, FileKind, FileRow,
38 LangArgs, LangArgsMeta, LangReceipt, LangReport, ModuleArgs, ModuleArgsMeta, ModuleReceipt,
39 ModuleReport, RedactMode, ScanArgs, ScanStatus, TableFormat, ToolInfo,
40};
41
42pub use tokmd_scan_args::{normalize_scan_input, scan_args};
43
44fn now_ms() -> u128 {
45 SystemTime::now()
46 .duration_since(UNIX_EPOCH)
47 .unwrap_or_default()
48 .as_millis()
49}
50
51pub fn write_lang_report_to<W: Write>(
59 mut out: W,
60 report: &LangReport,
61 global: &ScanOptions,
62 args: &LangArgs,
63) -> Result<()> {
64 match args.format {
65 TableFormat::Md => {
66 out.write_all(render_lang_md(report).as_bytes())?;
67 }
68 TableFormat::Tsv => {
69 out.write_all(render_lang_tsv(report).as_bytes())?;
70 }
71 TableFormat::Json => {
72 let receipt = LangReceipt {
73 schema_version: tokmd_types::SCHEMA_VERSION,
74 generated_at_ms: now_ms(),
75 tool: ToolInfo::current(),
76 mode: "lang".to_string(),
77 status: ScanStatus::Complete,
78 warnings: vec![],
79 scan: scan_args(&args.paths, global, None),
80 args: LangArgsMeta {
81 format: "json".to_string(),
82 top: report.top,
83 with_files: report.with_files,
84 children: report.children,
85 },
86 report: report.clone(),
87 };
88 writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
89 }
90 }
91 Ok(())
92}
93
94pub fn print_lang_report(report: &LangReport, global: &ScanOptions, args: &LangArgs) -> Result<()> {
98 let stdout = io::stdout();
99 let out = stdout.lock();
100 write_lang_report_to(out, report, global, args)
101}
102
103fn render_lang_md(report: &LangReport) -> String {
104 let mut s = String::with_capacity((report.rows.len() + 3) * 80);
106
107 if report.with_files {
108 s.push_str("|Lang|Code|Lines|Files|Bytes|Tokens|Avg|\n");
109 s.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
110 for r in &report.rows {
111 let _ = writeln!(
112 s,
113 "|{}|{}|{}|{}|{}|{}|{}|",
114 r.lang, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
115 );
116 }
117 let _ = writeln!(
118 s,
119 "|**Total**|{}|{}|{}|{}|{}|{}|",
120 report.total.code,
121 report.total.lines,
122 report.total.files,
123 report.total.bytes,
124 report.total.tokens,
125 report.total.avg_lines
126 );
127 } else {
128 s.push_str("|Lang|Code|Lines|Bytes|Tokens|\n");
129 s.push_str("|---|---:|---:|---:|---:|\n");
130 for r in &report.rows {
131 let _ = writeln!(
132 s,
133 "|{}|{}|{}|{}|{}|",
134 r.lang, r.code, r.lines, r.bytes, r.tokens
135 );
136 }
137 let _ = writeln!(
138 s,
139 "|**Total**|{}|{}|{}|{}|",
140 report.total.code, report.total.lines, report.total.bytes, report.total.tokens
141 );
142 }
143
144 s
145}
146
147fn render_lang_tsv(report: &LangReport) -> String {
148 let mut s = String::with_capacity((report.rows.len() + 2) * 64);
150
151 if report.with_files {
152 s.push_str("Lang\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n");
153 for r in &report.rows {
154 let _ = writeln!(
155 s,
156 "{}\t{}\t{}\t{}\t{}\t{}\t{}",
157 r.lang, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
158 );
159 }
160 let _ = writeln!(
161 s,
162 "Total\t{}\t{}\t{}\t{}\t{}\t{}",
163 report.total.code,
164 report.total.lines,
165 report.total.files,
166 report.total.bytes,
167 report.total.tokens,
168 report.total.avg_lines
169 );
170 } else {
171 s.push_str("Lang\tCode\tLines\tBytes\tTokens\n");
172 for r in &report.rows {
173 let _ = writeln!(
174 s,
175 "{}\t{}\t{}\t{}\t{}",
176 r.lang, r.code, r.lines, r.bytes, r.tokens
177 );
178 }
179 let _ = writeln!(
180 s,
181 "Total\t{}\t{}\t{}\t{}",
182 report.total.code, report.total.lines, report.total.bytes, report.total.tokens
183 );
184 }
185
186 s
187}
188
189pub fn write_module_report_to<W: Write>(
197 mut out: W,
198 report: &ModuleReport,
199 global: &ScanOptions,
200 args: &ModuleArgs,
201) -> Result<()> {
202 match args.format {
203 TableFormat::Md => {
204 out.write_all(render_module_md(report).as_bytes())?;
205 }
206 TableFormat::Tsv => {
207 out.write_all(render_module_tsv(report).as_bytes())?;
208 }
209 TableFormat::Json => {
210 let receipt = ModuleReceipt {
211 schema_version: tokmd_types::SCHEMA_VERSION,
212 generated_at_ms: now_ms(),
213 tool: ToolInfo::current(),
214 mode: "module".to_string(),
215 status: ScanStatus::Complete,
216 warnings: vec![],
217 scan: scan_args(&args.paths, global, None),
218 args: ModuleArgsMeta {
219 format: "json".to_string(),
220 top: report.top,
221 module_roots: report.module_roots.clone(),
222 module_depth: report.module_depth,
223 children: report.children,
224 },
225 report: report.clone(),
226 };
227 writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
228 }
229 }
230 Ok(())
231}
232
233pub fn print_module_report(
237 report: &ModuleReport,
238 global: &ScanOptions,
239 args: &ModuleArgs,
240) -> Result<()> {
241 let stdout = io::stdout();
242 let out = stdout.lock();
243 write_module_report_to(out, report, global, args)
244}
245
246fn render_module_md(report: &ModuleReport) -> String {
247 let mut s = String::with_capacity((report.rows.len() + 3) * 80);
249 s.push_str("|Module|Code|Lines|Files|Bytes|Tokens|Avg|\n");
250 s.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
251 for r in &report.rows {
252 let _ = writeln!(
253 s,
254 "|{}|{}|{}|{}|{}|{}|{}|",
255 r.module, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
256 );
257 }
258 let _ = writeln!(
259 s,
260 "|**Total**|{}|{}|{}|{}|{}|{}|",
261 report.total.code,
262 report.total.lines,
263 report.total.files,
264 report.total.bytes,
265 report.total.tokens,
266 report.total.avg_lines
267 );
268 s
269}
270
271fn render_module_tsv(report: &ModuleReport) -> String {
272 let mut s = String::with_capacity((report.rows.len() + 2) * 64);
274 s.push_str("Module\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n");
275 for r in &report.rows {
276 let _ = writeln!(
277 s,
278 "{}\t{}\t{}\t{}\t{}\t{}\t{}",
279 r.module, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
280 );
281 }
282 let _ = writeln!(
283 s,
284 "Total\t{}\t{}\t{}\t{}\t{}\t{}",
285 report.total.code,
286 report.total.lines,
287 report.total.files,
288 report.total.bytes,
289 report.total.tokens,
290 report.total.avg_lines
291 );
292 s
293}
294
295#[derive(Debug, Clone, Serialize)]
300struct ExportMeta {
301 #[serde(rename = "type")]
302 ty: &'static str,
303 schema_version: u32,
304 generated_at_ms: u128,
305 tool: ToolInfo,
306 mode: String,
307 status: ScanStatus,
308 warnings: Vec<String>,
309 scan: ScanArgs,
310 args: ExportArgsMeta,
311}
312
313#[derive(Debug, Clone, Serialize)]
314struct JsonlRow<'a> {
315 #[serde(rename = "type")]
316 ty: &'static str,
317 #[serde(flatten)]
318 row: &'a FileRow,
319}
320
321pub fn write_export(export: &ExportData, global: &ScanOptions, args: &ExportArgs) -> Result<()> {
322 match &args.output {
323 Some(path) => {
324 let file = File::create(path)?;
325 let mut out = BufWriter::new(file);
326 write_export_to(&mut out, export, global, args)?;
327 out.flush()?;
328 }
329 None => {
330 let stdout = io::stdout();
331 let mut out = stdout.lock();
332 write_export_to(&mut out, export, global, args)?;
333 out.flush()?;
334 }
335 }
336 Ok(())
337}
338
339fn write_export_to<W: Write>(
340 out: &mut W,
341 export: &ExportData,
342 global: &ScanOptions,
343 args: &ExportArgs,
344) -> Result<()> {
345 match args.format {
346 ExportFormat::Csv => write_export_csv(out, export, args),
347 ExportFormat::Jsonl => write_export_jsonl(out, export, global, args),
348 ExportFormat::Json => write_export_json(out, export, global, args),
349 ExportFormat::Cyclonedx => write_export_cyclonedx(out, export, args.redact),
350 }
351}
352
353fn write_export_csv<W: Write>(out: &mut W, export: &ExportData, args: &ExportArgs) -> Result<()> {
354 let mut wtr = csv::WriterBuilder::new().has_headers(true).from_writer(out);
355 wtr.write_record([
356 "path", "module", "lang", "kind", "code", "comments", "blanks", "lines", "bytes", "tokens",
357 ])?;
358
359 for r in redact_rows(&export.rows, args.redact) {
360 let code = r.code.to_string();
361 let comments = r.comments.to_string();
362 let blanks = r.blanks.to_string();
363 let lines = r.lines.to_string();
364 let bytes = r.bytes.to_string();
365 let tokens = r.tokens.to_string();
366 let kind = match r.kind {
367 FileKind::Parent => "parent",
368 FileKind::Child => "child",
369 };
370
371 wtr.write_record([
372 r.path.as_str(),
373 r.module.as_str(),
374 r.lang.as_str(),
375 kind,
376 &code,
377 &comments,
378 &blanks,
379 &lines,
380 &bytes,
381 &tokens,
382 ])?;
383 }
384
385 wtr.flush()?;
386 Ok(())
387}
388
389fn write_export_jsonl<W: Write>(
390 out: &mut W,
391 export: &ExportData,
392 global: &ScanOptions,
393 args: &ExportArgs,
394) -> Result<()> {
395 if args.meta {
396 let should_redact = args.redact == RedactMode::Paths || args.redact == RedactMode::All;
397 let strip_prefix_redacted = should_redact && args.strip_prefix.is_some();
398
399 let meta = ExportMeta {
400 ty: "meta",
401 schema_version: tokmd_types::SCHEMA_VERSION,
402 generated_at_ms: now_ms(),
403 tool: ToolInfo::current(),
404 mode: "export".to_string(),
405 status: ScanStatus::Complete,
406 warnings: vec![],
407 scan: scan_args(&args.paths, global, Some(args.redact)),
408 args: ExportArgsMeta {
409 format: args.format,
410 module_roots: export.module_roots.clone(),
411 module_depth: export.module_depth,
412 children: export.children,
413 min_code: args.min_code,
414 max_rows: args.max_rows,
415 redact: args.redact,
416 strip_prefix: if should_redact {
417 args.strip_prefix
418 .as_ref()
419 .map(|p| redact_path(&p.display().to_string().replace('\\', "/")))
420 } else {
421 args.strip_prefix
422 .as_ref()
423 .map(|p| p.display().to_string().replace('\\', "/"))
424 },
425 strip_prefix_redacted,
426 },
427 };
428 writeln!(out, "{}", serde_json::to_string(&meta)?)?;
429 }
430
431 for row in redact_rows(&export.rows, args.redact) {
432 let wrapper = JsonlRow {
433 ty: "row",
434 row: &row,
435 };
436 writeln!(out, "{}", serde_json::to_string(&wrapper)?)?;
437 }
438 Ok(())
439}
440
441fn write_export_json<W: Write>(
442 out: &mut W,
443 export: &ExportData,
444 global: &ScanOptions,
445 args: &ExportArgs,
446) -> Result<()> {
447 if args.meta {
448 let should_redact = args.redact == RedactMode::Paths || args.redact == RedactMode::All;
449 let strip_prefix_redacted = should_redact && args.strip_prefix.is_some();
450
451 let receipt = ExportReceipt {
452 schema_version: tokmd_types::SCHEMA_VERSION,
453 generated_at_ms: now_ms(),
454 tool: ToolInfo::current(),
455 mode: "export".to_string(),
456 status: ScanStatus::Complete,
457 warnings: vec![],
458 scan: scan_args(&args.paths, global, Some(args.redact)),
459 args: ExportArgsMeta {
460 format: args.format,
461 module_roots: export.module_roots.clone(),
462 module_depth: export.module_depth,
463 children: export.children,
464 min_code: args.min_code,
465 max_rows: args.max_rows,
466 redact: args.redact,
467 strip_prefix: if should_redact {
468 args.strip_prefix
469 .as_ref()
470 .map(|p| redact_path(&p.display().to_string().replace('\\', "/")))
471 } else {
472 args.strip_prefix
473 .as_ref()
474 .map(|p| p.display().to_string().replace('\\', "/"))
475 },
476 strip_prefix_redacted,
477 },
478 data: ExportData {
479 rows: redact_rows(&export.rows, args.redact)
480 .map(|c| c.into_owned())
481 .collect(),
482 module_roots: export.module_roots.clone(),
483 module_depth: export.module_depth,
484 children: export.children,
485 },
486 };
487 writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
488 } else {
489 writeln!(
490 out,
491 "{}",
492 serde_json::to_string(&redact_rows(&export.rows, args.redact).collect::<Vec<_>>())?
493 )?;
494 }
495 Ok(())
496}
497
498fn redact_rows(rows: &[FileRow], mode: RedactMode) -> impl Iterator<Item = Cow<'_, FileRow>> {
499 rows.iter().map(move |r| {
500 if mode == RedactMode::None {
501 Cow::Borrowed(r)
502 } else {
503 let mut owned = r.clone();
504 if mode == RedactMode::Paths || mode == RedactMode::All {
505 owned.path = redact_path(&owned.path);
506 }
507 if mode == RedactMode::All {
508 owned.module = short_hash(&owned.module);
509 }
510 Cow::Owned(owned)
511 }
512 })
513}
514
515pub use tokmd_redact::{redact_path, short_hash};
517
518#[derive(Debug, Clone, Serialize)]
523#[serde(rename_all = "camelCase")]
524struct CycloneDxBom {
525 bom_format: &'static str,
526 spec_version: &'static str,
527 serial_number: String,
528 version: u32,
529 metadata: CycloneDxMetadata,
530 components: Vec<CycloneDxComponent>,
531}
532
533#[derive(Debug, Clone, Serialize)]
534struct CycloneDxMetadata {
535 timestamp: String,
536 tools: Vec<CycloneDxTool>,
537}
538
539#[derive(Debug, Clone, Serialize)]
540struct CycloneDxTool {
541 vendor: &'static str,
542 name: &'static str,
543 version: String,
544}
545
546#[derive(Debug, Clone, Serialize)]
547struct CycloneDxComponent {
548 #[serde(rename = "type")]
549 ty: &'static str,
550 name: String,
551 #[serde(skip_serializing_if = "Option::is_none")]
552 group: Option<String>,
553 #[serde(skip_serializing_if = "Vec::is_empty")]
554 properties: Vec<CycloneDxProperty>,
555}
556
557#[derive(Debug, Clone, Serialize)]
558struct CycloneDxProperty {
559 name: String,
560 value: String,
561}
562
563fn write_export_cyclonedx<W: Write>(
564 out: &mut W,
565 export: &ExportData,
566 redact: RedactMode,
567) -> Result<()> {
568 write_export_cyclonedx_impl(out, export, redact, None, None)
569}
570
571fn write_export_cyclonedx_impl<W: Write>(
572 out: &mut W,
573 export: &ExportData,
574 redact: RedactMode,
575 serial_number: Option<String>,
576 timestamp: Option<String>,
577) -> Result<()> {
578 let timestamp = timestamp.unwrap_or_else(|| {
579 OffsetDateTime::now_utc()
580 .format(&Rfc3339)
581 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
582 });
583
584 let components: Vec<CycloneDxComponent> = redact_rows(&export.rows, redact)
586 .map(|row| {
587 let mut properties = vec![
588 CycloneDxProperty {
589 name: "tokmd:lang".to_string(),
590 value: row.lang.clone(),
591 },
592 CycloneDxProperty {
593 name: "tokmd:code".to_string(),
594 value: row.code.to_string(),
595 },
596 CycloneDxProperty {
597 name: "tokmd:comments".to_string(),
598 value: row.comments.to_string(),
599 },
600 CycloneDxProperty {
601 name: "tokmd:blanks".to_string(),
602 value: row.blanks.to_string(),
603 },
604 CycloneDxProperty {
605 name: "tokmd:lines".to_string(),
606 value: row.lines.to_string(),
607 },
608 CycloneDxProperty {
609 name: "tokmd:bytes".to_string(),
610 value: row.bytes.to_string(),
611 },
612 CycloneDxProperty {
613 name: "tokmd:tokens".to_string(),
614 value: row.tokens.to_string(),
615 },
616 ];
617
618 if row.kind == FileKind::Child {
620 properties.push(CycloneDxProperty {
621 name: "tokmd:kind".to_string(),
622 value: "child".to_string(),
623 });
624 }
625
626 CycloneDxComponent {
627 ty: "file",
628 name: row.path.clone(),
629 group: if row.module.is_empty() {
630 None
631 } else {
632 Some(row.module.clone())
633 },
634 properties,
635 }
636 })
637 .collect();
638
639 let bom = CycloneDxBom {
640 bom_format: "CycloneDX",
641 spec_version: "1.6",
642 serial_number: serial_number
643 .unwrap_or_else(|| format!("urn:uuid:{}", uuid::Uuid::new_v4())),
644 version: 1,
645 metadata: CycloneDxMetadata {
646 timestamp,
647 tools: vec![CycloneDxTool {
648 vendor: "tokmd",
649 name: "tokmd",
650 version: env!("CARGO_PKG_VERSION").to_string(),
651 }],
652 },
653 components,
654 };
655
656 writeln!(out, "{}", serde_json::to_string_pretty(&bom)?)?;
657 Ok(())
658}
659
660pub fn write_lang_json_to_file(
670 path: &Path,
671 report: &LangReport,
672 scan: &ScanArgs,
673 args_meta: &LangArgsMeta,
674) -> Result<()> {
675 let receipt = LangReceipt {
676 schema_version: tokmd_types::SCHEMA_VERSION,
677 generated_at_ms: now_ms(),
678 tool: ToolInfo::current(),
679 mode: "lang".to_string(),
680 status: ScanStatus::Complete,
681 warnings: vec![],
682 scan: scan.clone(),
683 args: args_meta.clone(),
684 report: report.clone(),
685 };
686 let file = File::create(path)?;
687 serde_json::to_writer(file, &receipt)?;
688 Ok(())
689}
690
691pub fn write_module_json_to_file(
697 path: &Path,
698 report: &ModuleReport,
699 scan: &ScanArgs,
700 args_meta: &ModuleArgsMeta,
701) -> Result<()> {
702 let receipt = ModuleReceipt {
703 schema_version: tokmd_types::SCHEMA_VERSION,
704 generated_at_ms: now_ms(),
705 tool: ToolInfo::current(),
706 mode: "module".to_string(),
707 status: ScanStatus::Complete,
708 warnings: vec![],
709 scan: scan.clone(),
710 args: args_meta.clone(),
711 report: report.clone(),
712 };
713 let file = File::create(path)?;
714 serde_json::to_writer(file, &receipt)?;
715 Ok(())
716}
717
718pub fn write_export_jsonl_to_file(
724 path: &Path,
725 export: &ExportData,
726 scan: &ScanArgs,
727 args_meta: &ExportArgsMeta,
728) -> Result<()> {
729 let file = File::create(path)?;
730 let mut out = BufWriter::new(file);
731
732 let meta = ExportMeta {
733 ty: "meta",
734 schema_version: tokmd_types::SCHEMA_VERSION,
735 generated_at_ms: now_ms(),
736 tool: ToolInfo::current(),
737 mode: "export".to_string(),
738 status: ScanStatus::Complete,
739 warnings: vec![],
740 scan: scan.clone(),
741 args: args_meta.clone(),
742 };
743 writeln!(out, "{}", serde_json::to_string(&meta)?)?;
744
745 for row in redact_rows(&export.rows, args_meta.redact) {
746 let wrapper = JsonlRow {
747 ty: "row",
748 row: &row,
749 };
750 writeln!(out, "{}", serde_json::to_string(&wrapper)?)?;
751 }
752
753 out.flush()?;
754 Ok(())
755}
756
757use tokmd_types::{DiffReceipt, DiffRow, DiffTotals, LangRow};
762
763pub fn compute_diff_rows(from_report: &LangReport, to_report: &LangReport) -> Vec<DiffRow> {
797 let mut all_langs: Vec<String> = from_report
799 .rows
800 .iter()
801 .chain(to_report.rows.iter())
802 .map(|r| r.lang.clone())
803 .collect();
804 all_langs.sort();
805 all_langs.dedup();
806
807 all_langs
808 .into_iter()
809 .filter_map(|lang_name| {
810 let old_row = from_report.rows.iter().find(|r| r.lang == lang_name);
811 let new_row = to_report.rows.iter().find(|r| r.lang == lang_name);
812
813 let old = old_row.cloned().unwrap_or_else(|| LangRow {
814 lang: lang_name.clone(),
815 code: 0,
816 lines: 0,
817 files: 0,
818 bytes: 0,
819 tokens: 0,
820 avg_lines: 0,
821 });
822 let new = new_row.cloned().unwrap_or_else(|| LangRow {
823 lang: lang_name.clone(),
824 code: 0,
825 lines: 0,
826 files: 0,
827 bytes: 0,
828 tokens: 0,
829 avg_lines: 0,
830 });
831
832 if old.code == new.code
834 && old.lines == new.lines
835 && old.files == new.files
836 && old.bytes == new.bytes
837 && old.tokens == new.tokens
838 {
839 return None;
840 }
841
842 Some(DiffRow {
843 lang: lang_name,
844 old_code: old.code,
845 new_code: new.code,
846 delta_code: new.code as i64 - old.code as i64,
847 old_lines: old.lines,
848 new_lines: new.lines,
849 delta_lines: new.lines as i64 - old.lines as i64,
850 old_files: old.files,
851 new_files: new.files,
852 delta_files: new.files as i64 - old.files as i64,
853 old_bytes: old.bytes,
854 new_bytes: new.bytes,
855 delta_bytes: new.bytes as i64 - old.bytes as i64,
856 old_tokens: old.tokens,
857 new_tokens: new.tokens,
858 delta_tokens: new.tokens as i64 - old.tokens as i64,
859 })
860 })
861 .collect()
862}
863
864pub fn compute_diff_totals(rows: &[DiffRow]) -> DiffTotals {
886 let mut totals = DiffTotals {
887 old_code: 0,
888 new_code: 0,
889 delta_code: 0,
890 old_lines: 0,
891 new_lines: 0,
892 delta_lines: 0,
893 old_files: 0,
894 new_files: 0,
895 delta_files: 0,
896 old_bytes: 0,
897 new_bytes: 0,
898 delta_bytes: 0,
899 old_tokens: 0,
900 new_tokens: 0,
901 delta_tokens: 0,
902 };
903
904 for row in rows {
905 totals.old_code += row.old_code;
906 totals.new_code += row.new_code;
907 totals.delta_code += row.delta_code;
908 totals.old_lines += row.old_lines;
909 totals.new_lines += row.new_lines;
910 totals.delta_lines += row.delta_lines;
911 totals.old_files += row.old_files;
912 totals.new_files += row.new_files;
913 totals.delta_files += row.delta_files;
914 totals.old_bytes += row.old_bytes;
915 totals.new_bytes += row.new_bytes;
916 totals.delta_bytes += row.delta_bytes;
917 totals.old_tokens += row.old_tokens;
918 totals.new_tokens += row.new_tokens;
919 totals.delta_tokens += row.delta_tokens;
920 }
921
922 totals
923}
924
925fn format_delta(delta: i64) -> String {
926 if delta > 0 {
927 format!("+{}", delta)
928 } else {
929 delta.to_string()
930 }
931}
932
933#[derive(Debug, Clone, Copy, PartialEq, Eq)]
934pub enum DiffColorMode {
935 Off,
936 Ansi,
937}
938
939#[derive(Debug, Clone, Copy, PartialEq, Eq)]
940pub struct DiffRenderOptions {
941 pub compact: bool,
942 pub color: DiffColorMode,
943}
944
945impl Default for DiffRenderOptions {
946 fn default() -> Self {
947 Self {
948 compact: false,
949 color: DiffColorMode::Off,
950 }
951 }
952}
953
954fn format_delta_colored(delta: i64, mode: DiffColorMode) -> String {
955 let raw = format_delta(delta);
956 if mode == DiffColorMode::Off {
957 return raw;
958 }
959 if delta > 0 {
960 format!("\x1b[32m{}\x1b[0m", raw)
961 } else if delta < 0 {
962 format!("\x1b[31m{}\x1b[0m", raw)
963 } else {
964 format!("\x1b[33m{}\x1b[0m", raw)
965 }
966}
967
968fn format_pct_delta_colored(delta_pct: f64, mode: DiffColorMode) -> String {
969 let raw = format!("{:+.1}%", delta_pct);
970 if mode == DiffColorMode::Off {
971 return raw;
972 }
973 if delta_pct > 0.0 {
974 format!("\x1b[32m{}\x1b[0m", raw)
975 } else if delta_pct < 0.0 {
976 format!("\x1b[31m{}\x1b[0m", raw)
977 } else {
978 format!("\x1b[33m{}\x1b[0m", raw)
979 }
980}
981
982fn percent_change(old: usize, new: usize) -> f64 {
983 if old > 0 {
984 ((new as f64 - old as f64) / old as f64) * 100.0
985 } else if new > 0 {
986 100.0
987 } else {
988 0.0
989 }
990}
991
992pub fn render_diff_md_with_options(
994 from_source: &str,
995 to_source: &str,
996 rows: &[DiffRow],
997 totals: &DiffTotals,
998 options: DiffRenderOptions,
999) -> String {
1000 let mut s = String::with_capacity((rows.len() + 20) * 80);
1002
1003 let _ = writeln!(s, "## Diff: {} → {}", from_source, to_source);
1004 s.push('\n');
1005
1006 let languages_added = rows
1007 .iter()
1008 .filter(|r| r.old_code == 0 && r.new_code > 0)
1009 .count();
1010 let languages_removed = rows
1011 .iter()
1012 .filter(|r| r.old_code > 0 && r.new_code == 0)
1013 .count();
1014 let languages_modified = rows
1015 .len()
1016 .saturating_sub(languages_added + languages_removed);
1017
1018 if options.compact {
1019 s.push_str("### Summary\n\n");
1020 s.push_str("|Metric|Value|\n");
1021 s.push_str("|---|---:|\n");
1022 let _ = writeln!(s, "|From LOC|{}|", totals.old_code);
1023 let _ = writeln!(s, "|To LOC|{}|", totals.new_code);
1024 let _ = writeln!(
1025 s,
1026 "|Delta LOC|{}|",
1027 format_delta_colored(totals.delta_code, options.color)
1028 );
1029 let _ = writeln!(
1030 s,
1031 "|LOC Change|{}|",
1032 format_pct_delta_colored(
1033 percent_change(totals.old_code, totals.new_code),
1034 options.color
1035 )
1036 );
1037 let _ = writeln!(
1038 s,
1039 "|Delta Lines|{}|",
1040 format_delta_colored(totals.delta_lines, options.color)
1041 );
1042 let _ = writeln!(
1043 s,
1044 "|Delta Files|{}|",
1045 format_delta_colored(totals.delta_files, options.color)
1046 );
1047 let _ = writeln!(
1048 s,
1049 "|Delta Bytes|{}|",
1050 format_delta_colored(totals.delta_bytes, options.color)
1051 );
1052 let _ = writeln!(
1053 s,
1054 "|Delta Tokens|{}|",
1055 format_delta_colored(totals.delta_tokens, options.color)
1056 );
1057 let _ = writeln!(s, "|Languages changed|{}|", rows.len());
1058 let _ = writeln!(s, "|Languages added|{}|", languages_added);
1059 let _ = writeln!(s, "|Languages removed|{}|", languages_removed);
1060 let _ = writeln!(s, "|Languages modified|{}|", languages_modified);
1061 return s;
1062 }
1063
1064 s.push_str("### Summary\n\n");
1066 s.push_str("|Metric|From|To|Delta|Change|\n");
1067 s.push_str("|---|---:|---:|---:|---:|\n");
1068
1069 let _ = writeln!(
1070 s,
1071 "|LOC|{}|{}|{}|{}|",
1072 totals.old_code,
1073 totals.new_code,
1074 format_delta_colored(totals.delta_code, options.color),
1075 format_pct_delta_colored(
1076 percent_change(totals.old_code, totals.new_code),
1077 options.color
1078 )
1079 );
1080 let _ = writeln!(
1081 s,
1082 "|Lines|{}|{}|{}|{}|",
1083 totals.old_lines,
1084 totals.new_lines,
1085 format_delta_colored(totals.delta_lines, options.color),
1086 format_pct_delta_colored(
1087 percent_change(totals.old_lines, totals.new_lines),
1088 options.color
1089 )
1090 );
1091 let _ = writeln!(
1092 s,
1093 "|Files|{}|{}|{}|{}|",
1094 totals.old_files,
1095 totals.new_files,
1096 format_delta_colored(totals.delta_files, options.color),
1097 format_pct_delta_colored(
1098 percent_change(totals.old_files, totals.new_files),
1099 options.color
1100 )
1101 );
1102 let _ = writeln!(
1103 s,
1104 "|Bytes|{}|{}|{}|{}|",
1105 totals.old_bytes,
1106 totals.new_bytes,
1107 format_delta_colored(totals.delta_bytes, options.color),
1108 format_pct_delta_colored(
1109 percent_change(totals.old_bytes, totals.new_bytes),
1110 options.color
1111 )
1112 );
1113 let _ = writeln!(
1114 s,
1115 "|Tokens|{}|{}|{}|{}|",
1116 totals.old_tokens,
1117 totals.new_tokens,
1118 format_delta_colored(totals.delta_tokens, options.color),
1119 format_pct_delta_colored(
1120 percent_change(totals.old_tokens, totals.new_tokens),
1121 options.color
1122 )
1123 );
1124 s.push('\n');
1125
1126 s.push_str("### Language Movement\n\n");
1127 s.push_str("|Type|Count|\n");
1128 s.push_str("|---|---:|\n");
1129 let _ = writeln!(s, "|Changed|{}|", rows.len());
1130 let _ = writeln!(s, "|Added|{}|", languages_added);
1131 let _ = writeln!(s, "|Removed|{}|", languages_removed);
1132 let _ = writeln!(s, "|Modified|{}|", languages_modified);
1133 s.push('\n');
1134
1135 s.push_str("### Language Breakdown\n\n");
1137 s.push_str("|Language|Old LOC|New LOC|Delta|\n");
1138 s.push_str("|---|---:|---:|---:|\n");
1139
1140 for row in rows {
1141 let _ = writeln!(
1142 s,
1143 "|{}|{}|{}|{}|",
1144 row.lang,
1145 row.old_code,
1146 row.new_code,
1147 format_delta_colored(row.delta_code, options.color)
1148 );
1149 }
1150
1151 let _ = writeln!(
1152 s,
1153 "|**Total**|{}|{}|{}|",
1154 totals.old_code,
1155 totals.new_code,
1156 format_delta_colored(totals.delta_code, options.color)
1157 );
1158
1159 s
1160}
1161
1162pub fn render_diff_md(
1164 from_source: &str,
1165 to_source: &str,
1166 rows: &[DiffRow],
1167 totals: &DiffTotals,
1168) -> String {
1169 render_diff_md_with_options(
1170 from_source,
1171 to_source,
1172 rows,
1173 totals,
1174 DiffRenderOptions::default(),
1175 )
1176}
1177
1178pub fn create_diff_receipt(
1180 from_source: &str,
1181 to_source: &str,
1182 rows: Vec<DiffRow>,
1183 totals: DiffTotals,
1184) -> DiffReceipt {
1185 DiffReceipt {
1186 schema_version: tokmd_types::SCHEMA_VERSION,
1187 generated_at_ms: now_ms(),
1188 tool: ToolInfo::current(),
1189 mode: "diff".to_string(),
1190 from_source: from_source.to_string(),
1191 to_source: to_source.to_string(),
1192 diff_rows: rows,
1193 totals,
1194 }
1195}
1196
1197#[doc(hidden)]
1203pub fn write_export_csv_to<W: Write>(
1204 out: &mut W,
1205 export: &ExportData,
1206 args: &ExportArgs,
1207) -> Result<()> {
1208 write_export_csv(out, export, args)
1209}
1210
1211#[doc(hidden)]
1213pub fn write_export_jsonl_to<W: Write>(
1214 out: &mut W,
1215 export: &ExportData,
1216 global: &ScanOptions,
1217 args: &ExportArgs,
1218) -> Result<()> {
1219 write_export_jsonl(out, export, global, args)
1220}
1221
1222#[doc(hidden)]
1224pub fn write_export_json_to<W: Write>(
1225 out: &mut W,
1226 export: &ExportData,
1227 global: &ScanOptions,
1228 args: &ExportArgs,
1229) -> Result<()> {
1230 write_export_json(out, export, global, args)
1231}
1232
1233#[doc(hidden)]
1235pub fn write_export_cyclonedx_to<W: Write>(
1236 out: &mut W,
1237 export: &ExportData,
1238 redact: RedactMode,
1239) -> Result<()> {
1240 write_export_cyclonedx(out, export, redact)
1241}
1242
1243#[doc(hidden)]
1245pub fn write_export_cyclonedx_with_options<W: Write>(
1246 out: &mut W,
1247 export: &ExportData,
1248 redact: RedactMode,
1249 serial_number: Option<String>,
1250 timestamp: Option<String>,
1251) -> Result<()> {
1252 write_export_cyclonedx_impl(out, export, redact, serial_number, timestamp)
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258 use proptest::prelude::*;
1259 use tokmd_settings::ChildrenMode;
1260 use tokmd_types::{LangRow, ModuleRow, Totals};
1261
1262 fn sample_lang_report(with_files: bool) -> LangReport {
1263 LangReport {
1264 rows: vec![
1265 LangRow {
1266 lang: "Rust".to_string(),
1267 code: 1000,
1268 lines: 1200,
1269 files: 10,
1270 bytes: 50000,
1271 tokens: 2500,
1272 avg_lines: 120,
1273 },
1274 LangRow {
1275 lang: "TOML".to_string(),
1276 code: 50,
1277 lines: 60,
1278 files: 2,
1279 bytes: 1000,
1280 tokens: 125,
1281 avg_lines: 30,
1282 },
1283 ],
1284 total: Totals {
1285 code: 1050,
1286 lines: 1260,
1287 files: 12,
1288 bytes: 51000,
1289 tokens: 2625,
1290 avg_lines: 105,
1291 },
1292 with_files,
1293 children: ChildrenMode::Collapse,
1294 top: 0,
1295 }
1296 }
1297
1298 fn sample_module_report() -> ModuleReport {
1299 ModuleReport {
1300 rows: vec![
1301 ModuleRow {
1302 module: "crates/foo".to_string(),
1303 code: 800,
1304 lines: 950,
1305 files: 8,
1306 bytes: 40000,
1307 tokens: 2000,
1308 avg_lines: 119,
1309 },
1310 ModuleRow {
1311 module: "crates/bar".to_string(),
1312 code: 200,
1313 lines: 250,
1314 files: 2,
1315 bytes: 10000,
1316 tokens: 500,
1317 avg_lines: 125,
1318 },
1319 ],
1320 total: Totals {
1321 code: 1000,
1322 lines: 1200,
1323 files: 10,
1324 bytes: 50000,
1325 tokens: 2500,
1326 avg_lines: 120,
1327 },
1328 module_roots: vec!["crates".to_string()],
1329 module_depth: 2,
1330 children: tokmd_settings::ChildIncludeMode::Separate,
1331 top: 0,
1332 }
1333 }
1334
1335 fn sample_file_rows() -> Vec<FileRow> {
1336 vec![
1337 FileRow {
1338 path: "src/lib.rs".to_string(),
1339 module: "src".to_string(),
1340 lang: "Rust".to_string(),
1341 kind: FileKind::Parent,
1342 code: 100,
1343 comments: 20,
1344 blanks: 10,
1345 lines: 130,
1346 bytes: 1000,
1347 tokens: 250,
1348 },
1349 FileRow {
1350 path: "tests/test.rs".to_string(),
1351 module: "tests".to_string(),
1352 lang: "Rust".to_string(),
1353 kind: FileKind::Parent,
1354 code: 50,
1355 comments: 5,
1356 blanks: 5,
1357 lines: 60,
1358 bytes: 500,
1359 tokens: 125,
1360 },
1361 ]
1362 }
1363
1364 #[test]
1369 fn render_lang_md_without_files() {
1370 let report = sample_lang_report(false);
1371 let output = render_lang_md(&report);
1372
1373 assert!(output.contains("|Lang|Code|Lines|Bytes|Tokens|"));
1375 assert!(!output.contains("|Files|"));
1377 assert!(!output.contains("|Avg|"));
1378 assert!(output.contains("|Rust|1000|1200|50000|2500|"));
1380 assert!(output.contains("|TOML|50|60|1000|125|"));
1381 assert!(output.contains("|**Total**|1050|1260|51000|2625|"));
1383 }
1384
1385 #[test]
1386 fn render_lang_md_with_files() {
1387 let report = sample_lang_report(true);
1388 let output = render_lang_md(&report);
1389
1390 assert!(output.contains("|Lang|Code|Lines|Files|Bytes|Tokens|Avg|"));
1392 assert!(output.contains("|Rust|1000|1200|10|50000|2500|120|"));
1394 assert!(output.contains("|TOML|50|60|2|1000|125|30|"));
1395 assert!(output.contains("|**Total**|1050|1260|12|51000|2625|105|"));
1397 }
1398
1399 #[test]
1400 fn render_lang_md_table_structure() {
1401 let report = sample_lang_report(true);
1402 let output = render_lang_md(&report);
1403
1404 let lines: Vec<&str> = output.lines().collect();
1406 assert!(lines.len() >= 4); assert!(lines[1].contains("|---|"));
1410 assert!(lines[1].contains(":")); }
1412
1413 #[test]
1418 fn render_lang_tsv_without_files() {
1419 let report = sample_lang_report(false);
1420 let output = render_lang_tsv(&report);
1421
1422 assert!(output.starts_with("Lang\tCode\tLines\tBytes\tTokens\n"));
1424 assert!(!output.contains("\tFiles\t"));
1426 assert!(!output.contains("\tAvg"));
1427 assert!(output.contains("Rust\t1000\t1200\t50000\t2500"));
1429 assert!(output.contains("TOML\t50\t60\t1000\t125"));
1430 assert!(output.contains("Total\t1050\t1260\t51000\t2625"));
1432 }
1433
1434 #[test]
1435 fn render_lang_tsv_with_files() {
1436 let report = sample_lang_report(true);
1437 let output = render_lang_tsv(&report);
1438
1439 assert!(output.starts_with("Lang\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n"));
1441 assert!(output.contains("Rust\t1000\t1200\t10\t50000\t2500\t120"));
1443 assert!(output.contains("TOML\t50\t60\t2\t1000\t125\t30"));
1444 }
1445
1446 #[test]
1447 fn render_lang_tsv_tab_separated() {
1448 let report = sample_lang_report(false);
1449 let output = render_lang_tsv(&report);
1450
1451 for line in output.lines().skip(1) {
1453 if line.starts_with("Total") || line.starts_with("Rust") || line.starts_with("TOML") {
1455 assert_eq!(line.matches('\t').count(), 4);
1456 }
1457 }
1458 }
1459
1460 #[test]
1465 fn render_module_md_structure() {
1466 let report = sample_module_report();
1467 let output = render_module_md(&report);
1468
1469 assert!(output.contains("|Module|Code|Lines|Files|Bytes|Tokens|Avg|"));
1471 assert!(output.contains("|crates/foo|800|950|8|40000|2000|119|"));
1473 assert!(output.contains("|crates/bar|200|250|2|10000|500|125|"));
1474 assert!(output.contains("|**Total**|1000|1200|10|50000|2500|120|"));
1476 }
1477
1478 #[test]
1479 fn render_module_md_table_format() {
1480 let report = sample_module_report();
1481 let output = render_module_md(&report);
1482
1483 let lines: Vec<&str> = output.lines().collect();
1484 assert_eq!(lines.len(), 5);
1486 assert!(lines[1].contains("---:"));
1488 }
1489
1490 #[test]
1495 fn render_module_tsv_structure() {
1496 let report = sample_module_report();
1497 let output = render_module_tsv(&report);
1498
1499 assert!(output.starts_with("Module\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n"));
1501 assert!(output.contains("crates/foo\t800\t950\t8\t40000\t2000\t119"));
1503 assert!(output.contains("crates/bar\t200\t250\t2\t10000\t500\t125"));
1504 assert!(output.contains("Total\t1000\t1200\t10\t50000\t2500\t120"));
1506 }
1507
1508 #[test]
1509 fn render_module_tsv_tab_count() {
1510 let report = sample_module_report();
1511 let output = render_module_tsv(&report);
1512
1513 for line in output.lines() {
1515 assert_eq!(line.matches('\t').count(), 6);
1516 }
1517 }
1518
1519 #[test]
1524 fn redact_rows_none_mode() {
1525 let rows = sample_file_rows();
1526 let redacted: Vec<_> = redact_rows(&rows, RedactMode::None).collect();
1527
1528 assert_eq!(redacted.len(), rows.len());
1530 assert_eq!(redacted[0].path, "src/lib.rs");
1531 assert_eq!(redacted[0].module, "src");
1532 }
1533
1534 #[test]
1535 fn redact_rows_paths_mode() {
1536 let rows = sample_file_rows();
1537 let redacted: Vec<_> = redact_rows(&rows, RedactMode::Paths).collect();
1538
1539 assert_ne!(redacted[0].path, "src/lib.rs");
1541 assert!(redacted[0].path.ends_with(".rs"));
1542 assert_eq!(redacted[0].path.len(), 16 + 3); assert_eq!(redacted[0].module, "src");
1546 }
1547
1548 #[test]
1549 fn redact_rows_all_mode() {
1550 let rows = sample_file_rows();
1551 let redacted: Vec<_> = redact_rows(&rows, RedactMode::All).collect();
1552
1553 assert_ne!(redacted[0].path, "src/lib.rs");
1555 assert!(redacted[0].path.ends_with(".rs"));
1556
1557 assert_ne!(redacted[0].module, "src");
1559 assert_eq!(redacted[0].module.len(), 16);
1560 }
1561
1562 #[test]
1563 fn redact_rows_preserves_other_fields() {
1564 let rows = sample_file_rows();
1565 let redacted: Vec<_> = redact_rows(&rows, RedactMode::All).collect();
1566
1567 assert_eq!(redacted[0].lang, "Rust");
1569 assert_eq!(redacted[0].kind, FileKind::Parent);
1570 assert_eq!(redacted[0].code, 100);
1571 assert_eq!(redacted[0].comments, 20);
1572 assert_eq!(redacted[0].blanks, 10);
1573 assert_eq!(redacted[0].lines, 130);
1574 assert_eq!(redacted[0].bytes, 1000);
1575 assert_eq!(redacted[0].tokens, 250);
1576 }
1577
1578 #[test]
1583 fn normalize_scan_input_forward_slash() {
1584 let p = Path::new("src/lib.rs");
1585 let normalized = normalize_scan_input(p);
1586 assert_eq!(normalized, "src/lib.rs");
1587 }
1588
1589 #[test]
1590 fn normalize_scan_input_backslash_to_forward() {
1591 let p = Path::new("src\\lib.rs");
1592 let normalized = normalize_scan_input(p);
1593 assert_eq!(normalized, "src/lib.rs");
1594 }
1595
1596 #[test]
1597 fn normalize_scan_input_strips_dot_slash() {
1598 let p = Path::new("./src/lib.rs");
1599 let normalized = normalize_scan_input(p);
1600 assert_eq!(normalized, "src/lib.rs");
1601 }
1602
1603 #[test]
1604 fn normalize_scan_input_current_dir() {
1605 let p = Path::new(".");
1606 let normalized = normalize_scan_input(p);
1607 assert_eq!(normalized, ".");
1608 }
1609
1610 proptest! {
1615 #[test]
1616 fn normalize_scan_input_no_backslash(s in "[a-zA-Z0-9_/\\\\.]+") {
1617 let p = Path::new(&s);
1618 let normalized = normalize_scan_input(p);
1619 prop_assert!(!normalized.contains('\\'), "Should not contain backslash: {}", normalized);
1620 }
1621
1622 #[test]
1623 fn normalize_scan_input_no_leading_dot_slash(s in "[a-zA-Z0-9_/\\\\.]+") {
1624 let p = Path::new(&s);
1625 let normalized = normalize_scan_input(p);
1626 prop_assert!(!normalized.starts_with("./"), "Should not start with ./: {}", normalized);
1627 }
1628
1629 #[test]
1630 fn redact_rows_preserves_count(
1631 code in 0usize..10000,
1632 comments in 0usize..1000,
1633 blanks in 0usize..500
1634 ) {
1635 let rows = vec![FileRow {
1636 path: "test/file.rs".to_string(),
1637 module: "test".to_string(),
1638 lang: "Rust".to_string(),
1639 kind: FileKind::Parent,
1640 code,
1641 comments,
1642 blanks,
1643 lines: code + comments + blanks,
1644 bytes: 1000,
1645 tokens: 250,
1646 }];
1647
1648 for mode in [RedactMode::None, RedactMode::Paths, RedactMode::All] {
1649 let redacted: Vec<_> = redact_rows(&rows, mode).collect();
1650 prop_assert_eq!(redacted.len(), 1);
1651 prop_assert_eq!(redacted[0].code, code);
1652 prop_assert_eq!(redacted[0].comments, comments);
1653 prop_assert_eq!(redacted[0].blanks, blanks);
1654 }
1655 }
1656
1657 #[test]
1658 fn redact_rows_paths_end_with_extension(ext in "[a-z]{1,4}") {
1659 let path = format!("some/path/file.{}", ext);
1660 let rows = vec![FileRow {
1661 path: path.clone(),
1662 module: "some".to_string(),
1663 lang: "Test".to_string(),
1664 kind: FileKind::Parent,
1665 code: 100,
1666 comments: 10,
1667 blanks: 5,
1668 lines: 115,
1669 bytes: 1000,
1670 tokens: 250,
1671 }];
1672
1673 let redacted: Vec<_> = redact_rows(&rows, RedactMode::Paths).collect();
1674 prop_assert!(redacted[0].path.ends_with(&format!(".{}", ext)),
1675 "Redacted path '{}' should end with .{}", redacted[0].path, ext);
1676 }
1677 }
1678
1679 #[test]
1684 fn snapshot_lang_md_with_files() {
1685 let report = sample_lang_report(true);
1686 let output = render_lang_md(&report);
1687 insta::assert_snapshot!(output);
1688 }
1689
1690 #[test]
1691 fn snapshot_lang_md_without_files() {
1692 let report = sample_lang_report(false);
1693 let output = render_lang_md(&report);
1694 insta::assert_snapshot!(output);
1695 }
1696
1697 #[test]
1698 fn snapshot_lang_tsv_with_files() {
1699 let report = sample_lang_report(true);
1700 let output = render_lang_tsv(&report);
1701 insta::assert_snapshot!(output);
1702 }
1703
1704 #[test]
1705 fn snapshot_module_md() {
1706 let report = sample_module_report();
1707 let output = render_module_md(&report);
1708 insta::assert_snapshot!(output);
1709 }
1710
1711 #[test]
1712 fn snapshot_module_tsv() {
1713 let report = sample_module_report();
1714 let output = render_module_tsv(&report);
1715 insta::assert_snapshot!(output);
1716 }
1717
1718 #[test]
1723 fn test_render_diff_md_smoke() {
1724 let from = LangReport {
1726 rows: vec![LangRow {
1727 lang: "Rust".to_string(),
1728 code: 10,
1729 lines: 10,
1730 files: 1,
1731 bytes: 100,
1732 tokens: 20,
1733 avg_lines: 10,
1734 }],
1735 total: Totals {
1736 code: 10,
1737 lines: 10,
1738 files: 1,
1739 bytes: 100,
1740 tokens: 20,
1741 avg_lines: 10,
1742 },
1743 with_files: false,
1744 children: ChildrenMode::Collapse,
1745 top: 0,
1746 };
1747
1748 let to = LangReport {
1749 rows: vec![LangRow {
1750 lang: "Rust".to_string(),
1751 code: 12,
1752 lines: 12,
1753 files: 1,
1754 bytes: 120,
1755 tokens: 24,
1756 avg_lines: 12,
1757 }],
1758 total: Totals {
1759 code: 12,
1760 lines: 12,
1761 files: 1,
1762 bytes: 120,
1763 tokens: 24,
1764 avg_lines: 12,
1765 },
1766 with_files: false,
1767 children: ChildrenMode::Collapse,
1768 top: 0,
1769 };
1770
1771 let rows = compute_diff_rows(&from, &to);
1772 assert_eq!(rows.len(), 1);
1773 assert_eq!(rows[0].lang, "Rust");
1774 assert_eq!(rows[0].delta_code, 2);
1775
1776 let totals = compute_diff_totals(&rows);
1777 assert_eq!(totals.delta_code, 2);
1778
1779 let md = render_diff_md("from", "to", &rows, &totals);
1780
1781 assert!(!md.trim().is_empty(), "diff markdown must not be empty");
1782 assert!(md.contains("from"));
1783 assert!(md.contains("to"));
1784 assert!(md.contains("Rust"));
1785 assert!(md.contains("|LOC|"));
1786 assert!(md.contains("|Lines|"));
1787 assert!(md.contains("|Files|"));
1788 assert!(md.contains("|Bytes|"));
1789 assert!(md.contains("|Tokens|"));
1790 assert!(md.contains("### Language Movement"));
1791 }
1792
1793 #[test]
1794 fn test_render_diff_md_compact_includes_movement_counts() {
1795 let from = LangReport {
1796 rows: vec![LangRow {
1797 lang: "Rust".to_string(),
1798 code: 10,
1799 lines: 10,
1800 files: 1,
1801 bytes: 100,
1802 tokens: 20,
1803 avg_lines: 10,
1804 }],
1805 total: Totals {
1806 code: 10,
1807 lines: 10,
1808 files: 1,
1809 bytes: 100,
1810 tokens: 20,
1811 avg_lines: 10,
1812 },
1813 with_files: false,
1814 children: ChildrenMode::Collapse,
1815 top: 0,
1816 };
1817 let to = LangReport {
1818 rows: vec![
1819 LangRow {
1820 lang: "Rust".to_string(),
1821 code: 12,
1822 lines: 12,
1823 files: 1,
1824 bytes: 120,
1825 tokens: 24,
1826 avg_lines: 12,
1827 },
1828 LangRow {
1829 lang: "Python".to_string(),
1830 code: 8,
1831 lines: 8,
1832 files: 1,
1833 bytes: 80,
1834 tokens: 16,
1835 avg_lines: 8,
1836 },
1837 ],
1838 total: Totals {
1839 code: 20,
1840 lines: 20,
1841 files: 2,
1842 bytes: 200,
1843 tokens: 40,
1844 avg_lines: 10,
1845 },
1846 with_files: false,
1847 children: ChildrenMode::Collapse,
1848 top: 0,
1849 };
1850 let rows = compute_diff_rows(&from, &to);
1851 let totals = compute_diff_totals(&rows);
1852 let md = render_diff_md_with_options(
1853 "from",
1854 "to",
1855 &rows,
1856 &totals,
1857 DiffRenderOptions {
1858 compact: true,
1859 color: DiffColorMode::Off,
1860 },
1861 );
1862
1863 assert!(md.contains("|Delta Lines|"));
1864 assert!(md.contains("|Delta Files|"));
1865 assert!(md.contains("|Delta Bytes|"));
1866 assert!(md.contains("|Delta Tokens|"));
1867 assert!(md.contains("|Languages added|1|"));
1868 assert!(md.contains("|Languages modified|1|"));
1869 }
1870
1871 #[test]
1872 fn test_compute_diff_rows_language_added() {
1873 let from = LangReport {
1875 rows: vec![],
1876 total: Totals {
1877 code: 0,
1878 lines: 0,
1879 files: 0,
1880 bytes: 0,
1881 tokens: 0,
1882 avg_lines: 0,
1883 },
1884 with_files: false,
1885 children: ChildrenMode::Collapse,
1886 top: 0,
1887 };
1888
1889 let to = LangReport {
1890 rows: vec![LangRow {
1891 lang: "Python".to_string(),
1892 code: 100,
1893 lines: 120,
1894 files: 5,
1895 bytes: 5000,
1896 tokens: 250,
1897 avg_lines: 24,
1898 }],
1899 total: Totals {
1900 code: 100,
1901 lines: 120,
1902 files: 5,
1903 bytes: 5000,
1904 tokens: 250,
1905 avg_lines: 24,
1906 },
1907 with_files: false,
1908 children: ChildrenMode::Collapse,
1909 top: 0,
1910 };
1911
1912 let rows = compute_diff_rows(&from, &to);
1913 assert_eq!(rows.len(), 1);
1914 assert_eq!(rows[0].lang, "Python");
1915 assert_eq!(rows[0].old_code, 0);
1916 assert_eq!(rows[0].new_code, 100);
1917 assert_eq!(rows[0].delta_code, 100);
1918 }
1919
1920 #[test]
1921 fn test_compute_diff_rows_language_removed() {
1922 let from = LangReport {
1924 rows: vec![LangRow {
1925 lang: "Go".to_string(),
1926 code: 50,
1927 lines: 60,
1928 files: 2,
1929 bytes: 2000,
1930 tokens: 125,
1931 avg_lines: 30,
1932 }],
1933 total: Totals {
1934 code: 50,
1935 lines: 60,
1936 files: 2,
1937 bytes: 2000,
1938 tokens: 125,
1939 avg_lines: 30,
1940 },
1941 with_files: false,
1942 children: ChildrenMode::Collapse,
1943 top: 0,
1944 };
1945
1946 let to = LangReport {
1947 rows: vec![],
1948 total: Totals {
1949 code: 0,
1950 lines: 0,
1951 files: 0,
1952 bytes: 0,
1953 tokens: 0,
1954 avg_lines: 0,
1955 },
1956 with_files: false,
1957 children: ChildrenMode::Collapse,
1958 top: 0,
1959 };
1960
1961 let rows = compute_diff_rows(&from, &to);
1962 assert_eq!(rows.len(), 1);
1963 assert_eq!(rows[0].lang, "Go");
1964 assert_eq!(rows[0].old_code, 50);
1965 assert_eq!(rows[0].new_code, 0);
1966 assert_eq!(rows[0].delta_code, -50);
1967 }
1968
1969 #[test]
1970 fn test_compute_diff_rows_unchanged_excluded() {
1971 let report = LangReport {
1973 rows: vec![LangRow {
1974 lang: "Rust".to_string(),
1975 code: 100,
1976 lines: 100,
1977 files: 1,
1978 bytes: 1000,
1979 tokens: 250,
1980 avg_lines: 100,
1981 }],
1982 total: Totals {
1983 code: 100,
1984 lines: 100,
1985 files: 1,
1986 bytes: 1000,
1987 tokens: 250,
1988 avg_lines: 100,
1989 },
1990 with_files: false,
1991 children: ChildrenMode::Collapse,
1992 top: 0,
1993 };
1994
1995 let rows = compute_diff_rows(&report, &report);
1996 assert!(rows.is_empty(), "unchanged languages should be excluded");
1997 }
1998
1999 #[test]
2000 fn test_format_delta() {
2001 assert_eq!(format_delta(5), "+5");
2003 assert_eq!(format_delta(0), "0");
2004 assert_eq!(format_delta(-3), "-3");
2005 }
2006
2007 fn sample_global_args() -> ScanOptions {
2012 ScanOptions::default()
2013 }
2014
2015 fn sample_lang_args(format: TableFormat) -> LangArgs {
2016 LangArgs {
2017 paths: vec![PathBuf::from(".")],
2018 format,
2019 top: 0,
2020 files: false,
2021 children: ChildrenMode::Collapse,
2022 }
2023 }
2024
2025 fn sample_module_args(format: TableFormat) -> ModuleArgs {
2026 ModuleArgs {
2027 paths: vec![PathBuf::from(".")],
2028 format,
2029 top: 0,
2030 module_roots: vec!["crates".to_string()],
2031 module_depth: 2,
2032 children: tokmd_settings::ChildIncludeMode::Separate,
2033 }
2034 }
2035
2036 #[test]
2037 fn write_lang_report_to_md_writes_content() {
2038 let report = sample_lang_report(true);
2039 let global = sample_global_args();
2040 let args = sample_lang_args(TableFormat::Md);
2041 let mut buf = Vec::new();
2042
2043 write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
2044 let output = String::from_utf8(buf).unwrap();
2045
2046 assert!(!output.is_empty(), "output must not be empty");
2047 assert!(output.contains("|Lang|"), "must contain markdown header");
2048 assert!(output.contains("|Rust|"), "must contain Rust row");
2049 assert!(output.contains("|**Total**|"), "must contain total row");
2050 }
2051
2052 #[test]
2053 fn write_lang_report_to_tsv_writes_content() {
2054 let report = sample_lang_report(false);
2055 let global = sample_global_args();
2056 let args = sample_lang_args(TableFormat::Tsv);
2057 let mut buf = Vec::new();
2058
2059 write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
2060 let output = String::from_utf8(buf).unwrap();
2061
2062 assert!(!output.is_empty(), "output must not be empty");
2063 assert!(output.contains("Lang\t"), "must contain TSV header");
2064 assert!(output.contains("Rust\t"), "must contain Rust row");
2065 assert!(output.contains("Total\t"), "must contain total row");
2066 }
2067
2068 #[test]
2069 fn write_lang_report_to_json_writes_receipt() {
2070 let report = sample_lang_report(true);
2071 let global = sample_global_args();
2072 let args = sample_lang_args(TableFormat::Json);
2073 let mut buf = Vec::new();
2074
2075 write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
2076 let output = String::from_utf8(buf).unwrap();
2077
2078 assert!(!output.is_empty(), "output must not be empty");
2079 let receipt: LangReceipt = serde_json::from_str(&output).unwrap();
2081 assert_eq!(receipt.mode, "lang");
2082 assert_eq!(receipt.report.rows.len(), 2);
2083 assert_eq!(receipt.report.total.code, 1050);
2084 }
2085
2086 #[test]
2087 fn write_module_report_to_md_writes_content() {
2088 let report = sample_module_report();
2089 let global = sample_global_args();
2090 let args = sample_module_args(TableFormat::Md);
2091 let mut buf = Vec::new();
2092
2093 write_module_report_to(&mut buf, &report, &global, &args).unwrap();
2094 let output = String::from_utf8(buf).unwrap();
2095
2096 assert!(!output.is_empty(), "output must not be empty");
2097 assert!(output.contains("|Module|"), "must contain markdown header");
2098 assert!(output.contains("|crates/foo|"), "must contain module row");
2099 assert!(output.contains("|**Total**|"), "must contain total row");
2100 }
2101
2102 #[test]
2103 fn write_module_report_to_tsv_writes_content() {
2104 let report = sample_module_report();
2105 let global = sample_global_args();
2106 let args = sample_module_args(TableFormat::Tsv);
2107 let mut buf = Vec::new();
2108
2109 write_module_report_to(&mut buf, &report, &global, &args).unwrap();
2110 let output = String::from_utf8(buf).unwrap();
2111
2112 assert!(!output.is_empty(), "output must not be empty");
2113 assert!(output.contains("Module\t"), "must contain TSV header");
2114 assert!(output.contains("crates/foo\t"), "must contain module row");
2115 assert!(output.contains("Total\t"), "must contain total row");
2116 }
2117
2118 #[test]
2119 fn write_module_report_to_json_writes_receipt() {
2120 let report = sample_module_report();
2121 let global = sample_global_args();
2122 let args = sample_module_args(TableFormat::Json);
2123 let mut buf = Vec::new();
2124
2125 write_module_report_to(&mut buf, &report, &global, &args).unwrap();
2126 let output = String::from_utf8(buf).unwrap();
2127
2128 assert!(!output.is_empty(), "output must not be empty");
2129 let receipt: ModuleReceipt = serde_json::from_str(&output).unwrap();
2131 assert_eq!(receipt.mode, "module");
2132 assert_eq!(receipt.report.rows.len(), 2);
2133 assert_eq!(receipt.report.total.code, 1000);
2134 }
2135}
2136
2137#[cfg(doctest)]
2138#[doc = include_str!("../README.md")]
2139pub mod readme_doctests {}