Skip to main content

tokmd_types/
lib.rs

1//! # tokmd-types
2//!
3//! **Tier 0 (Core Types)**
4//!
5//! This crate defines the core data structures and contracts for `tokmd`.
6//! It contains only data types, Serde definitions, and `schema_version`.
7//!
8//! ## Stability Policy
9//!
10//! **JSON-first stability**: The primary contract is the JSON schema, not Rust struct literals.
11//!
12//! - **JSON consumers**: Stable. New fields have sensible defaults; removed/renamed fields
13//!   bump `SCHEMA_VERSION`.
14//! - **Rust library consumers**: Semi-stable. New fields may be added in minor versions,
15//!   which can break struct literal construction. Use `Default` + field mutation or
16//!   `..Default::default()` patterns for forward compatibility.
17//!
18//! If you need strict Rust API stability, pin to an exact version.
19//!
20//! ## What belongs here
21//! * Pure data structs (Receipts, Rows, Reports)
22//! * Serialization/Deserialization logic
23//! * Stability markers (SCHEMA_VERSION)
24//!
25//! ## What does NOT belong here
26//! * File I/O
27//! * CLI argument parsing
28//! * Complex business logic
29//! * Tokei dependencies
30
31pub mod cockpit;
32
33use std::path::PathBuf;
34
35use serde::{Deserialize, Serialize};
36
37/// The current schema version for core receipt types (`lang`, `module`, `export`, `diff`, `run`).
38///
39/// # Examples
40///
41/// ```
42/// assert_eq!(tokmd_types::SCHEMA_VERSION, 2);
43/// ```
44pub const SCHEMA_VERSION: u32 = 2;
45
46/// A small totals struct shared by summary outputs.
47///
48/// # Examples
49///
50/// ```
51/// use tokmd_types::Totals;
52///
53/// let totals = Totals {
54///     code: 1000,
55///     lines: 1500,
56///     files: 10,
57///     bytes: 40000,
58///     tokens: 10000,
59///     avg_lines: 150,
60/// };
61/// assert_eq!(totals.code, 1000);
62/// ```
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct Totals {
65    pub code: usize,
66    pub lines: usize,
67    pub files: usize,
68    pub bytes: usize,
69    pub tokens: usize,
70    pub avg_lines: usize,
71}
72
73/// A single language row in the lang summary.
74///
75/// # Examples
76///
77/// ```
78/// use tokmd_types::LangRow;
79///
80/// let row = LangRow {
81///     lang: "Rust".to_string(),
82///     code: 5000,
83///     lines: 6500,
84///     files: 42,
85///     bytes: 180_000,
86///     tokens: 45_000,
87///     avg_lines: 154,
88/// };
89/// assert_eq!(row.lang, "Rust");
90/// assert_eq!(row.files, 42);
91/// ```
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct LangRow {
94    pub lang: String,
95    pub code: usize,
96    pub lines: usize,
97    pub files: usize,
98    pub bytes: usize,
99    pub tokens: usize,
100    pub avg_lines: usize,
101}
102
103/// A report detailing language statistics.
104///
105/// # Examples
106///
107/// ```
108/// use tokmd_types::{LangReport, LangRow, Totals, ChildrenMode};
109///
110/// let report = LangReport {
111///     rows: vec![
112///         LangRow {
113///             lang: "Rust".to_string(),
114///             code: 5000,
115///             lines: 6500,
116///             files: 42,
117///             bytes: 180_000,
118///             tokens: 45_000,
119///             avg_lines: 154,
120///         }
121///     ],
122///     total: Totals {
123///         code: 5000,
124///         lines: 6500,
125///         files: 42,
126///         bytes: 180_000,
127///         tokens: 45_000,
128///         avg_lines: 154,
129///     },
130///     with_files: false,
131///     children: ChildrenMode::Collapse,
132///     top: 10,
133/// };
134/// assert_eq!(report.rows.len(), 1);
135/// assert_eq!(report.total.files, 42);
136/// ```
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct LangReport {
139    pub rows: Vec<LangRow>,
140    pub total: Totals,
141    pub with_files: bool,
142    pub children: ChildrenMode,
143    pub top: usize,
144}
145
146/// A single module row in the module breakdown.
147///
148/// # Examples
149///
150/// ```
151/// use tokmd_types::ModuleRow;
152///
153/// let row = ModuleRow {
154///     module: "crates/tokmd-types".to_string(),
155///     code: 800,
156///     lines: 1100,
157///     files: 3,
158///     bytes: 32_000,
159///     tokens: 8_000,
160///     avg_lines: 366,
161/// };
162/// assert_eq!(row.module, "crates/tokmd-types");
163/// assert_eq!(row.code, 800);
164/// ```
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166pub struct ModuleRow {
167    pub module: String,
168    pub code: usize,
169    pub lines: usize,
170    pub files: usize,
171    pub bytes: usize,
172    pub tokens: usize,
173    pub avg_lines: usize,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct ModuleReport {
178    pub rows: Vec<ModuleRow>,
179    pub total: Totals,
180    pub module_roots: Vec<String>,
181    pub module_depth: usize,
182    pub children: ChildIncludeMode,
183    pub top: usize,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum FileKind {
189    Parent,
190    Child,
191}
192
193/// A single file row in the export inventory.
194///
195/// # Examples
196///
197/// ```
198/// use tokmd_types::{FileRow, FileKind};
199///
200/// let row = FileRow {
201///     path: "src/main.rs".to_string(),
202///     module: "src".to_string(),
203///     lang: "Rust".to_string(),
204///     kind: FileKind::Parent,
205///     code: 120,
206///     comments: 30,
207///     blanks: 20,
208///     lines: 170,
209///     bytes: 4_800,
210///     tokens: 1_200,
211/// };
212/// assert_eq!(row.path, "src/main.rs");
213/// assert_eq!(row.kind, FileKind::Parent);
214/// assert_eq!(row.lines, row.code + row.comments + row.blanks);
215/// ```
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
217pub struct FileRow {
218    pub path: String,
219    pub module: String,
220    pub lang: String,
221    pub kind: FileKind,
222    pub code: usize,
223    pub comments: usize,
224    pub blanks: usize,
225    pub lines: usize,
226    pub bytes: usize,
227    pub tokens: usize,
228}
229
230/// Detailed export data containing individual file statistics.
231///
232/// # Examples
233///
234/// ```
235/// use tokmd_types::{ExportData, FileRow, FileKind, ChildIncludeMode};
236///
237/// let data = ExportData {
238///     rows: vec![
239///         FileRow {
240///             path: "src/main.rs".to_string(),
241///             module: "src".to_string(),
242///             lang: "Rust".to_string(),
243///             kind: FileKind::Parent,
244///             code: 120,
245///             comments: 30,
246///             blanks: 20,
247///             lines: 170,
248///             bytes: 4_800,
249///             tokens: 1_200,
250///         }
251///     ],
252///     module_roots: vec![],
253///     module_depth: 1,
254///     children: ChildIncludeMode::Separate,
255/// };
256/// assert_eq!(data.rows.len(), 1);
257/// ```
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ExportData {
260    pub rows: Vec<FileRow>,
261    pub module_roots: Vec<String>,
262    pub module_depth: usize,
263    pub children: ChildIncludeMode,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct RunReceipt {
268    pub schema_version: u32,
269    pub generated_at_ms: u128,
270    pub lang_file: String,
271    pub module_file: String,
272    pub export_file: String,
273    // We could store the scan args here too
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum ScanStatus {
279    Complete,
280    Partial,
281}
282
283/// Classification of a commit's intent, derived from subject line.
284///
285/// Lives in `tokmd-types` (Tier 0) so that both `tokmd-git` (Tier 2) and
286/// `tokmd-analysis-types` (Tier 0) can reference it without creating
287/// upward dependency edges.
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum CommitIntentKind {
291    Feat,
292    Fix,
293    Refactor,
294    Docs,
295    Test,
296    Chore,
297    Ci,
298    Build,
299    Perf,
300    Style,
301    Revert,
302    Other,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, Default)]
306pub struct ToolInfo {
307    pub name: String,
308    pub version: String,
309}
310
311impl ToolInfo {
312    pub fn current() -> Self {
313        Self {
314            name: "tokmd".to_string(),
315            version: env!("CARGO_PKG_VERSION").to_string(),
316        }
317    }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct ScanArgs {
322    pub paths: Vec<String>,
323    pub excluded: Vec<String>,
324    /// True if `excluded` patterns were redacted (replaced with hashes).
325    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
326    pub excluded_redacted: bool,
327    pub config: ConfigMode,
328    pub hidden: bool,
329    pub no_ignore: bool,
330    pub no_ignore_parent: bool,
331    pub no_ignore_dot: bool,
332    pub no_ignore_vcs: bool,
333    pub treat_doc_strings_as_comments: bool,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct LangArgsMeta {
338    pub format: String,
339    pub top: usize,
340    pub with_files: bool,
341    pub children: ChildrenMode,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct LangReceipt {
346    pub schema_version: u32,
347    pub generated_at_ms: u128,
348    pub tool: ToolInfo,
349    pub mode: String, // "lang"
350    pub status: ScanStatus,
351    pub warnings: Vec<String>,
352    pub scan: ScanArgs,
353    pub args: LangArgsMeta,
354    #[serde(flatten)]
355    pub report: LangReport,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct ModuleArgsMeta {
360    pub format: String,
361    pub module_roots: Vec<String>,
362    pub module_depth: usize,
363    pub children: ChildIncludeMode,
364    pub top: usize,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct ModuleReceipt {
369    pub schema_version: u32,
370    pub generated_at_ms: u128,
371    pub tool: ToolInfo,
372    pub mode: String, // "module"
373    pub status: ScanStatus,
374    pub warnings: Vec<String>,
375    pub scan: ScanArgs,
376    pub args: ModuleArgsMeta,
377    #[serde(flatten)]
378    pub report: ModuleReport,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct ExportArgsMeta {
383    pub format: ExportFormat,
384    pub module_roots: Vec<String>,
385    pub module_depth: usize,
386    pub children: ChildIncludeMode,
387    pub min_code: usize,
388    pub max_rows: usize,
389    pub redact: RedactMode,
390    pub strip_prefix: Option<String>,
391    /// True if `strip_prefix` was redacted (replaced with a hash).
392    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
393    pub strip_prefix_redacted: bool,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct ExportReceipt {
398    pub schema_version: u32,
399    pub generated_at_ms: u128,
400    pub tool: ToolInfo,
401    pub mode: String, // "export"
402    pub status: ScanStatus,
403    pub warnings: Vec<String>,
404    pub scan: ScanArgs,
405    pub args: ExportArgsMeta,
406    #[serde(flatten)]
407    pub data: ExportData,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct LangArgs {
412    pub paths: Vec<PathBuf>,
413    pub format: TableFormat,
414    pub top: usize,
415    pub files: bool,
416    pub children: ChildrenMode,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct ModuleArgs {
421    pub paths: Vec<PathBuf>,
422    pub format: TableFormat,
423    pub top: usize,
424    pub module_roots: Vec<String>,
425    pub module_depth: usize,
426    pub children: ChildIncludeMode,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct ExportArgs {
431    pub paths: Vec<PathBuf>,
432    pub format: ExportFormat,
433    pub output: Option<PathBuf>,
434    pub module_roots: Vec<String>,
435    pub module_depth: usize,
436    pub children: ChildIncludeMode,
437    pub min_code: usize,
438    pub max_rows: usize,
439    pub redact: RedactMode,
440    pub meta: bool,
441    pub strip_prefix: Option<PathBuf>,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct ContextReceipt {
446    pub schema_version: u32,
447    pub generated_at_ms: u128,
448    pub tool: ToolInfo,
449    pub mode: String,
450    pub budget_tokens: usize,
451    pub used_tokens: usize,
452    pub utilization_pct: f64,
453    pub strategy: String,
454    pub rank_by: String,
455    pub file_count: usize,
456    pub files: Vec<ContextFileRow>,
457    /// Effective ranking metric (may differ from rank_by if fallback occurred).
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub rank_by_effective: Option<String>,
460    /// Reason for fallback if rank_by_effective differs from rank_by.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub fallback_reason: Option<String>,
463    /// Files excluded by per-file cap / classification policy.
464    #[serde(default, skip_serializing_if = "Vec::is_empty")]
465    pub excluded_by_policy: Vec<PolicyExcludedFile>,
466    /// Token estimation envelope with uncertainty bounds.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub token_estimation: Option<TokenEstimationMeta>,
469    /// Post-bundle audit comparing actual bytes to estimates.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub bundle_audit: Option<TokenAudit>,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct ContextFileRow {
476    pub path: String,
477    pub module: String,
478    pub lang: String,
479    pub tokens: usize,
480    pub code: usize,
481    pub lines: usize,
482    pub bytes: usize,
483    pub value: usize,
484    #[serde(default, skip_serializing_if = "String::is_empty")]
485    pub rank_reason: String,
486    /// Inclusion policy applied to this file.
487    #[serde(default, skip_serializing_if = "is_default_policy")]
488    pub policy: InclusionPolicy,
489    /// Effective token count when policy != Full (None means same as `tokens`).
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub effective_tokens: Option<usize>,
492    /// Reason for the applied policy.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub policy_reason: Option<String>,
495    /// File classifications detected by hygiene analysis.
496    #[serde(default, skip_serializing_if = "Vec::is_empty")]
497    pub classifications: Vec<FileClassification>,
498}
499
500// -----------------------
501// Diff types
502// -----------------------
503
504/// A row in the diff output showing changes for a single language.
505///
506/// # Examples
507///
508/// ```
509/// use tokmd_types::DiffRow;
510///
511/// let row = DiffRow {
512///     lang: "Rust".to_string(),
513///     old_code: 1000, new_code: 1200, delta_code: 200,
514///     old_lines: 1500, new_lines: 1800, delta_lines: 300,
515///     old_files: 10,   new_files: 12,   delta_files: 2,
516///     old_bytes: 40000, new_bytes: 48000, delta_bytes: 8000,
517///     old_tokens: 10000, new_tokens: 12000, delta_tokens: 2000,
518/// };
519/// assert_eq!(row.delta_code, (row.new_code as i64) - (row.old_code as i64));
520/// ```
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
522pub struct DiffRow {
523    pub lang: String,
524    pub old_code: usize,
525    pub new_code: usize,
526    pub delta_code: i64,
527    pub old_lines: usize,
528    pub new_lines: usize,
529    pub delta_lines: i64,
530    pub old_files: usize,
531    pub new_files: usize,
532    pub delta_files: i64,
533    pub old_bytes: usize,
534    pub new_bytes: usize,
535    pub delta_bytes: i64,
536    pub old_tokens: usize,
537    pub new_tokens: usize,
538    pub delta_tokens: i64,
539}
540
541/// Aggregate totals for the diff.
542///
543/// # Examples
544///
545/// ```
546/// use tokmd_types::DiffTotals;
547///
548/// // Default is all zeros
549/// let totals = DiffTotals::default();
550/// assert_eq!(totals.delta_code, 0);
551/// assert_eq!(totals.delta_files, 0);
552/// ```
553#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
554pub struct DiffTotals {
555    pub old_code: usize,
556    pub new_code: usize,
557    pub delta_code: i64,
558    pub old_lines: usize,
559    pub new_lines: usize,
560    pub delta_lines: i64,
561    pub old_files: usize,
562    pub new_files: usize,
563    pub delta_files: i64,
564    pub old_bytes: usize,
565    pub new_bytes: usize,
566    pub delta_bytes: i64,
567    pub old_tokens: usize,
568    pub new_tokens: usize,
569    pub delta_tokens: i64,
570}
571
572/// JSON receipt for diff output with envelope metadata.
573#[derive(Debug, Clone, Serialize, Deserialize)]
574pub struct DiffReceipt {
575    pub schema_version: u32,
576    pub generated_at_ms: u128,
577    pub tool: ToolInfo,
578    pub mode: String,
579    pub from_source: String,
580    pub to_source: String,
581    pub diff_rows: Vec<DiffRow>,
582    pub totals: DiffTotals,
583}
584
585// -----------------------------------------------------------------------------
586// Enums shared with CLI (moved from tokmd-config)
587// -----------------------------------------------------------------------------
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
590#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
591#[serde(rename_all = "kebab-case")]
592pub enum TableFormat {
593    /// Markdown table (great for pasting into ChatGPT).
594    Md,
595    /// Tab-separated values (good for piping to other tools).
596    Tsv,
597    /// JSON (compact).
598    Json,
599}
600
601#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
602#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
603#[serde(rename_all = "kebab-case")]
604pub enum ExportFormat {
605    /// CSV with a header row.
606    Csv,
607    /// One JSON object per line.
608    Jsonl,
609    /// A single JSON array.
610    Json,
611    /// CycloneDX 1.6 JSON SBOM format.
612    Cyclonedx,
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
616#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
617#[serde(rename_all = "kebab-case")]
618pub enum ConfigMode {
619    /// Read scan config files (`tokei.toml` / `.tokeirc`) if present.
620    #[default]
621    Auto,
622    /// Ignore config files.
623    None,
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
627#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
628#[serde(rename_all = "kebab-case")]
629pub enum ChildrenMode {
630    /// Merge embedded content into the parent language totals.
631    Collapse,
632    /// Show embedded languages as separate "(embedded)" rows.
633    Separate,
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
637#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
638#[serde(rename_all = "kebab-case")]
639pub enum ChildIncludeMode {
640    /// Include embedded languages as separate contributions.
641    Separate,
642    /// Ignore embedded languages.
643    ParentsOnly,
644}
645
646#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
647#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
648#[serde(rename_all = "kebab-case")]
649pub enum RedactMode {
650    /// Do not redact.
651    None,
652    /// Redact file paths.
653    Paths,
654    /// Redact file paths and module names.
655    All,
656}
657
658#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
659#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
660#[serde(rename_all = "kebab-case")]
661pub enum AnalysisFormat {
662    Md,
663    Json,
664    Jsonld,
665    Xml,
666    Svg,
667    Mermaid,
668    Obj,
669    Midi,
670    Tree,
671    Html,
672}
673
674/// Log record for context command JSONL append mode.
675/// Contains metadata only (not file contents) for lightweight logging.
676#[derive(Debug, Clone, Serialize, Deserialize)]
677pub struct ContextLogRecord {
678    pub schema_version: u32,
679    pub generated_at_ms: u128,
680    pub tool: ToolInfo,
681    pub budget_tokens: usize,
682    pub used_tokens: usize,
683    pub utilization_pct: f64,
684    pub strategy: String,
685    pub rank_by: String,
686    pub file_count: usize,
687    pub total_bytes: usize,
688    pub output_destination: String,
689}
690
691// -----------------------
692// Handoff types
693// -----------------------
694
695/// Schema version for handoff receipts.
696///
697/// ```
698/// assert_eq!(tokmd_types::HANDOFF_SCHEMA_VERSION, 5);
699/// ```
700pub const HANDOFF_SCHEMA_VERSION: u32 = 5;
701
702/// Schema version for context bundle manifests.
703///
704/// ```
705/// assert_eq!(tokmd_types::CONTEXT_BUNDLE_SCHEMA_VERSION, 2);
706/// ```
707pub const CONTEXT_BUNDLE_SCHEMA_VERSION: u32 = 2;
708
709/// Schema version for context receipts (separate from SCHEMA_VERSION used by lang/module/export/diff).
710///
711/// ```
712/// assert_eq!(tokmd_types::CONTEXT_SCHEMA_VERSION, 4);
713/// ```
714pub const CONTEXT_SCHEMA_VERSION: u32 = 4;
715
716// -----------------------
717// Token estimation types
718// -----------------------
719
720/// Metadata about how token estimates were produced.
721///
722/// Rails are NOT guaranteed bounds — they are heuristic fences.
723/// Default divisors: est=4.0, low=3.0 (conservative → more tokens),
724/// high=5.0 (optimistic → fewer tokens).
725///
726/// **Invariant**: `tokens_min <= tokens_est <= tokens_max`.
727#[derive(Debug, Clone, Serialize, Deserialize)]
728pub struct TokenEstimationMeta {
729    /// Divisor used for main estimate (default 4.0).
730    pub bytes_per_token_est: f64,
731    /// Conservative divisor — more tokens (default 3.0).
732    pub bytes_per_token_low: f64,
733    /// Optimistic divisor — fewer tokens (default 5.0).
734    pub bytes_per_token_high: f64,
735    /// tokens = source_bytes / bytes_per_token_high (optimistic, fewest tokens).
736    #[serde(alias = "tokens_high")]
737    pub tokens_min: usize,
738    /// tokens = source_bytes / bytes_per_token_est.
739    pub tokens_est: usize,
740    /// tokens = source_bytes / bytes_per_token_low (conservative, most tokens).
741    #[serde(alias = "tokens_low")]
742    pub tokens_max: usize,
743    /// Total source bytes used to compute estimates.
744    pub source_bytes: usize,
745}
746
747impl TokenEstimationMeta {
748    /// Default bytes-per-token divisors.
749    pub const DEFAULT_BPT_EST: f64 = 4.0;
750    pub const DEFAULT_BPT_LOW: f64 = 3.0;
751    pub const DEFAULT_BPT_HIGH: f64 = 5.0;
752
753    /// Create estimation from source byte count using default divisors.
754    ///
755    /// # Examples
756    ///
757    /// ```
758    /// use tokmd_types::TokenEstimationMeta;
759    ///
760    /// let est = TokenEstimationMeta::from_bytes(4000, 4.0);
761    /// assert_eq!(est.tokens_est, 1000);
762    /// assert_eq!(est.source_bytes, 4000);
763    /// // Invariant: tokens_min <= tokens_est <= tokens_max
764    /// assert!(est.tokens_min <= est.tokens_est);
765    /// assert!(est.tokens_est <= est.tokens_max);
766    /// ```
767    pub fn from_bytes(bytes: usize, bpt: f64) -> Self {
768        Self::from_bytes_with_bounds(bytes, bpt, Self::DEFAULT_BPT_LOW, Self::DEFAULT_BPT_HIGH)
769    }
770
771    /// Create estimation from source byte count with explicit low/high divisors.
772    pub fn from_bytes_with_bounds(bytes: usize, bpt_est: f64, bpt_low: f64, bpt_high: f64) -> Self {
773        Self {
774            bytes_per_token_est: bpt_est,
775            bytes_per_token_low: bpt_low,
776            bytes_per_token_high: bpt_high,
777            tokens_min: (bytes as f64 / bpt_high).ceil() as usize,
778            tokens_est: (bytes as f64 / bpt_est).ceil() as usize,
779            tokens_max: (bytes as f64 / bpt_low).ceil() as usize,
780            source_bytes: bytes,
781        }
782    }
783}
784
785/// Post-write audit comparing actual output to estimates.
786#[derive(Debug, Clone, Serialize, Deserialize)]
787pub struct TokenAudit {
788    /// Actual bytes written to the output bundle.
789    pub output_bytes: u64,
790    /// tokens = output_bytes / bytes_per_token_high (optimistic, fewest tokens).
791    #[serde(alias = "tokens_high")]
792    pub tokens_min: usize,
793    /// tokens = output_bytes / bytes_per_token_est.
794    pub tokens_est: usize,
795    /// tokens = output_bytes / bytes_per_token_low (conservative, most tokens).
796    #[serde(alias = "tokens_low")]
797    pub tokens_max: usize,
798    /// Bytes of framing/separators/headers (output_bytes - content_bytes).
799    pub overhead_bytes: u64,
800    /// overhead_bytes / output_bytes (0.0-1.0).
801    pub overhead_pct: f64,
802}
803
804impl TokenAudit {
805    /// Create an audit from output bytes and content bytes.
806    ///
807    /// # Examples
808    ///
809    /// ```
810    /// use tokmd_types::TokenAudit;
811    ///
812    /// let audit = TokenAudit::from_output(5000, 4500);
813    /// assert_eq!(audit.output_bytes, 5000);
814    /// assert_eq!(audit.overhead_bytes, 500);
815    /// assert!(audit.overhead_pct > 0.0);
816    /// ```
817    pub fn from_output(output_bytes: u64, content_bytes: u64) -> Self {
818        Self::from_output_with_divisors(
819            output_bytes,
820            content_bytes,
821            TokenEstimationMeta::DEFAULT_BPT_EST,
822            TokenEstimationMeta::DEFAULT_BPT_LOW,
823            TokenEstimationMeta::DEFAULT_BPT_HIGH,
824        )
825    }
826
827    /// Create an audit from output bytes with explicit divisors.
828    pub fn from_output_with_divisors(
829        output_bytes: u64,
830        content_bytes: u64,
831        bpt_est: f64,
832        bpt_low: f64,
833        bpt_high: f64,
834    ) -> Self {
835        let overhead_bytes = output_bytes.saturating_sub(content_bytes);
836        let overhead_pct = if output_bytes > 0 {
837            overhead_bytes as f64 / output_bytes as f64
838        } else {
839            0.0
840        };
841        Self {
842            output_bytes,
843            tokens_min: (output_bytes as f64 / bpt_high).ceil() as usize,
844            tokens_est: (output_bytes as f64 / bpt_est).ceil() as usize,
845            tokens_max: (output_bytes as f64 / bpt_low).ceil() as usize,
846            overhead_bytes,
847            overhead_pct,
848        }
849    }
850}
851
852// -----------------------
853// Bundle hygiene types
854// -----------------------
855
856/// Classification of a file for bundle hygiene purposes.
857#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
858#[serde(rename_all = "snake_case")]
859pub enum FileClassification {
860    /// Protobuf output, parser tables, node-types.json, etc.
861    Generated,
862    /// Test fixtures, golden snapshots.
863    Fixture,
864    /// Third-party vendored code.
865    Vendored,
866    /// Cargo.lock, package-lock.json, etc.
867    Lockfile,
868    /// *.min.js, *.min.css.
869    Minified,
870    /// Files with very high tokens-per-line ratio.
871    DataBlob,
872    /// *.js.map, *.css.map.
873    Sourcemap,
874}
875
876/// How a file is included in the context/handoff bundle.
877#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
878#[serde(rename_all = "snake_case")]
879pub enum InclusionPolicy {
880    /// Full file content.
881    #[default]
882    Full,
883    /// First N + last N lines.
884    HeadTail,
885    /// Structural summary (placeholder, behaves as Skip for now).
886    Summary,
887    /// Excluded from payload entirely.
888    Skip,
889}
890
891/// Helper for serde skip_serializing_if on InclusionPolicy.
892fn is_default_policy(policy: &InclusionPolicy) -> bool {
893    *policy == InclusionPolicy::Full
894}
895
896/// A file excluded by per-file cap / classification policy.
897#[derive(Debug, Clone, Serialize, Deserialize)]
898pub struct PolicyExcludedFile {
899    pub path: String,
900    pub original_tokens: usize,
901    pub policy: InclusionPolicy,
902    pub reason: String,
903    pub classifications: Vec<FileClassification>,
904}
905
906/// Manifest for a handoff bundle containing LLM-ready artifacts.
907#[derive(Debug, Clone, Serialize, Deserialize)]
908pub struct HandoffManifest {
909    pub schema_version: u32,
910    pub generated_at_ms: u128,
911    pub tool: ToolInfo,
912    pub mode: String,
913    pub inputs: Vec<String>,
914    pub output_dir: String,
915    pub budget_tokens: usize,
916    pub used_tokens: usize,
917    pub utilization_pct: f64,
918    pub strategy: String,
919    pub rank_by: String,
920    pub capabilities: Vec<CapabilityStatus>,
921    pub artifacts: Vec<ArtifactEntry>,
922    pub included_files: Vec<ContextFileRow>,
923    pub excluded_paths: Vec<HandoffExcludedPath>,
924    pub excluded_patterns: Vec<String>,
925    pub smart_excluded_files: Vec<SmartExcludedFile>,
926    pub total_files: usize,
927    pub bundled_files: usize,
928    pub intelligence_preset: String,
929    /// Effective ranking metric (may differ from rank_by if fallback occurred).
930    #[serde(default, skip_serializing_if = "Option::is_none")]
931    pub rank_by_effective: Option<String>,
932    /// Reason for fallback if rank_by_effective differs from rank_by.
933    #[serde(default, skip_serializing_if = "Option::is_none")]
934    pub fallback_reason: Option<String>,
935    /// Files excluded by per-file cap / classification policy.
936    #[serde(default, skip_serializing_if = "Vec::is_empty")]
937    pub excluded_by_policy: Vec<PolicyExcludedFile>,
938    /// Token estimation envelope with uncertainty bounds.
939    #[serde(default, skip_serializing_if = "Option::is_none")]
940    pub token_estimation: Option<TokenEstimationMeta>,
941    /// Post-bundle audit comparing actual code bundle bytes to estimates.
942    #[serde(default, skip_serializing_if = "Option::is_none")]
943    pub code_audit: Option<TokenAudit>,
944}
945
946/// A file excluded by smart-exclude heuristics (lockfiles, minified, etc.).
947#[derive(Debug, Clone, Serialize, Deserialize)]
948pub struct SmartExcludedFile {
949    pub path: String,
950    pub reason: String,
951    pub tokens: usize,
952}
953
954/// Manifest for a context bundle directory (bundle.txt + receipt.json + manifest.json).
955#[derive(Debug, Clone, Serialize, Deserialize)]
956pub struct ContextBundleManifest {
957    pub schema_version: u32,
958    pub generated_at_ms: u128,
959    pub tool: ToolInfo,
960    pub mode: String,
961    pub budget_tokens: usize,
962    pub used_tokens: usize,
963    pub utilization_pct: f64,
964    pub strategy: String,
965    pub rank_by: String,
966    pub file_count: usize,
967    pub bundle_bytes: usize,
968    pub artifacts: Vec<ArtifactEntry>,
969    pub included_files: Vec<ContextFileRow>,
970    pub excluded_paths: Vec<ContextExcludedPath>,
971    pub excluded_patterns: Vec<String>,
972    /// Effective ranking metric (may differ from rank_by if fallback occurred).
973    #[serde(default, skip_serializing_if = "Option::is_none")]
974    pub rank_by_effective: Option<String>,
975    /// Reason for fallback if rank_by_effective differs from rank_by.
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub fallback_reason: Option<String>,
978    /// Files excluded by per-file cap / classification policy.
979    #[serde(default, skip_serializing_if = "Vec::is_empty")]
980    pub excluded_by_policy: Vec<PolicyExcludedFile>,
981    /// Token estimation envelope with uncertainty bounds.
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    pub token_estimation: Option<TokenEstimationMeta>,
984    /// Post-bundle audit comparing actual bundle bytes to estimates.
985    #[serde(default, skip_serializing_if = "Option::is_none")]
986    pub bundle_audit: Option<TokenAudit>,
987}
988
989/// Explicitly excluded path with reason for context bundles.
990#[derive(Debug, Clone, Serialize, Deserialize)]
991pub struct ContextExcludedPath {
992    pub path: String,
993    pub reason: String,
994}
995
996/// Intelligence bundle for handoff containing tree, hotspots, complexity, and derived metrics.
997#[derive(Debug, Clone, Serialize, Deserialize)]
998pub struct HandoffIntelligence {
999    pub tree: Option<String>,
1000    pub tree_depth: Option<usize>,
1001    pub hotspots: Option<Vec<HandoffHotspot>>,
1002    pub complexity: Option<HandoffComplexity>,
1003    pub derived: Option<HandoffDerived>,
1004    pub warnings: Vec<String>,
1005}
1006
1007/// Explicitly excluded path with reason.
1008#[derive(Debug, Clone, Serialize, Deserialize)]
1009pub struct HandoffExcludedPath {
1010    pub path: String,
1011    pub reason: String,
1012}
1013
1014/// Simplified hotspot row for handoff intelligence.
1015#[derive(Debug, Clone, Serialize, Deserialize)]
1016pub struct HandoffHotspot {
1017    pub path: String,
1018    pub commits: usize,
1019    pub lines: usize,
1020    pub score: usize,
1021}
1022
1023/// Simplified complexity report for handoff intelligence.
1024#[derive(Debug, Clone, Serialize, Deserialize)]
1025pub struct HandoffComplexity {
1026    pub total_functions: usize,
1027    pub avg_function_length: f64,
1028    pub max_function_length: usize,
1029    pub avg_cyclomatic: f64,
1030    pub max_cyclomatic: usize,
1031    pub high_risk_files: usize,
1032}
1033
1034/// Simplified derived metrics for handoff intelligence.
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1036pub struct HandoffDerived {
1037    pub total_files: usize,
1038    pub total_code: usize,
1039    pub total_lines: usize,
1040    pub total_tokens: usize,
1041    pub lang_count: usize,
1042    pub dominant_lang: String,
1043    pub dominant_pct: f64,
1044}
1045
1046/// Status of a detected capability.
1047#[derive(Debug, Clone, Serialize, Deserialize)]
1048pub struct CapabilityStatus {
1049    pub name: String,
1050    pub status: CapabilityState,
1051    #[serde(skip_serializing_if = "Option::is_none")]
1052    pub reason: Option<String>,
1053}
1054
1055/// State of a capability: available, skipped, or unavailable.
1056#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1057#[serde(rename_all = "snake_case")]
1058pub enum CapabilityState {
1059    /// Capability is available and was used.
1060    Available,
1061    /// Capability is available but was skipped (e.g., --no-git flag).
1062    Skipped,
1063    /// Capability is unavailable (e.g., not in a git repo).
1064    Unavailable,
1065}
1066
1067/// Entry describing an artifact in the handoff bundle.
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1069pub struct ArtifactEntry {
1070    pub name: String,
1071    pub path: String,
1072    pub description: String,
1073    pub bytes: u64,
1074    #[serde(skip_serializing_if = "Option::is_none")]
1075    pub hash: Option<ArtifactHash>,
1076}
1077
1078/// Hash for artifact integrity.
1079#[derive(Debug, Clone, Serialize, Deserialize)]
1080pub struct ArtifactHash {
1081    pub algo: String,
1082    pub hash: String,
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088
1089    // ── Schema version constants ──────────────────────────────────────
1090    #[test]
1091    fn schema_version_constants() {
1092        assert_eq!(SCHEMA_VERSION, 2);
1093        assert_eq!(HANDOFF_SCHEMA_VERSION, 5);
1094        assert_eq!(CONTEXT_BUNDLE_SCHEMA_VERSION, 2);
1095        assert_eq!(CONTEXT_SCHEMA_VERSION, 4);
1096    }
1097
1098    // ── Default impls ─────────────────────────────────────────────────
1099    #[test]
1100    fn config_mode_default_is_auto() {
1101        assert_eq!(ConfigMode::default(), ConfigMode::Auto);
1102    }
1103
1104    #[test]
1105    fn inclusion_policy_default_is_full() {
1106        assert_eq!(InclusionPolicy::default(), InclusionPolicy::Full);
1107    }
1108
1109    #[test]
1110    fn diff_totals_default_is_zeroed() {
1111        let dt = DiffTotals::default();
1112        assert_eq!(dt.old_code, 0);
1113        assert_eq!(dt.new_code, 0);
1114        assert_eq!(dt.delta_code, 0);
1115        assert_eq!(dt.delta_tokens, 0);
1116    }
1117
1118    #[test]
1119    fn tool_info_default_is_empty() {
1120        let ti = ToolInfo::default();
1121        assert!(ti.name.is_empty());
1122        assert!(ti.version.is_empty());
1123    }
1124
1125    #[test]
1126    fn tool_info_current() {
1127        let ti = ToolInfo::current();
1128        assert_eq!(ti.name, "tokmd");
1129        assert!(!ti.version.is_empty());
1130    }
1131
1132    // ── Serde roundtrips for enums ────────────────────────────────────
1133    #[test]
1134    fn table_format_serde_roundtrip() {
1135        for variant in [TableFormat::Md, TableFormat::Tsv, TableFormat::Json] {
1136            let json = serde_json::to_string(&variant).unwrap();
1137            let back: TableFormat = serde_json::from_str(&json).unwrap();
1138            assert_eq!(back, variant);
1139        }
1140    }
1141
1142    #[test]
1143    fn export_format_serde_roundtrip() {
1144        for variant in [
1145            ExportFormat::Csv,
1146            ExportFormat::Jsonl,
1147            ExportFormat::Json,
1148            ExportFormat::Cyclonedx,
1149        ] {
1150            let json = serde_json::to_string(&variant).unwrap();
1151            let back: ExportFormat = serde_json::from_str(&json).unwrap();
1152            assert_eq!(back, variant);
1153        }
1154    }
1155
1156    #[test]
1157    fn config_mode_serde_roundtrip() {
1158        for variant in [ConfigMode::Auto, ConfigMode::None] {
1159            let json = serde_json::to_string(&variant).unwrap();
1160            let back: ConfigMode = serde_json::from_str(&json).unwrap();
1161            assert_eq!(back, variant);
1162        }
1163    }
1164
1165    #[test]
1166    fn children_mode_serde_roundtrip() {
1167        for variant in [ChildrenMode::Collapse, ChildrenMode::Separate] {
1168            let json = serde_json::to_string(&variant).unwrap();
1169            let back: ChildrenMode = serde_json::from_str(&json).unwrap();
1170            assert_eq!(back, variant);
1171        }
1172    }
1173
1174    #[test]
1175    fn redact_mode_serde_roundtrip() {
1176        for variant in [RedactMode::None, RedactMode::Paths, RedactMode::All] {
1177            let json = serde_json::to_string(&variant).unwrap();
1178            let back: RedactMode = serde_json::from_str(&json).unwrap();
1179            assert_eq!(back, variant);
1180        }
1181    }
1182
1183    #[test]
1184    fn file_kind_serde_roundtrip() {
1185        for variant in [FileKind::Parent, FileKind::Child] {
1186            let json = serde_json::to_string(&variant).unwrap();
1187            let back: FileKind = serde_json::from_str(&json).unwrap();
1188            assert_eq!(back, variant);
1189        }
1190    }
1191
1192    #[test]
1193    fn scan_status_serde_roundtrip() {
1194        let json = serde_json::to_string(&ScanStatus::Complete).unwrap();
1195        assert_eq!(json, "\"complete\"");
1196        let back: ScanStatus = serde_json::from_str(&json).unwrap();
1197        assert!(matches!(back, ScanStatus::Complete));
1198    }
1199
1200    #[test]
1201    fn file_classification_serde_roundtrip() {
1202        for variant in [
1203            FileClassification::Generated,
1204            FileClassification::Fixture,
1205            FileClassification::Vendored,
1206            FileClassification::Lockfile,
1207            FileClassification::Minified,
1208            FileClassification::DataBlob,
1209            FileClassification::Sourcemap,
1210        ] {
1211            let json = serde_json::to_string(&variant).unwrap();
1212            let back: FileClassification = serde_json::from_str(&json).unwrap();
1213            assert_eq!(back, variant);
1214        }
1215    }
1216
1217    #[test]
1218    fn inclusion_policy_serde_roundtrip() {
1219        for variant in [
1220            InclusionPolicy::Full,
1221            InclusionPolicy::HeadTail,
1222            InclusionPolicy::Summary,
1223            InclusionPolicy::Skip,
1224        ] {
1225            let json = serde_json::to_string(&variant).unwrap();
1226            let back: InclusionPolicy = serde_json::from_str(&json).unwrap();
1227            assert_eq!(back, variant);
1228        }
1229    }
1230
1231    #[test]
1232    fn capability_state_serde_roundtrip() {
1233        for variant in [
1234            CapabilityState::Available,
1235            CapabilityState::Skipped,
1236            CapabilityState::Unavailable,
1237        ] {
1238            let json = serde_json::to_string(&variant).unwrap();
1239            let back: CapabilityState = serde_json::from_str(&json).unwrap();
1240            assert_eq!(back, variant);
1241        }
1242    }
1243
1244    #[test]
1245    fn analysis_format_serde_roundtrip() {
1246        for variant in [
1247            AnalysisFormat::Md,
1248            AnalysisFormat::Json,
1249            AnalysisFormat::Jsonld,
1250            AnalysisFormat::Xml,
1251            AnalysisFormat::Svg,
1252            AnalysisFormat::Mermaid,
1253            AnalysisFormat::Obj,
1254            AnalysisFormat::Midi,
1255            AnalysisFormat::Tree,
1256            AnalysisFormat::Html,
1257        ] {
1258            let json = serde_json::to_string(&variant).unwrap();
1259            let back: AnalysisFormat = serde_json::from_str(&json).unwrap();
1260            assert_eq!(back, variant);
1261        }
1262    }
1263
1264    #[test]
1265    fn commit_intent_kind_serde_roundtrip() {
1266        for variant in [
1267            CommitIntentKind::Feat,
1268            CommitIntentKind::Fix,
1269            CommitIntentKind::Refactor,
1270            CommitIntentKind::Docs,
1271            CommitIntentKind::Test,
1272            CommitIntentKind::Chore,
1273            CommitIntentKind::Ci,
1274            CommitIntentKind::Other,
1275        ] {
1276            let json = serde_json::to_string(&variant).unwrap();
1277            let back: CommitIntentKind = serde_json::from_str(&json).unwrap();
1278            assert_eq!(back, variant);
1279        }
1280    }
1281
1282    // ── is_default_policy helper ──────────────────────────────────────
1283    #[test]
1284    fn is_default_policy_works() {
1285        assert!(is_default_policy(&InclusionPolicy::Full));
1286        assert!(!is_default_policy(&InclusionPolicy::Skip));
1287        assert!(!is_default_policy(&InclusionPolicy::Summary));
1288        assert!(!is_default_policy(&InclusionPolicy::HeadTail));
1289    }
1290
1291    // ── Struct serde roundtrips ───────────────────────────────────────
1292    #[test]
1293    fn totals_serde_roundtrip() {
1294        let t = Totals {
1295            code: 100,
1296            lines: 200,
1297            files: 10,
1298            bytes: 5000,
1299            tokens: 250,
1300            avg_lines: 20,
1301        };
1302        let json = serde_json::to_string(&t).unwrap();
1303        let back: Totals = serde_json::from_str(&json).unwrap();
1304        assert_eq!(back, t);
1305    }
1306
1307    #[test]
1308    fn lang_row_serde_roundtrip() {
1309        let r = LangRow {
1310            lang: "Rust".into(),
1311            code: 100,
1312            lines: 150,
1313            files: 5,
1314            bytes: 3000,
1315            tokens: 200,
1316            avg_lines: 30,
1317        };
1318        let json = serde_json::to_string(&r).unwrap();
1319        let back: LangRow = serde_json::from_str(&json).unwrap();
1320        assert_eq!(back, r);
1321    }
1322
1323    #[test]
1324    fn diff_row_serde_roundtrip() {
1325        let r = DiffRow {
1326            lang: "Rust".into(),
1327            old_code: 100,
1328            new_code: 120,
1329            delta_code: 20,
1330            old_lines: 200,
1331            new_lines: 220,
1332            delta_lines: 20,
1333            old_files: 10,
1334            new_files: 11,
1335            delta_files: 1,
1336            old_bytes: 5000,
1337            new_bytes: 6000,
1338            delta_bytes: 1000,
1339            old_tokens: 250,
1340            new_tokens: 300,
1341            delta_tokens: 50,
1342        };
1343        let json = serde_json::to_string(&r).unwrap();
1344        let back: DiffRow = serde_json::from_str(&json).unwrap();
1345        assert_eq!(back, r);
1346    }
1347
1348    #[test]
1349    fn diff_totals_serde_roundtrip() {
1350        let t = DiffTotals {
1351            old_code: 100,
1352            new_code: 120,
1353            delta_code: 20,
1354            ..DiffTotals::default()
1355        };
1356        let json = serde_json::to_string(&t).unwrap();
1357        let back: DiffTotals = serde_json::from_str(&json).unwrap();
1358        assert_eq!(back, t);
1359    }
1360
1361    // ── TokenEstimationMeta ───────────────────────────────────────────
1362    #[test]
1363    fn token_estimation_from_bytes_defaults() {
1364        let est = TokenEstimationMeta::from_bytes(4000, TokenEstimationMeta::DEFAULT_BPT_EST);
1365        assert_eq!(est.source_bytes, 4000);
1366        assert_eq!(est.tokens_est, 1000); // 4000 / 4.0
1367        // tokens_min uses bpt_high=5.0 → 4000/5.0 = 800
1368        assert_eq!(est.tokens_min, 800);
1369        // tokens_max uses bpt_low=3.0 → ceil(4000/3.0) = 1334
1370        assert_eq!(est.tokens_max, 1334);
1371    }
1372
1373    #[test]
1374    fn token_estimation_invariant_min_le_est_le_max() {
1375        let est = TokenEstimationMeta::from_bytes(12345, 4.0);
1376        assert!(est.tokens_min <= est.tokens_est);
1377        assert!(est.tokens_est <= est.tokens_max);
1378    }
1379
1380    #[test]
1381    fn token_estimation_zero_bytes() {
1382        let est = TokenEstimationMeta::from_bytes(0, 4.0);
1383        assert_eq!(est.tokens_min, 0);
1384        assert_eq!(est.tokens_est, 0);
1385        assert_eq!(est.tokens_max, 0);
1386    }
1387
1388    #[test]
1389    fn token_estimation_with_custom_bounds() {
1390        let est = TokenEstimationMeta::from_bytes_with_bounds(1000, 4.0, 2.0, 8.0);
1391        assert_eq!(est.bytes_per_token_est, 4.0);
1392        assert_eq!(est.bytes_per_token_low, 2.0);
1393        assert_eq!(est.bytes_per_token_high, 8.0);
1394        assert_eq!(est.tokens_est, 250); // 1000 / 4.0
1395        assert_eq!(est.tokens_min, 125); // 1000 / 8.0
1396        assert_eq!(est.tokens_max, 500); // 1000 / 2.0
1397    }
1398
1399    // ── TokenAudit ────────────────────────────────────────────────────
1400    #[test]
1401    fn token_audit_from_output_basic() {
1402        let audit = TokenAudit::from_output(1000, 800);
1403        assert_eq!(audit.output_bytes, 1000);
1404        assert_eq!(audit.overhead_bytes, 200);
1405        assert!((audit.overhead_pct - 0.2).abs() < f64::EPSILON);
1406    }
1407
1408    #[test]
1409    fn token_audit_from_output_with_divisors() {
1410        let audit = TokenAudit::from_output_with_divisors(1000, 800, 4.0, 2.0, 8.0);
1411
1412        assert_eq!(audit.output_bytes, 1000);
1413        assert_eq!(audit.overhead_bytes, 200);
1414        assert_eq!(audit.tokens_est, 250);
1415        assert_eq!(audit.tokens_min, 125);
1416        assert_eq!(audit.tokens_max, 500);
1417    }
1418
1419    #[test]
1420    fn token_audit_zero_output() {
1421        let audit = TokenAudit::from_output(0, 0);
1422        assert_eq!(audit.output_bytes, 0);
1423        assert_eq!(audit.overhead_bytes, 0);
1424        assert_eq!(audit.overhead_pct, 0.0);
1425    }
1426
1427    #[test]
1428    fn token_audit_content_exceeds_output() {
1429        // content_bytes > output_bytes should saturate to 0 overhead
1430        let audit = TokenAudit::from_output(100, 200);
1431        assert_eq!(audit.overhead_bytes, 0);
1432        assert_eq!(audit.overhead_pct, 0.0);
1433    }
1434
1435    #[test]
1436    fn token_audit_serde_roundtrip() {
1437        let audit = TokenAudit::from_output(5000, 4500);
1438        let json = serde_json::to_string(&audit).unwrap();
1439        let back: TokenAudit = serde_json::from_str(&json).unwrap();
1440        assert_eq!(back.output_bytes, 5000);
1441        assert_eq!(back.overhead_bytes, 500);
1442    }
1443
1444    // ── Kebab-case serde naming ───────────────────────────────────────
1445    #[test]
1446    fn table_format_uses_kebab_case() {
1447        assert_eq!(serde_json::to_string(&TableFormat::Md).unwrap(), "\"md\"");
1448        assert_eq!(serde_json::to_string(&TableFormat::Tsv).unwrap(), "\"tsv\"");
1449    }
1450
1451    #[test]
1452    fn export_format_uses_kebab_case() {
1453        assert_eq!(
1454            serde_json::to_string(&ExportFormat::Cyclonedx).unwrap(),
1455            "\"cyclonedx\""
1456        );
1457    }
1458
1459    #[test]
1460    fn redact_mode_uses_kebab_case() {
1461        assert_eq!(
1462            serde_json::to_string(&RedactMode::Paths).unwrap(),
1463            "\"paths\""
1464        );
1465    }
1466
1467    // ── FileRow serde roundtrip ───────────────────────────────────────
1468    #[test]
1469    fn file_row_serde_roundtrip() {
1470        let r = FileRow {
1471            path: "src/main.rs".into(),
1472            module: "src".into(),
1473            lang: "Rust".into(),
1474            kind: FileKind::Parent,
1475            code: 50,
1476            comments: 10,
1477            blanks: 5,
1478            lines: 65,
1479            bytes: 2000,
1480            tokens: 100,
1481        };
1482        let json = serde_json::to_string(&r).unwrap();
1483        let back: FileRow = serde_json::from_str(&json).unwrap();
1484        assert_eq!(back, r);
1485    }
1486}
1487
1488#[cfg(doctest)]
1489#[doc = include_str!("../README.md")]
1490pub mod readme_doctests {}