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