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::path::PathBuf;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use anyhow::Result;
29use serde::Serialize;
30use time::OffsetDateTime;
31use time::format_description::well_known::Rfc3339;
32
33use tokmd_config::GlobalArgs;
34use tokmd_types::{
35 ExportArgs, ExportArgsMeta, ExportData, ExportFormat, ExportReceipt, FileKind, FileRow,
36 LangArgs, LangArgsMeta, LangReceipt, LangReport, ModuleArgs, ModuleArgsMeta, ModuleReceipt,
37 ModuleReport, RedactMode, ScanArgs, ScanStatus, TableFormat, ToolInfo,
38};
39
40fn now_ms() -> u128 {
41 SystemTime::now()
42 .duration_since(UNIX_EPOCH)
43 .unwrap_or_default()
44 .as_millis()
45}
46
47pub fn normalize_scan_input(p: &Path) -> String {
52 let mut s = p.display().to_string().replace('\\', "/");
53 while s.starts_with("./") {
54 s = s.strip_prefix("./").unwrap().to_string();
55 }
56 if s.is_empty() { ".".to_string() } else { s }
57}
58
59pub fn scan_args(paths: &[PathBuf], global: &GlobalArgs, redact: Option<RedactMode>) -> ScanArgs {
71 let should_redact = redact == Some(RedactMode::Paths) || redact == Some(RedactMode::All);
72 let excluded_redacted = should_redact && !global.excluded.is_empty();
73
74 let mut args = ScanArgs {
75 paths: paths.iter().map(|p| normalize_scan_input(p)).collect(),
76 excluded: if should_redact {
77 global.excluded.iter().map(|p| short_hash(p)).collect()
78 } else {
79 global.excluded.clone()
80 },
81 excluded_redacted,
82 config: global.config,
83 hidden: global.hidden,
84 no_ignore: global.no_ignore,
85 no_ignore_parent: global.no_ignore || global.no_ignore_parent,
86 no_ignore_dot: global.no_ignore || global.no_ignore_dot,
87 no_ignore_vcs: global.no_ignore || global.no_ignore_vcs,
88 treat_doc_strings_as_comments: global.treat_doc_strings_as_comments,
89 };
90
91 if should_redact {
92 args.paths = args.paths.iter().map(|p| redact_path(p)).collect();
93 }
94 args
95}
96
97pub fn write_lang_report_to<W: Write>(
105 mut out: W,
106 report: &LangReport,
107 global: &GlobalArgs,
108 args: &LangArgs,
109) -> Result<()> {
110 match args.format {
111 TableFormat::Md => {
112 out.write_all(render_lang_md(report).as_bytes())?;
113 }
114 TableFormat::Tsv => {
115 out.write_all(render_lang_tsv(report).as_bytes())?;
116 }
117 TableFormat::Json => {
118 let receipt = LangReceipt {
119 schema_version: tokmd_types::SCHEMA_VERSION,
120 generated_at_ms: now_ms(),
121 tool: ToolInfo::current(),
122 mode: "lang".to_string(),
123 status: ScanStatus::Complete,
124 warnings: vec![],
125 scan: scan_args(&args.paths, global, None),
126 args: LangArgsMeta {
127 format: "json".to_string(),
128 top: report.top,
129 with_files: report.with_files,
130 children: report.children,
131 },
132 report: report.clone(),
133 };
134 writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
135 }
136 }
137 Ok(())
138}
139
140pub fn print_lang_report(report: &LangReport, global: &GlobalArgs, args: &LangArgs) -> Result<()> {
144 let stdout = io::stdout();
145 let out = stdout.lock();
146 write_lang_report_to(out, report, global, args)
147}
148
149fn render_lang_md(report: &LangReport) -> String {
150 let mut s = String::new();
151
152 if report.with_files {
153 s.push_str("|Lang|Code|Lines|Files|Bytes|Tokens|Avg|\n");
154 s.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
155 for r in &report.rows {
156 let _ = writeln!(
157 s,
158 "|{}|{}|{}|{}|{}|{}|{}|",
159 r.lang, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
160 );
161 }
162 let _ = writeln!(
163 s,
164 "|**Total**|{}|{}|{}|{}|{}|{}|",
165 report.total.code,
166 report.total.lines,
167 report.total.files,
168 report.total.bytes,
169 report.total.tokens,
170 report.total.avg_lines
171 );
172 } else {
173 s.push_str("|Lang|Code|Lines|Bytes|Tokens|\n");
174 s.push_str("|---|---:|---:|---:|---:|\n");
175 for r in &report.rows {
176 let _ = writeln!(
177 s,
178 "|{}|{}|{}|{}|{}|",
179 r.lang, r.code, r.lines, r.bytes, r.tokens
180 );
181 }
182 let _ = writeln!(
183 s,
184 "|**Total**|{}|{}|{}|{}|",
185 report.total.code, report.total.lines, report.total.bytes, report.total.tokens
186 );
187 }
188
189 s
190}
191
192fn render_lang_tsv(report: &LangReport) -> String {
193 let mut s = String::new();
194
195 if report.with_files {
196 s.push_str("Lang\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n");
197 for r in &report.rows {
198 let _ = writeln!(
199 s,
200 "{}\t{}\t{}\t{}\t{}\t{}\t{}",
201 r.lang, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
202 );
203 }
204 let _ = writeln!(
205 s,
206 "Total\t{}\t{}\t{}\t{}\t{}\t{}",
207 report.total.code,
208 report.total.lines,
209 report.total.files,
210 report.total.bytes,
211 report.total.tokens,
212 report.total.avg_lines
213 );
214 } else {
215 s.push_str("Lang\tCode\tLines\tBytes\tTokens\n");
216 for r in &report.rows {
217 let _ = writeln!(
218 s,
219 "{}\t{}\t{}\t{}\t{}",
220 r.lang, r.code, r.lines, r.bytes, r.tokens
221 );
222 }
223 let _ = writeln!(
224 s,
225 "Total\t{}\t{}\t{}\t{}",
226 report.total.code, report.total.lines, report.total.bytes, report.total.tokens
227 );
228 }
229
230 s
231}
232
233pub fn write_module_report_to<W: Write>(
241 mut out: W,
242 report: &ModuleReport,
243 global: &GlobalArgs,
244 args: &ModuleArgs,
245) -> Result<()> {
246 match args.format {
247 TableFormat::Md => {
248 out.write_all(render_module_md(report).as_bytes())?;
249 }
250 TableFormat::Tsv => {
251 out.write_all(render_module_tsv(report).as_bytes())?;
252 }
253 TableFormat::Json => {
254 let receipt = ModuleReceipt {
255 schema_version: tokmd_types::SCHEMA_VERSION,
256 generated_at_ms: now_ms(),
257 tool: ToolInfo::current(),
258 mode: "module".to_string(),
259 status: ScanStatus::Complete,
260 warnings: vec![],
261 scan: scan_args(&args.paths, global, None),
262 args: ModuleArgsMeta {
263 format: "json".to_string(),
264 top: report.top,
265 module_roots: report.module_roots.clone(),
266 module_depth: report.module_depth,
267 children: report.children,
268 },
269 report: report.clone(),
270 };
271 writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
272 }
273 }
274 Ok(())
275}
276
277pub fn print_module_report(
281 report: &ModuleReport,
282 global: &GlobalArgs,
283 args: &ModuleArgs,
284) -> Result<()> {
285 let stdout = io::stdout();
286 let out = stdout.lock();
287 write_module_report_to(out, report, global, args)
288}
289
290fn render_module_md(report: &ModuleReport) -> String {
291 let mut s = String::new();
292 s.push_str("|Module|Code|Lines|Files|Bytes|Tokens|Avg|\n");
293 s.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
294 for r in &report.rows {
295 let _ = writeln!(
296 s,
297 "|{}|{}|{}|{}|{}|{}|{}|",
298 r.module, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
299 );
300 }
301 let _ = writeln!(
302 s,
303 "|**Total**|{}|{}|{}|{}|{}|{}|",
304 report.total.code,
305 report.total.lines,
306 report.total.files,
307 report.total.bytes,
308 report.total.tokens,
309 report.total.avg_lines
310 );
311 s
312}
313
314fn render_module_tsv(report: &ModuleReport) -> String {
315 let mut s = String::new();
316 s.push_str("Module\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n");
317 for r in &report.rows {
318 let _ = writeln!(
319 s,
320 "{}\t{}\t{}\t{}\t{}\t{}\t{}",
321 r.module, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
322 );
323 }
324 let _ = writeln!(
325 s,
326 "Total\t{}\t{}\t{}\t{}\t{}\t{}",
327 report.total.code,
328 report.total.lines,
329 report.total.files,
330 report.total.bytes,
331 report.total.tokens,
332 report.total.avg_lines
333 );
334 s
335}
336
337#[derive(Debug, Clone, Serialize)]
342struct ExportMeta {
343 #[serde(rename = "type")]
344 ty: &'static str,
345 schema_version: u32,
346 generated_at_ms: u128,
347 tool: ToolInfo,
348 mode: String,
349 status: ScanStatus,
350 warnings: Vec<String>,
351 scan: ScanArgs,
352 args: ExportArgsMeta,
353}
354
355#[derive(Debug, Clone, Serialize)]
356struct JsonlRow<'a> {
357 #[serde(rename = "type")]
358 ty: &'static str,
359 #[serde(flatten)]
360 row: &'a FileRow,
361}
362
363pub fn write_export(export: &ExportData, global: &GlobalArgs, args: &ExportArgs) -> Result<()> {
364 match &args.out {
365 Some(path) => {
366 let file = File::create(path)?;
367 let mut out = BufWriter::new(file);
368 write_export_to(&mut out, export, global, args)?;
369 out.flush()?;
370 }
371 None => {
372 let stdout = io::stdout();
373 let mut out = stdout.lock();
374 write_export_to(&mut out, export, global, args)?;
375 out.flush()?;
376 }
377 }
378 Ok(())
379}
380
381fn write_export_to<W: Write>(
382 out: &mut W,
383 export: &ExportData,
384 global: &GlobalArgs,
385 args: &ExportArgs,
386) -> Result<()> {
387 match args.format {
388 ExportFormat::Csv => write_export_csv(out, export, args),
389 ExportFormat::Jsonl => write_export_jsonl(out, export, global, args),
390 ExportFormat::Json => write_export_json(out, export, global, args),
391 ExportFormat::Cyclonedx => write_export_cyclonedx(out, export, args.redact),
392 }
393}
394
395fn write_export_csv<W: Write>(out: &mut W, export: &ExportData, args: &ExportArgs) -> Result<()> {
396 let mut wtr = csv::WriterBuilder::new().has_headers(true).from_writer(out);
397 wtr.write_record([
398 "path", "module", "lang", "kind", "code", "comments", "blanks", "lines", "bytes", "tokens",
399 ])?;
400
401 for r in redact_rows(&export.rows, args.redact) {
402 let code = r.code.to_string();
403 let comments = r.comments.to_string();
404 let blanks = r.blanks.to_string();
405 let lines = r.lines.to_string();
406 let bytes = r.bytes.to_string();
407 let tokens = r.tokens.to_string();
408 let kind = match r.kind {
409 FileKind::Parent => "parent",
410 FileKind::Child => "child",
411 };
412
413 wtr.write_record([
414 r.path.as_str(),
415 r.module.as_str(),
416 r.lang.as_str(),
417 kind,
418 &code,
419 &comments,
420 &blanks,
421 &lines,
422 &bytes,
423 &tokens,
424 ])?;
425 }
426
427 wtr.flush()?;
428 Ok(())
429}
430
431fn write_export_jsonl<W: Write>(
432 out: &mut W,
433 export: &ExportData,
434 global: &GlobalArgs,
435 args: &ExportArgs,
436) -> Result<()> {
437 if args.meta {
438 let should_redact = args.redact == RedactMode::Paths || args.redact == RedactMode::All;
439 let strip_prefix_redacted = should_redact && args.strip_prefix.is_some();
440
441 let meta = ExportMeta {
442 ty: "meta",
443 schema_version: tokmd_types::SCHEMA_VERSION,
444 generated_at_ms: now_ms(),
445 tool: ToolInfo::current(),
446 mode: "export".to_string(),
447 status: ScanStatus::Complete,
448 warnings: vec![],
449 scan: scan_args(&args.paths, global, Some(args.redact)),
450 args: ExportArgsMeta {
451 format: args.format,
452 module_roots: export.module_roots.clone(),
453 module_depth: export.module_depth,
454 children: export.children,
455 min_code: args.min_code,
456 max_rows: args.max_rows,
457 redact: args.redact,
458 strip_prefix: if should_redact {
459 args.strip_prefix
460 .as_ref()
461 .map(|p| redact_path(&p.display().to_string().replace('\\', "/")))
462 } else {
463 args.strip_prefix
464 .as_ref()
465 .map(|p| p.display().to_string().replace('\\', "/"))
466 },
467 strip_prefix_redacted,
468 },
469 };
470 writeln!(out, "{}", serde_json::to_string(&meta)?)?;
471 }
472
473 for row in redact_rows(&export.rows, args.redact) {
474 let wrapper = JsonlRow {
475 ty: "row",
476 row: &row,
477 };
478 writeln!(out, "{}", serde_json::to_string(&wrapper)?)?;
479 }
480 Ok(())
481}
482
483fn write_export_json<W: Write>(
484 out: &mut W,
485 export: &ExportData,
486 global: &GlobalArgs,
487 args: &ExportArgs,
488) -> Result<()> {
489 if args.meta {
490 let should_redact = args.redact == RedactMode::Paths || args.redact == RedactMode::All;
491 let strip_prefix_redacted = should_redact && args.strip_prefix.is_some();
492
493 let receipt = ExportReceipt {
494 schema_version: tokmd_types::SCHEMA_VERSION,
495 generated_at_ms: now_ms(),
496 tool: ToolInfo::current(),
497 mode: "export".to_string(),
498 status: ScanStatus::Complete,
499 warnings: vec![],
500 scan: scan_args(&args.paths, global, Some(args.redact)),
501 args: ExportArgsMeta {
502 format: args.format,
503 module_roots: export.module_roots.clone(),
504 module_depth: export.module_depth,
505 children: export.children,
506 min_code: args.min_code,
507 max_rows: args.max_rows,
508 redact: args.redact,
509 strip_prefix: if should_redact {
510 args.strip_prefix
511 .as_ref()
512 .map(|p| redact_path(&p.display().to_string().replace('\\', "/")))
513 } else {
514 args.strip_prefix
515 .as_ref()
516 .map(|p| p.display().to_string().replace('\\', "/"))
517 },
518 strip_prefix_redacted,
519 },
520 data: ExportData {
521 rows: redact_rows(&export.rows, args.redact)
522 .map(|c| c.into_owned())
523 .collect(),
524 module_roots: export.module_roots.clone(),
525 module_depth: export.module_depth,
526 children: export.children,
527 },
528 };
529 writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
530 } else {
531 writeln!(
532 out,
533 "{}",
534 serde_json::to_string(&redact_rows(&export.rows, args.redact).collect::<Vec<_>>())?
535 )?;
536 }
537 Ok(())
538}
539
540fn redact_rows(rows: &[FileRow], mode: RedactMode) -> impl Iterator<Item = Cow<'_, FileRow>> {
541 rows.iter().map(move |r| {
542 if mode == RedactMode::None {
543 Cow::Borrowed(r)
544 } else {
545 let mut owned = r.clone();
546 if mode == RedactMode::Paths || mode == RedactMode::All {
547 owned.path = redact_path(&owned.path);
548 }
549 if mode == RedactMode::All {
550 owned.module = short_hash(&owned.module);
551 }
552 Cow::Owned(owned)
553 }
554 })
555}
556
557pub use tokmd_redact::{redact_path, short_hash};
559
560#[derive(Debug, Clone, Serialize)]
565#[serde(rename_all = "camelCase")]
566struct CycloneDxBom {
567 bom_format: &'static str,
568 spec_version: &'static str,
569 serial_number: String,
570 version: u32,
571 metadata: CycloneDxMetadata,
572 components: Vec<CycloneDxComponent>,
573}
574
575#[derive(Debug, Clone, Serialize)]
576struct CycloneDxMetadata {
577 timestamp: String,
578 tools: Vec<CycloneDxTool>,
579}
580
581#[derive(Debug, Clone, Serialize)]
582struct CycloneDxTool {
583 vendor: &'static str,
584 name: &'static str,
585 version: String,
586}
587
588#[derive(Debug, Clone, Serialize)]
589struct CycloneDxComponent {
590 #[serde(rename = "type")]
591 ty: &'static str,
592 name: String,
593 #[serde(skip_serializing_if = "Option::is_none")]
594 group: Option<String>,
595 #[serde(skip_serializing_if = "Vec::is_empty")]
596 properties: Vec<CycloneDxProperty>,
597}
598
599#[derive(Debug, Clone, Serialize)]
600struct CycloneDxProperty {
601 name: String,
602 value: String,
603}
604
605fn write_export_cyclonedx<W: Write>(
606 out: &mut W,
607 export: &ExportData,
608 redact: RedactMode,
609) -> Result<()> {
610 let timestamp = OffsetDateTime::now_utc()
611 .format(&Rfc3339)
612 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
613
614 let components: Vec<CycloneDxComponent> = redact_rows(&export.rows, redact)
616 .map(|row| {
617 let mut properties = vec![
618 CycloneDxProperty {
619 name: "tokmd:lang".to_string(),
620 value: row.lang.clone(),
621 },
622 CycloneDxProperty {
623 name: "tokmd:code".to_string(),
624 value: row.code.to_string(),
625 },
626 CycloneDxProperty {
627 name: "tokmd:comments".to_string(),
628 value: row.comments.to_string(),
629 },
630 CycloneDxProperty {
631 name: "tokmd:blanks".to_string(),
632 value: row.blanks.to_string(),
633 },
634 CycloneDxProperty {
635 name: "tokmd:lines".to_string(),
636 value: row.lines.to_string(),
637 },
638 CycloneDxProperty {
639 name: "tokmd:bytes".to_string(),
640 value: row.bytes.to_string(),
641 },
642 CycloneDxProperty {
643 name: "tokmd:tokens".to_string(),
644 value: row.tokens.to_string(),
645 },
646 ];
647
648 if row.kind == FileKind::Child {
650 properties.push(CycloneDxProperty {
651 name: "tokmd:kind".to_string(),
652 value: "child".to_string(),
653 });
654 }
655
656 CycloneDxComponent {
657 ty: "file",
658 name: row.path.clone(),
659 group: if row.module.is_empty() {
660 None
661 } else {
662 Some(row.module.clone())
663 },
664 properties,
665 }
666 })
667 .collect();
668
669 let bom = CycloneDxBom {
670 bom_format: "CycloneDX",
671 spec_version: "1.6",
672 serial_number: format!("urn:uuid:{}", uuid::Uuid::new_v4()),
673 version: 1,
674 metadata: CycloneDxMetadata {
675 timestamp,
676 tools: vec![CycloneDxTool {
677 vendor: "tokmd",
678 name: "tokmd",
679 version: env!("CARGO_PKG_VERSION").to_string(),
680 }],
681 },
682 components,
683 };
684
685 writeln!(out, "{}", serde_json::to_string_pretty(&bom)?)?;
686 Ok(())
687}
688
689pub fn write_lang_json_to_file(
699 path: &Path,
700 report: &LangReport,
701 scan: &ScanArgs,
702 args_meta: &LangArgsMeta,
703) -> Result<()> {
704 let receipt = LangReceipt {
705 schema_version: tokmd_types::SCHEMA_VERSION,
706 generated_at_ms: now_ms(),
707 tool: ToolInfo::current(),
708 mode: "lang".to_string(),
709 status: ScanStatus::Complete,
710 warnings: vec![],
711 scan: scan.clone(),
712 args: args_meta.clone(),
713 report: report.clone(),
714 };
715 let file = File::create(path)?;
716 serde_json::to_writer(file, &receipt)?;
717 Ok(())
718}
719
720pub fn write_module_json_to_file(
726 path: &Path,
727 report: &ModuleReport,
728 scan: &ScanArgs,
729 args_meta: &ModuleArgsMeta,
730) -> Result<()> {
731 let receipt = ModuleReceipt {
732 schema_version: tokmd_types::SCHEMA_VERSION,
733 generated_at_ms: now_ms(),
734 tool: ToolInfo::current(),
735 mode: "module".to_string(),
736 status: ScanStatus::Complete,
737 warnings: vec![],
738 scan: scan.clone(),
739 args: args_meta.clone(),
740 report: report.clone(),
741 };
742 let file = File::create(path)?;
743 serde_json::to_writer(file, &receipt)?;
744 Ok(())
745}
746
747pub fn write_export_jsonl_to_file(
753 path: &Path,
754 export: &ExportData,
755 scan: &ScanArgs,
756 args_meta: &ExportArgsMeta,
757) -> Result<()> {
758 let file = File::create(path)?;
759 let mut out = BufWriter::new(file);
760
761 let meta = ExportMeta {
762 ty: "meta",
763 schema_version: tokmd_types::SCHEMA_VERSION,
764 generated_at_ms: now_ms(),
765 tool: ToolInfo::current(),
766 mode: "export".to_string(),
767 status: ScanStatus::Complete,
768 warnings: vec![],
769 scan: scan.clone(),
770 args: args_meta.clone(),
771 };
772 writeln!(out, "{}", serde_json::to_string(&meta)?)?;
773
774 for row in redact_rows(&export.rows, args_meta.redact) {
775 let wrapper = JsonlRow {
776 ty: "row",
777 row: &row,
778 };
779 writeln!(out, "{}", serde_json::to_string(&wrapper)?)?;
780 }
781
782 out.flush()?;
783 Ok(())
784}
785
786use tokmd_types::{DiffReceipt, DiffRow, DiffTotals, LangRow};
791
792pub fn compute_diff_rows(from_report: &LangReport, to_report: &LangReport) -> Vec<DiffRow> {
794 let mut all_langs: Vec<String> = from_report
796 .rows
797 .iter()
798 .chain(to_report.rows.iter())
799 .map(|r| r.lang.clone())
800 .collect();
801 all_langs.sort();
802 all_langs.dedup();
803
804 all_langs
805 .into_iter()
806 .filter_map(|lang_name| {
807 let old_row = from_report.rows.iter().find(|r| r.lang == lang_name);
808 let new_row = to_report.rows.iter().find(|r| r.lang == lang_name);
809
810 let old = old_row.cloned().unwrap_or_else(|| LangRow {
811 lang: lang_name.clone(),
812 code: 0,
813 lines: 0,
814 files: 0,
815 bytes: 0,
816 tokens: 0,
817 avg_lines: 0,
818 });
819 let new = new_row.cloned().unwrap_or_else(|| LangRow {
820 lang: lang_name.clone(),
821 code: 0,
822 lines: 0,
823 files: 0,
824 bytes: 0,
825 tokens: 0,
826 avg_lines: 0,
827 });
828
829 if old.code == new.code
831 && old.lines == new.lines
832 && old.files == new.files
833 && old.bytes == new.bytes
834 && old.tokens == new.tokens
835 {
836 return None;
837 }
838
839 Some(DiffRow {
840 lang: lang_name,
841 old_code: old.code,
842 new_code: new.code,
843 delta_code: new.code as i64 - old.code as i64,
844 old_lines: old.lines,
845 new_lines: new.lines,
846 delta_lines: new.lines as i64 - old.lines as i64,
847 old_files: old.files,
848 new_files: new.files,
849 delta_files: new.files as i64 - old.files as i64,
850 old_bytes: old.bytes,
851 new_bytes: new.bytes,
852 delta_bytes: new.bytes as i64 - old.bytes as i64,
853 old_tokens: old.tokens,
854 new_tokens: new.tokens,
855 delta_tokens: new.tokens as i64 - old.tokens as i64,
856 })
857 })
858 .collect()
859}
860
861pub fn compute_diff_totals(rows: &[DiffRow]) -> DiffTotals {
863 let mut totals = DiffTotals {
864 old_code: 0,
865 new_code: 0,
866 delta_code: 0,
867 old_lines: 0,
868 new_lines: 0,
869 delta_lines: 0,
870 old_files: 0,
871 new_files: 0,
872 delta_files: 0,
873 old_bytes: 0,
874 new_bytes: 0,
875 delta_bytes: 0,
876 old_tokens: 0,
877 new_tokens: 0,
878 delta_tokens: 0,
879 };
880
881 for row in rows {
882 totals.old_code += row.old_code;
883 totals.new_code += row.new_code;
884 totals.delta_code += row.delta_code;
885 totals.old_lines += row.old_lines;
886 totals.new_lines += row.new_lines;
887 totals.delta_lines += row.delta_lines;
888 totals.old_files += row.old_files;
889 totals.new_files += row.new_files;
890 totals.delta_files += row.delta_files;
891 totals.old_bytes += row.old_bytes;
892 totals.new_bytes += row.new_bytes;
893 totals.delta_bytes += row.delta_bytes;
894 totals.old_tokens += row.old_tokens;
895 totals.new_tokens += row.new_tokens;
896 totals.delta_tokens += row.delta_tokens;
897 }
898
899 totals
900}
901
902fn format_delta(delta: i64) -> String {
903 if delta > 0 {
904 format!("+{}", delta)
905 } else {
906 delta.to_string()
907 }
908}
909
910pub fn render_diff_md(
912 from_source: &str,
913 to_source: &str,
914 rows: &[DiffRow],
915 totals: &DiffTotals,
916) -> String {
917 let mut s = String::new();
918
919 let _ = writeln!(s, "## Diff: {} → {}", from_source, to_source);
920 s.push('\n');
921 s.push_str("|Language|Old LOC|New LOC|Delta|\n");
922 s.push_str("|---|---:|---:|---:|\n");
923
924 for row in rows {
925 let _ = writeln!(
926 s,
927 "|{}|{}|{}|{}|",
928 row.lang,
929 row.old_code,
930 row.new_code,
931 format_delta(row.delta_code)
932 );
933 }
934
935 let _ = writeln!(
936 s,
937 "|**Total**|{}|{}|{}|",
938 totals.old_code,
939 totals.new_code,
940 format_delta(totals.delta_code)
941 );
942
943 s
944}
945
946pub fn create_diff_receipt(
948 from_source: &str,
949 to_source: &str,
950 rows: Vec<DiffRow>,
951 totals: DiffTotals,
952) -> DiffReceipt {
953 DiffReceipt {
954 schema_version: tokmd_types::SCHEMA_VERSION,
955 generated_at_ms: now_ms(),
956 tool: ToolInfo::current(),
957 mode: "diff".to_string(),
958 from_source: from_source.to_string(),
959 to_source: to_source.to_string(),
960 diff_rows: rows,
961 totals,
962 }
963}
964
965#[doc(hidden)]
971pub fn write_export_csv_to<W: Write>(
972 out: &mut W,
973 export: &ExportData,
974 args: &ExportArgs,
975) -> Result<()> {
976 write_export_csv(out, export, args)
977}
978
979#[doc(hidden)]
981pub fn write_export_jsonl_to<W: Write>(
982 out: &mut W,
983 export: &ExportData,
984 global: &GlobalArgs,
985 args: &ExportArgs,
986) -> Result<()> {
987 write_export_jsonl(out, export, global, args)
988}
989
990#[doc(hidden)]
992pub fn write_export_json_to<W: Write>(
993 out: &mut W,
994 export: &ExportData,
995 global: &GlobalArgs,
996 args: &ExportArgs,
997) -> Result<()> {
998 write_export_json(out, export, global, args)
999}
1000
1001#[doc(hidden)]
1003pub fn write_export_cyclonedx_to<W: Write>(
1004 out: &mut W,
1005 export: &ExportData,
1006 redact: RedactMode,
1007) -> Result<()> {
1008 write_export_cyclonedx(out, export, redact)
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014 use proptest::prelude::*;
1015 use tokmd_config::ChildrenMode;
1016 use tokmd_types::{LangRow, ModuleRow, Totals};
1017
1018 fn sample_lang_report(with_files: bool) -> LangReport {
1019 LangReport {
1020 rows: vec![
1021 LangRow {
1022 lang: "Rust".to_string(),
1023 code: 1000,
1024 lines: 1200,
1025 files: 10,
1026 bytes: 50000,
1027 tokens: 2500,
1028 avg_lines: 120,
1029 },
1030 LangRow {
1031 lang: "TOML".to_string(),
1032 code: 50,
1033 lines: 60,
1034 files: 2,
1035 bytes: 1000,
1036 tokens: 125,
1037 avg_lines: 30,
1038 },
1039 ],
1040 total: Totals {
1041 code: 1050,
1042 lines: 1260,
1043 files: 12,
1044 bytes: 51000,
1045 tokens: 2625,
1046 avg_lines: 105,
1047 },
1048 with_files,
1049 children: ChildrenMode::Collapse,
1050 top: 0,
1051 }
1052 }
1053
1054 fn sample_module_report() -> ModuleReport {
1055 ModuleReport {
1056 rows: vec![
1057 ModuleRow {
1058 module: "crates/foo".to_string(),
1059 code: 800,
1060 lines: 950,
1061 files: 8,
1062 bytes: 40000,
1063 tokens: 2000,
1064 avg_lines: 119,
1065 },
1066 ModuleRow {
1067 module: "crates/bar".to_string(),
1068 code: 200,
1069 lines: 250,
1070 files: 2,
1071 bytes: 10000,
1072 tokens: 500,
1073 avg_lines: 125,
1074 },
1075 ],
1076 total: Totals {
1077 code: 1000,
1078 lines: 1200,
1079 files: 10,
1080 bytes: 50000,
1081 tokens: 2500,
1082 avg_lines: 120,
1083 },
1084 module_roots: vec!["crates".to_string()],
1085 module_depth: 2,
1086 children: tokmd_config::ChildIncludeMode::Separate,
1087 top: 0,
1088 }
1089 }
1090
1091 fn sample_file_rows() -> Vec<FileRow> {
1092 vec![
1093 FileRow {
1094 path: "src/lib.rs".to_string(),
1095 module: "src".to_string(),
1096 lang: "Rust".to_string(),
1097 kind: FileKind::Parent,
1098 code: 100,
1099 comments: 20,
1100 blanks: 10,
1101 lines: 130,
1102 bytes: 1000,
1103 tokens: 250,
1104 },
1105 FileRow {
1106 path: "tests/test.rs".to_string(),
1107 module: "tests".to_string(),
1108 lang: "Rust".to_string(),
1109 kind: FileKind::Parent,
1110 code: 50,
1111 comments: 5,
1112 blanks: 5,
1113 lines: 60,
1114 bytes: 500,
1115 tokens: 125,
1116 },
1117 ]
1118 }
1119
1120 #[test]
1125 fn render_lang_md_without_files() {
1126 let report = sample_lang_report(false);
1127 let output = render_lang_md(&report);
1128
1129 assert!(output.contains("|Lang|Code|Lines|Bytes|Tokens|"));
1131 assert!(!output.contains("|Files|"));
1133 assert!(!output.contains("|Avg|"));
1134 assert!(output.contains("|Rust|1000|1200|50000|2500|"));
1136 assert!(output.contains("|TOML|50|60|1000|125|"));
1137 assert!(output.contains("|**Total**|1050|1260|51000|2625|"));
1139 }
1140
1141 #[test]
1142 fn render_lang_md_with_files() {
1143 let report = sample_lang_report(true);
1144 let output = render_lang_md(&report);
1145
1146 assert!(output.contains("|Lang|Code|Lines|Files|Bytes|Tokens|Avg|"));
1148 assert!(output.contains("|Rust|1000|1200|10|50000|2500|120|"));
1150 assert!(output.contains("|TOML|50|60|2|1000|125|30|"));
1151 assert!(output.contains("|**Total**|1050|1260|12|51000|2625|105|"));
1153 }
1154
1155 #[test]
1156 fn render_lang_md_table_structure() {
1157 let report = sample_lang_report(true);
1158 let output = render_lang_md(&report);
1159
1160 let lines: Vec<&str> = output.lines().collect();
1162 assert!(lines.len() >= 4); assert!(lines[1].contains("|---|"));
1166 assert!(lines[1].contains(":")); }
1168
1169 #[test]
1174 fn render_lang_tsv_without_files() {
1175 let report = sample_lang_report(false);
1176 let output = render_lang_tsv(&report);
1177
1178 assert!(output.starts_with("Lang\tCode\tLines\tBytes\tTokens\n"));
1180 assert!(!output.contains("\tFiles\t"));
1182 assert!(!output.contains("\tAvg"));
1183 assert!(output.contains("Rust\t1000\t1200\t50000\t2500"));
1185 assert!(output.contains("TOML\t50\t60\t1000\t125"));
1186 assert!(output.contains("Total\t1050\t1260\t51000\t2625"));
1188 }
1189
1190 #[test]
1191 fn render_lang_tsv_with_files() {
1192 let report = sample_lang_report(true);
1193 let output = render_lang_tsv(&report);
1194
1195 assert!(output.starts_with("Lang\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n"));
1197 assert!(output.contains("Rust\t1000\t1200\t10\t50000\t2500\t120"));
1199 assert!(output.contains("TOML\t50\t60\t2\t1000\t125\t30"));
1200 }
1201
1202 #[test]
1203 fn render_lang_tsv_tab_separated() {
1204 let report = sample_lang_report(false);
1205 let output = render_lang_tsv(&report);
1206
1207 for line in output.lines().skip(1) {
1209 if line.starts_with("Total") || line.starts_with("Rust") || line.starts_with("TOML") {
1211 assert_eq!(line.matches('\t').count(), 4);
1212 }
1213 }
1214 }
1215
1216 #[test]
1221 fn render_module_md_structure() {
1222 let report = sample_module_report();
1223 let output = render_module_md(&report);
1224
1225 assert!(output.contains("|Module|Code|Lines|Files|Bytes|Tokens|Avg|"));
1227 assert!(output.contains("|crates/foo|800|950|8|40000|2000|119|"));
1229 assert!(output.contains("|crates/bar|200|250|2|10000|500|125|"));
1230 assert!(output.contains("|**Total**|1000|1200|10|50000|2500|120|"));
1232 }
1233
1234 #[test]
1235 fn render_module_md_table_format() {
1236 let report = sample_module_report();
1237 let output = render_module_md(&report);
1238
1239 let lines: Vec<&str> = output.lines().collect();
1240 assert_eq!(lines.len(), 5);
1242 assert!(lines[1].contains("---:"));
1244 }
1245
1246 #[test]
1251 fn render_module_tsv_structure() {
1252 let report = sample_module_report();
1253 let output = render_module_tsv(&report);
1254
1255 assert!(output.starts_with("Module\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n"));
1257 assert!(output.contains("crates/foo\t800\t950\t8\t40000\t2000\t119"));
1259 assert!(output.contains("crates/bar\t200\t250\t2\t10000\t500\t125"));
1260 assert!(output.contains("Total\t1000\t1200\t10\t50000\t2500\t120"));
1262 }
1263
1264 #[test]
1265 fn render_module_tsv_tab_count() {
1266 let report = sample_module_report();
1267 let output = render_module_tsv(&report);
1268
1269 for line in output.lines() {
1271 assert_eq!(line.matches('\t').count(), 6);
1272 }
1273 }
1274
1275 #[test]
1280 fn redact_rows_none_mode() {
1281 let rows = sample_file_rows();
1282 let redacted: Vec<_> = redact_rows(&rows, RedactMode::None).collect();
1283
1284 assert_eq!(redacted.len(), rows.len());
1286 assert_eq!(redacted[0].path, "src/lib.rs");
1287 assert_eq!(redacted[0].module, "src");
1288 }
1289
1290 #[test]
1291 fn redact_rows_paths_mode() {
1292 let rows = sample_file_rows();
1293 let redacted: Vec<_> = redact_rows(&rows, RedactMode::Paths).collect();
1294
1295 assert_ne!(redacted[0].path, "src/lib.rs");
1297 assert!(redacted[0].path.ends_with(".rs"));
1298 assert_eq!(redacted[0].path.len(), 16 + 3); assert_eq!(redacted[0].module, "src");
1302 }
1303
1304 #[test]
1305 fn redact_rows_all_mode() {
1306 let rows = sample_file_rows();
1307 let redacted: Vec<_> = redact_rows(&rows, RedactMode::All).collect();
1308
1309 assert_ne!(redacted[0].path, "src/lib.rs");
1311 assert!(redacted[0].path.ends_with(".rs"));
1312
1313 assert_ne!(redacted[0].module, "src");
1315 assert_eq!(redacted[0].module.len(), 16);
1316 }
1317
1318 #[test]
1319 fn redact_rows_preserves_other_fields() {
1320 let rows = sample_file_rows();
1321 let redacted: Vec<_> = redact_rows(&rows, RedactMode::All).collect();
1322
1323 assert_eq!(redacted[0].lang, "Rust");
1325 assert_eq!(redacted[0].kind, FileKind::Parent);
1326 assert_eq!(redacted[0].code, 100);
1327 assert_eq!(redacted[0].comments, 20);
1328 assert_eq!(redacted[0].blanks, 10);
1329 assert_eq!(redacted[0].lines, 130);
1330 assert_eq!(redacted[0].bytes, 1000);
1331 assert_eq!(redacted[0].tokens, 250);
1332 }
1333
1334 #[test]
1339 fn normalize_scan_input_forward_slash() {
1340 let p = Path::new("src/lib.rs");
1341 let normalized = normalize_scan_input(p);
1342 assert_eq!(normalized, "src/lib.rs");
1343 }
1344
1345 #[test]
1346 fn normalize_scan_input_backslash_to_forward() {
1347 let p = Path::new("src\\lib.rs");
1348 let normalized = normalize_scan_input(p);
1349 assert_eq!(normalized, "src/lib.rs");
1350 }
1351
1352 #[test]
1353 fn normalize_scan_input_strips_dot_slash() {
1354 let p = Path::new("./src/lib.rs");
1355 let normalized = normalize_scan_input(p);
1356 assert_eq!(normalized, "src/lib.rs");
1357 }
1358
1359 #[test]
1360 fn normalize_scan_input_current_dir() {
1361 let p = Path::new(".");
1362 let normalized = normalize_scan_input(p);
1363 assert_eq!(normalized, ".");
1364 }
1365
1366 proptest! {
1371 #[test]
1372 fn normalize_scan_input_no_backslash(s in "[a-zA-Z0-9_/\\\\.]+") {
1373 let p = Path::new(&s);
1374 let normalized = normalize_scan_input(p);
1375 prop_assert!(!normalized.contains('\\'), "Should not contain backslash: {}", normalized);
1376 }
1377
1378 #[test]
1379 fn normalize_scan_input_no_leading_dot_slash(s in "[a-zA-Z0-9_/\\\\.]+") {
1380 let p = Path::new(&s);
1381 let normalized = normalize_scan_input(p);
1382 prop_assert!(!normalized.starts_with("./"), "Should not start with ./: {}", normalized);
1383 }
1384
1385 #[test]
1386 fn redact_rows_preserves_count(
1387 code in 0usize..10000,
1388 comments in 0usize..1000,
1389 blanks in 0usize..500
1390 ) {
1391 let rows = vec![FileRow {
1392 path: "test/file.rs".to_string(),
1393 module: "test".to_string(),
1394 lang: "Rust".to_string(),
1395 kind: FileKind::Parent,
1396 code,
1397 comments,
1398 blanks,
1399 lines: code + comments + blanks,
1400 bytes: 1000,
1401 tokens: 250,
1402 }];
1403
1404 for mode in [RedactMode::None, RedactMode::Paths, RedactMode::All] {
1405 let redacted: Vec<_> = redact_rows(&rows, mode).collect();
1406 prop_assert_eq!(redacted.len(), 1);
1407 prop_assert_eq!(redacted[0].code, code);
1408 prop_assert_eq!(redacted[0].comments, comments);
1409 prop_assert_eq!(redacted[0].blanks, blanks);
1410 }
1411 }
1412
1413 #[test]
1414 fn redact_rows_paths_end_with_extension(ext in "[a-z]{1,4}") {
1415 let path = format!("some/path/file.{}", ext);
1416 let rows = vec![FileRow {
1417 path: path.clone(),
1418 module: "some".to_string(),
1419 lang: "Test".to_string(),
1420 kind: FileKind::Parent,
1421 code: 100,
1422 comments: 10,
1423 blanks: 5,
1424 lines: 115,
1425 bytes: 1000,
1426 tokens: 250,
1427 }];
1428
1429 let redacted: Vec<_> = redact_rows(&rows, RedactMode::Paths).collect();
1430 prop_assert!(redacted[0].path.ends_with(&format!(".{}", ext)),
1431 "Redacted path '{}' should end with .{}", redacted[0].path, ext);
1432 }
1433 }
1434
1435 #[test]
1440 fn snapshot_lang_md_with_files() {
1441 let report = sample_lang_report(true);
1442 let output = render_lang_md(&report);
1443 insta::assert_snapshot!(output);
1444 }
1445
1446 #[test]
1447 fn snapshot_lang_md_without_files() {
1448 let report = sample_lang_report(false);
1449 let output = render_lang_md(&report);
1450 insta::assert_snapshot!(output);
1451 }
1452
1453 #[test]
1454 fn snapshot_lang_tsv_with_files() {
1455 let report = sample_lang_report(true);
1456 let output = render_lang_tsv(&report);
1457 insta::assert_snapshot!(output);
1458 }
1459
1460 #[test]
1461 fn snapshot_module_md() {
1462 let report = sample_module_report();
1463 let output = render_module_md(&report);
1464 insta::assert_snapshot!(output);
1465 }
1466
1467 #[test]
1468 fn snapshot_module_tsv() {
1469 let report = sample_module_report();
1470 let output = render_module_tsv(&report);
1471 insta::assert_snapshot!(output);
1472 }
1473
1474 #[test]
1479 fn test_render_diff_md_smoke() {
1480 let from = LangReport {
1482 rows: vec![LangRow {
1483 lang: "Rust".to_string(),
1484 code: 10,
1485 lines: 10,
1486 files: 1,
1487 bytes: 100,
1488 tokens: 20,
1489 avg_lines: 10,
1490 }],
1491 total: Totals {
1492 code: 10,
1493 lines: 10,
1494 files: 1,
1495 bytes: 100,
1496 tokens: 20,
1497 avg_lines: 10,
1498 },
1499 with_files: false,
1500 children: ChildrenMode::Collapse,
1501 top: 0,
1502 };
1503
1504 let to = LangReport {
1505 rows: vec![LangRow {
1506 lang: "Rust".to_string(),
1507 code: 12,
1508 lines: 12,
1509 files: 1,
1510 bytes: 120,
1511 tokens: 24,
1512 avg_lines: 12,
1513 }],
1514 total: Totals {
1515 code: 12,
1516 lines: 12,
1517 files: 1,
1518 bytes: 120,
1519 tokens: 24,
1520 avg_lines: 12,
1521 },
1522 with_files: false,
1523 children: ChildrenMode::Collapse,
1524 top: 0,
1525 };
1526
1527 let rows = compute_diff_rows(&from, &to);
1528 assert_eq!(rows.len(), 1);
1529 assert_eq!(rows[0].lang, "Rust");
1530 assert_eq!(rows[0].delta_code, 2);
1531
1532 let totals = compute_diff_totals(&rows);
1533 assert_eq!(totals.delta_code, 2);
1534
1535 let md = render_diff_md("from", "to", &rows, &totals);
1536
1537 assert!(!md.trim().is_empty(), "diff markdown must not be empty");
1538 assert!(md.contains("from"));
1539 assert!(md.contains("to"));
1540 assert!(md.contains("Rust"));
1541 }
1542
1543 #[test]
1544 fn test_compute_diff_rows_language_added() {
1545 let from = LangReport {
1547 rows: vec![],
1548 total: Totals {
1549 code: 0,
1550 lines: 0,
1551 files: 0,
1552 bytes: 0,
1553 tokens: 0,
1554 avg_lines: 0,
1555 },
1556 with_files: false,
1557 children: ChildrenMode::Collapse,
1558 top: 0,
1559 };
1560
1561 let to = LangReport {
1562 rows: vec![LangRow {
1563 lang: "Python".to_string(),
1564 code: 100,
1565 lines: 120,
1566 files: 5,
1567 bytes: 5000,
1568 tokens: 250,
1569 avg_lines: 24,
1570 }],
1571 total: Totals {
1572 code: 100,
1573 lines: 120,
1574 files: 5,
1575 bytes: 5000,
1576 tokens: 250,
1577 avg_lines: 24,
1578 },
1579 with_files: false,
1580 children: ChildrenMode::Collapse,
1581 top: 0,
1582 };
1583
1584 let rows = compute_diff_rows(&from, &to);
1585 assert_eq!(rows.len(), 1);
1586 assert_eq!(rows[0].lang, "Python");
1587 assert_eq!(rows[0].old_code, 0);
1588 assert_eq!(rows[0].new_code, 100);
1589 assert_eq!(rows[0].delta_code, 100);
1590 }
1591
1592 #[test]
1593 fn test_compute_diff_rows_language_removed() {
1594 let from = LangReport {
1596 rows: vec![LangRow {
1597 lang: "Go".to_string(),
1598 code: 50,
1599 lines: 60,
1600 files: 2,
1601 bytes: 2000,
1602 tokens: 125,
1603 avg_lines: 30,
1604 }],
1605 total: Totals {
1606 code: 50,
1607 lines: 60,
1608 files: 2,
1609 bytes: 2000,
1610 tokens: 125,
1611 avg_lines: 30,
1612 },
1613 with_files: false,
1614 children: ChildrenMode::Collapse,
1615 top: 0,
1616 };
1617
1618 let to = LangReport {
1619 rows: vec![],
1620 total: Totals {
1621 code: 0,
1622 lines: 0,
1623 files: 0,
1624 bytes: 0,
1625 tokens: 0,
1626 avg_lines: 0,
1627 },
1628 with_files: false,
1629 children: ChildrenMode::Collapse,
1630 top: 0,
1631 };
1632
1633 let rows = compute_diff_rows(&from, &to);
1634 assert_eq!(rows.len(), 1);
1635 assert_eq!(rows[0].lang, "Go");
1636 assert_eq!(rows[0].old_code, 50);
1637 assert_eq!(rows[0].new_code, 0);
1638 assert_eq!(rows[0].delta_code, -50);
1639 }
1640
1641 #[test]
1642 fn test_compute_diff_rows_unchanged_excluded() {
1643 let report = LangReport {
1645 rows: vec![LangRow {
1646 lang: "Rust".to_string(),
1647 code: 100,
1648 lines: 100,
1649 files: 1,
1650 bytes: 1000,
1651 tokens: 250,
1652 avg_lines: 100,
1653 }],
1654 total: Totals {
1655 code: 100,
1656 lines: 100,
1657 files: 1,
1658 bytes: 1000,
1659 tokens: 250,
1660 avg_lines: 100,
1661 },
1662 with_files: false,
1663 children: ChildrenMode::Collapse,
1664 top: 0,
1665 };
1666
1667 let rows = compute_diff_rows(&report, &report);
1668 assert!(rows.is_empty(), "unchanged languages should be excluded");
1669 }
1670
1671 #[test]
1672 fn test_format_delta() {
1673 assert_eq!(format_delta(5), "+5");
1675 assert_eq!(format_delta(0), "0");
1676 assert_eq!(format_delta(-3), "-3");
1677 }
1678
1679 fn sample_global_args() -> GlobalArgs {
1684 GlobalArgs::default()
1685 }
1686
1687 fn sample_lang_args(format: TableFormat) -> LangArgs {
1688 LangArgs {
1689 paths: vec![PathBuf::from(".")],
1690 format,
1691 top: 0,
1692 files: false,
1693 children: ChildrenMode::Collapse,
1694 }
1695 }
1696
1697 fn sample_module_args(format: TableFormat) -> ModuleArgs {
1698 ModuleArgs {
1699 paths: vec![PathBuf::from(".")],
1700 format,
1701 top: 0,
1702 module_roots: vec!["crates".to_string()],
1703 module_depth: 2,
1704 children: tokmd_config::ChildIncludeMode::Separate,
1705 }
1706 }
1707
1708 #[test]
1709 fn write_lang_report_to_md_writes_content() {
1710 let report = sample_lang_report(true);
1711 let global = sample_global_args();
1712 let args = sample_lang_args(TableFormat::Md);
1713 let mut buf = Vec::new();
1714
1715 write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
1716 let output = String::from_utf8(buf).unwrap();
1717
1718 assert!(!output.is_empty(), "output must not be empty");
1719 assert!(output.contains("|Lang|"), "must contain markdown header");
1720 assert!(output.contains("|Rust|"), "must contain Rust row");
1721 assert!(output.contains("|**Total**|"), "must contain total row");
1722 }
1723
1724 #[test]
1725 fn write_lang_report_to_tsv_writes_content() {
1726 let report = sample_lang_report(false);
1727 let global = sample_global_args();
1728 let args = sample_lang_args(TableFormat::Tsv);
1729 let mut buf = Vec::new();
1730
1731 write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
1732 let output = String::from_utf8(buf).unwrap();
1733
1734 assert!(!output.is_empty(), "output must not be empty");
1735 assert!(output.contains("Lang\t"), "must contain TSV header");
1736 assert!(output.contains("Rust\t"), "must contain Rust row");
1737 assert!(output.contains("Total\t"), "must contain total row");
1738 }
1739
1740 #[test]
1741 fn write_lang_report_to_json_writes_receipt() {
1742 let report = sample_lang_report(true);
1743 let global = sample_global_args();
1744 let args = sample_lang_args(TableFormat::Json);
1745 let mut buf = Vec::new();
1746
1747 write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
1748 let output = String::from_utf8(buf).unwrap();
1749
1750 assert!(!output.is_empty(), "output must not be empty");
1751 let receipt: LangReceipt = serde_json::from_str(&output).unwrap();
1753 assert_eq!(receipt.mode, "lang");
1754 assert_eq!(receipt.report.rows.len(), 2);
1755 assert_eq!(receipt.report.total.code, 1050);
1756 }
1757
1758 #[test]
1759 fn write_module_report_to_md_writes_content() {
1760 let report = sample_module_report();
1761 let global = sample_global_args();
1762 let args = sample_module_args(TableFormat::Md);
1763 let mut buf = Vec::new();
1764
1765 write_module_report_to(&mut buf, &report, &global, &args).unwrap();
1766 let output = String::from_utf8(buf).unwrap();
1767
1768 assert!(!output.is_empty(), "output must not be empty");
1769 assert!(output.contains("|Module|"), "must contain markdown header");
1770 assert!(output.contains("|crates/foo|"), "must contain module row");
1771 assert!(output.contains("|**Total**|"), "must contain total row");
1772 }
1773
1774 #[test]
1775 fn write_module_report_to_tsv_writes_content() {
1776 let report = sample_module_report();
1777 let global = sample_global_args();
1778 let args = sample_module_args(TableFormat::Tsv);
1779 let mut buf = Vec::new();
1780
1781 write_module_report_to(&mut buf, &report, &global, &args).unwrap();
1782 let output = String::from_utf8(buf).unwrap();
1783
1784 assert!(!output.is_empty(), "output must not be empty");
1785 assert!(output.contains("Module\t"), "must contain TSV header");
1786 assert!(output.contains("crates/foo\t"), "must contain module row");
1787 assert!(output.contains("Total\t"), "must contain total row");
1788 }
1789
1790 #[test]
1791 fn write_module_report_to_json_writes_receipt() {
1792 let report = sample_module_report();
1793 let global = sample_global_args();
1794 let args = sample_module_args(TableFormat::Json);
1795 let mut buf = Vec::new();
1796
1797 write_module_report_to(&mut buf, &report, &global, &args).unwrap();
1798 let output = String::from_utf8(buf).unwrap();
1799
1800 assert!(!output.is_empty(), "output must not be empty");
1801 let receipt: ModuleReceipt = serde_json::from_str(&output).unwrap();
1803 assert_eq!(receipt.mode, "module");
1804 assert_eq!(receipt.report.rows.len(), 2);
1805 assert_eq!(receipt.report.total.code, 1000);
1806 }
1807}