Skip to main content

tokmd_types/
context.rs

1//! Context and handoff receipt DTOs.
2//!
3//! This module owns the serde-stable context packing and handoff artifact
4//! contracts. Public consumers should keep using the root-level re-exports
5//! from `tokmd_types`.
6
7use serde::{Deserialize, Serialize};
8
9use crate::ToolInfo;
10
11/// Schema version for handoff receipts.
12///
13/// ```
14/// assert_eq!(tokmd_types::HANDOFF_SCHEMA_VERSION, 5);
15/// ```
16pub const HANDOFF_SCHEMA_VERSION: u32 = 5;
17
18/// Schema version for context bundle manifests.
19///
20/// ```
21/// assert_eq!(tokmd_types::CONTEXT_BUNDLE_SCHEMA_VERSION, 2);
22/// ```
23pub const CONTEXT_BUNDLE_SCHEMA_VERSION: u32 = 2;
24
25/// Schema version for context receipts (separate from SCHEMA_VERSION used by lang/module/export/diff).
26///
27/// ```
28/// assert_eq!(tokmd_types::CONTEXT_SCHEMA_VERSION, 4);
29/// ```
30pub const CONTEXT_SCHEMA_VERSION: u32 = 4;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ContextReceipt {
34    pub schema_version: u32,
35    pub generated_at_ms: u128,
36    pub tool: ToolInfo,
37    pub mode: String,
38    pub budget_tokens: usize,
39    pub used_tokens: usize,
40    pub utilization_pct: f64,
41    pub strategy: String,
42    pub rank_by: String,
43    pub file_count: usize,
44    pub files: Vec<ContextFileRow>,
45    /// Effective ranking metric (may differ from rank_by if fallback occurred).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub rank_by_effective: Option<String>,
48    /// Reason for fallback if rank_by_effective differs from rank_by.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub fallback_reason: Option<String>,
51    /// Files excluded by per-file cap / classification policy.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub excluded_by_policy: Vec<PolicyExcludedFile>,
54    /// Token estimation envelope with uncertainty bounds.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub token_estimation: Option<TokenEstimationMeta>,
57    /// Post-bundle audit comparing actual bytes to estimates.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub bundle_audit: Option<TokenAudit>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ContextFileRow {
64    pub path: String,
65    pub module: String,
66    pub lang: String,
67    pub tokens: usize,
68    pub code: usize,
69    pub lines: usize,
70    pub bytes: usize,
71    pub value: usize,
72    #[serde(default, skip_serializing_if = "String::is_empty")]
73    pub rank_reason: String,
74    /// Inclusion policy applied to this file.
75    #[serde(default, skip_serializing_if = "is_default_policy")]
76    pub policy: InclusionPolicy,
77    /// Effective token count when policy != Full (None means same as `tokens`).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub effective_tokens: Option<usize>,
80    /// Reason for the applied policy.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub policy_reason: Option<String>,
83    /// File classifications detected by hygiene analysis.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub classifications: Vec<FileClassification>,
86}
87
88/// Log record for context command JSONL append mode.
89/// Contains metadata only (not file contents) for lightweight logging.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ContextLogRecord {
92    pub schema_version: u32,
93    pub generated_at_ms: u128,
94    pub tool: ToolInfo,
95    pub budget_tokens: usize,
96    pub used_tokens: usize,
97    pub utilization_pct: f64,
98    pub strategy: String,
99    pub rank_by: String,
100    pub file_count: usize,
101    pub total_bytes: usize,
102    pub output_destination: String,
103}
104
105/// Metadata about how token estimates were produced.
106///
107/// Rails are NOT guaranteed bounds - they are heuristic fences.
108/// Default divisors: est=4.0, low=3.0 (conservative -> more tokens),
109/// high=5.0 (optimistic -> fewer tokens).
110///
111/// **Invariant**: `tokens_min <= tokens_est <= tokens_max`.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct TokenEstimationMeta {
114    /// Divisor used for main estimate (default 4.0).
115    pub bytes_per_token_est: f64,
116    /// Conservative divisor - more tokens (default 3.0).
117    pub bytes_per_token_low: f64,
118    /// Optimistic divisor - fewer tokens (default 5.0).
119    pub bytes_per_token_high: f64,
120    /// tokens = source_bytes / bytes_per_token_high (optimistic, fewest tokens).
121    #[serde(alias = "tokens_high")]
122    pub tokens_min: usize,
123    /// tokens = source_bytes / bytes_per_token_est.
124    pub tokens_est: usize,
125    /// tokens = source_bytes / bytes_per_token_low (conservative, most tokens).
126    #[serde(alias = "tokens_low")]
127    pub tokens_max: usize,
128    /// Total source bytes used to compute estimates.
129    pub source_bytes: usize,
130}
131
132impl TokenEstimationMeta {
133    /// Default bytes-per-token divisors.
134    pub const DEFAULT_BPT_EST: f64 = 4.0;
135    pub const DEFAULT_BPT_LOW: f64 = 3.0;
136    pub const DEFAULT_BPT_HIGH: f64 = 5.0;
137
138    /// Create estimation from source byte count using default divisors.
139    ///
140    /// # Examples
141    ///
142    /// ```
143    /// use tokmd_types::TokenEstimationMeta;
144    ///
145    /// let est = TokenEstimationMeta::from_bytes(4000, 4.0);
146    /// assert_eq!(est.tokens_est, 1000);
147    /// assert_eq!(est.source_bytes, 4000);
148    /// // Invariant: tokens_min <= tokens_est <= tokens_max
149    /// assert!(est.tokens_min <= est.tokens_est);
150    /// assert!(est.tokens_est <= est.tokens_max);
151    /// ```
152    pub fn from_bytes(bytes: usize, bpt: f64) -> Self {
153        Self::from_bytes_with_bounds(bytes, bpt, Self::DEFAULT_BPT_LOW, Self::DEFAULT_BPT_HIGH)
154    }
155
156    /// Create estimation from source byte count with explicit low/high divisors.
157    pub fn from_bytes_with_bounds(bytes: usize, bpt_est: f64, bpt_low: f64, bpt_high: f64) -> Self {
158        Self {
159            bytes_per_token_est: bpt_est,
160            bytes_per_token_low: bpt_low,
161            bytes_per_token_high: bpt_high,
162            tokens_min: (bytes as f64 / bpt_high).ceil() as usize,
163            tokens_est: (bytes as f64 / bpt_est).ceil() as usize,
164            tokens_max: (bytes as f64 / bpt_low).ceil() as usize,
165            source_bytes: bytes,
166        }
167    }
168}
169
170/// Post-write audit comparing actual output to estimates.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct TokenAudit {
173    /// Actual bytes written to the output bundle.
174    pub output_bytes: u64,
175    /// tokens = output_bytes / bytes_per_token_high (optimistic, fewest tokens).
176    #[serde(alias = "tokens_high")]
177    pub tokens_min: usize,
178    /// tokens = output_bytes / bytes_per_token_est.
179    pub tokens_est: usize,
180    /// tokens = output_bytes / bytes_per_token_low (conservative, most tokens).
181    #[serde(alias = "tokens_low")]
182    pub tokens_max: usize,
183    /// Bytes of framing/separators/headers (output_bytes - content_bytes).
184    pub overhead_bytes: u64,
185    /// overhead_bytes / output_bytes (0.0-1.0).
186    pub overhead_pct: f64,
187}
188
189impl TokenAudit {
190    /// Create an audit from output bytes and content bytes.
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// use tokmd_types::TokenAudit;
196    ///
197    /// let audit = TokenAudit::from_output(5000, 4500);
198    /// assert_eq!(audit.output_bytes, 5000);
199    /// assert_eq!(audit.overhead_bytes, 500);
200    /// assert!(audit.overhead_pct > 0.0);
201    /// ```
202    pub fn from_output(output_bytes: u64, content_bytes: u64) -> Self {
203        Self::from_output_with_divisors(
204            output_bytes,
205            content_bytes,
206            TokenEstimationMeta::DEFAULT_BPT_EST,
207            TokenEstimationMeta::DEFAULT_BPT_LOW,
208            TokenEstimationMeta::DEFAULT_BPT_HIGH,
209        )
210    }
211
212    /// Create an audit from output bytes with explicit divisors.
213    pub fn from_output_with_divisors(
214        output_bytes: u64,
215        content_bytes: u64,
216        bpt_est: f64,
217        bpt_low: f64,
218        bpt_high: f64,
219    ) -> Self {
220        let overhead_bytes = output_bytes.saturating_sub(content_bytes);
221        let overhead_pct = if output_bytes > 0 {
222            overhead_bytes as f64 / output_bytes as f64
223        } else {
224            0.0
225        };
226        Self {
227            output_bytes,
228            tokens_min: (output_bytes as f64 / bpt_high).ceil() as usize,
229            tokens_est: (output_bytes as f64 / bpt_est).ceil() as usize,
230            tokens_max: (output_bytes as f64 / bpt_low).ceil() as usize,
231            overhead_bytes,
232            overhead_pct,
233        }
234    }
235}
236
237/// Classification of a file for bundle hygiene purposes.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum FileClassification {
241    /// Protobuf output, parser tables, node-types.json, etc.
242    Generated,
243    /// Test fixtures, golden snapshots.
244    Fixture,
245    /// Third-party vendored code.
246    Vendored,
247    /// Cargo.lock, package-lock.json, etc.
248    Lockfile,
249    /// *.min.js, *.min.css.
250    Minified,
251    /// Files with very high tokens-per-line ratio.
252    DataBlob,
253    /// *.js.map, *.css.map.
254    Sourcemap,
255}
256
257/// How a file is included in the context/handoff bundle.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
259#[serde(rename_all = "snake_case")]
260pub enum InclusionPolicy {
261    /// Full file content.
262    #[default]
263    Full,
264    /// First N + last N lines.
265    HeadTail,
266    /// Structural summary (placeholder, behaves as Skip for now).
267    Summary,
268    /// Excluded from payload entirely.
269    Skip,
270}
271
272/// Helper for serde skip_serializing_if on InclusionPolicy.
273pub(crate) fn is_default_policy(policy: &InclusionPolicy) -> bool {
274    *policy == InclusionPolicy::Full
275}
276
277/// A file excluded by per-file cap / classification policy.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct PolicyExcludedFile {
280    pub path: String,
281    pub original_tokens: usize,
282    pub policy: InclusionPolicy,
283    pub reason: String,
284    pub classifications: Vec<FileClassification>,
285}
286
287/// Manifest for a handoff bundle containing LLM-ready artifacts.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct HandoffManifest {
290    pub schema_version: u32,
291    pub generated_at_ms: u128,
292    pub tool: ToolInfo,
293    pub mode: String,
294    pub inputs: Vec<String>,
295    pub output_dir: String,
296    pub budget_tokens: usize,
297    pub used_tokens: usize,
298    pub utilization_pct: f64,
299    pub strategy: String,
300    pub rank_by: String,
301    pub capabilities: Vec<CapabilityStatus>,
302    pub artifacts: Vec<ArtifactEntry>,
303    pub included_files: Vec<ContextFileRow>,
304    pub excluded_paths: Vec<HandoffExcludedPath>,
305    pub excluded_patterns: Vec<String>,
306    pub smart_excluded_files: Vec<SmartExcludedFile>,
307    pub total_files: usize,
308    pub bundled_files: usize,
309    pub intelligence_preset: String,
310    /// Effective ranking metric (may differ from rank_by if fallback occurred).
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub rank_by_effective: Option<String>,
313    /// Reason for fallback if rank_by_effective differs from rank_by.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub fallback_reason: Option<String>,
316    /// Files excluded by per-file cap / classification policy.
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub excluded_by_policy: Vec<PolicyExcludedFile>,
319    /// Token estimation envelope with uncertainty bounds.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub token_estimation: Option<TokenEstimationMeta>,
322    /// Post-bundle audit comparing actual code bundle bytes to estimates.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub code_audit: Option<TokenAudit>,
325}
326
327/// A file excluded by smart-exclude heuristics (lockfiles, minified, etc.).
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct SmartExcludedFile {
330    pub path: String,
331    pub reason: String,
332    pub tokens: usize,
333}
334
335/// Manifest for a context bundle directory (bundle.txt + receipt.json + manifest.json).
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct ContextBundleManifest {
338    pub schema_version: u32,
339    pub generated_at_ms: u128,
340    pub tool: ToolInfo,
341    pub mode: String,
342    pub budget_tokens: usize,
343    pub used_tokens: usize,
344    pub utilization_pct: f64,
345    pub strategy: String,
346    pub rank_by: String,
347    pub file_count: usize,
348    pub bundle_bytes: usize,
349    pub artifacts: Vec<ArtifactEntry>,
350    pub included_files: Vec<ContextFileRow>,
351    pub excluded_paths: Vec<ContextExcludedPath>,
352    pub excluded_patterns: Vec<String>,
353    /// Effective ranking metric (may differ from rank_by if fallback occurred).
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub rank_by_effective: Option<String>,
356    /// Reason for fallback if rank_by_effective differs from rank_by.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub fallback_reason: Option<String>,
359    /// Files excluded by per-file cap / classification policy.
360    #[serde(default, skip_serializing_if = "Vec::is_empty")]
361    pub excluded_by_policy: Vec<PolicyExcludedFile>,
362    /// Token estimation envelope with uncertainty bounds.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub token_estimation: Option<TokenEstimationMeta>,
365    /// Post-bundle audit comparing actual bundle bytes to estimates.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub bundle_audit: Option<TokenAudit>,
368}
369
370/// Explicitly excluded path with reason for context bundles.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct ContextExcludedPath {
373    pub path: String,
374    pub reason: String,
375}
376
377/// Intelligence bundle for handoff containing tree, hotspots, complexity, and derived metrics.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct HandoffIntelligence {
380    pub tree: Option<String>,
381    pub tree_depth: Option<usize>,
382    pub hotspots: Option<Vec<HandoffHotspot>>,
383    pub complexity: Option<HandoffComplexity>,
384    pub derived: Option<HandoffDerived>,
385    pub warnings: Vec<String>,
386}
387
388/// Explicitly excluded path with reason.
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct HandoffExcludedPath {
391    pub path: String,
392    pub reason: String,
393}
394
395/// Simplified hotspot row for handoff intelligence.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct HandoffHotspot {
398    pub path: String,
399    pub commits: usize,
400    pub lines: usize,
401    pub score: usize,
402}
403
404/// Simplified complexity report for handoff intelligence.
405#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct HandoffComplexity {
407    pub total_functions: usize,
408    pub avg_function_length: f64,
409    pub max_function_length: usize,
410    pub avg_cyclomatic: f64,
411    pub max_cyclomatic: usize,
412    pub high_risk_files: usize,
413}
414
415/// Simplified derived metrics for handoff intelligence.
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct HandoffDerived {
418    pub total_files: usize,
419    pub total_code: usize,
420    pub total_lines: usize,
421    pub total_tokens: usize,
422    pub lang_count: usize,
423    pub dominant_lang: String,
424    pub dominant_pct: f64,
425}
426
427/// Status of a detected capability.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct CapabilityStatus {
430    pub name: String,
431    pub status: CapabilityState,
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub reason: Option<String>,
434}
435
436/// State of a capability: available, skipped, or unavailable.
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
438#[serde(rename_all = "snake_case")]
439pub enum CapabilityState {
440    /// Capability is available and was used.
441    Available,
442    /// Capability is available but was skipped (e.g., --no-git flag).
443    Skipped,
444    /// Capability is unavailable (e.g., not in a git repo).
445    Unavailable,
446}
447
448/// Entry describing an artifact in the handoff bundle.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct ArtifactEntry {
451    pub name: String,
452    pub path: String,
453    pub description: String,
454    pub bytes: u64,
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub hash: Option<ArtifactHash>,
457}
458
459/// Hash for artifact integrity.
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct ArtifactHash {
462    pub algo: String,
463    pub hash: String,
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    fn sample_tool() -> ToolInfo {
471        ToolInfo {
472            name: "tokmd".into(),
473            version: "1.0.0".into(),
474        }
475    }
476
477    fn sample_context_file_row() -> ContextFileRow {
478        ContextFileRow {
479            path: "src/main.rs".into(),
480            module: "src".into(),
481            lang: "Rust".into(),
482            tokens: 100,
483            code: 50,
484            lines: 65,
485            bytes: 2_000,
486            value: 75,
487            rank_reason: String::new(),
488            policy: InclusionPolicy::Full,
489            effective_tokens: None,
490            policy_reason: None,
491            classifications: vec![],
492        }
493    }
494
495    // ── Schema version constants ────────────────────────────────────
496    #[test]
497    fn handoff_schema_version_constant() {
498        assert_eq!(HANDOFF_SCHEMA_VERSION, 5);
499    }
500
501    #[test]
502    fn context_bundle_schema_version_constant() {
503        assert_eq!(CONTEXT_BUNDLE_SCHEMA_VERSION, 2);
504    }
505
506    #[test]
507    fn context_schema_version_constant() {
508        assert_eq!(CONTEXT_SCHEMA_VERSION, 4);
509    }
510
511    // ── ContextReceipt ──────────────────────────────────────────────
512    #[test]
513    fn context_receipt_serde_roundtrip_minimal() {
514        let receipt = ContextReceipt {
515            schema_version: CONTEXT_SCHEMA_VERSION,
516            generated_at_ms: 1_700_000_000_000,
517            tool: sample_tool(),
518            mode: "context".into(),
519            budget_tokens: 8_000,
520            used_tokens: 4_000,
521            utilization_pct: 50.0,
522            strategy: "value-greedy".into(),
523            rank_by: "value".into(),
524            file_count: 1,
525            files: vec![sample_context_file_row()],
526            rank_by_effective: None,
527            fallback_reason: None,
528            excluded_by_policy: vec![],
529            token_estimation: None,
530            bundle_audit: None,
531        };
532        let json = serde_json::to_string(&receipt).unwrap();
533        let back: ContextReceipt = serde_json::from_str(&json).unwrap();
534        assert_eq!(back.schema_version, CONTEXT_SCHEMA_VERSION);
535        assert_eq!(back.mode, "context");
536        assert_eq!(back.files.len(), 1);
537        assert_eq!(back.budget_tokens, 8_000);
538    }
539
540    #[test]
541    fn context_receipt_omits_optional_when_empty_or_none() {
542        let receipt = ContextReceipt {
543            schema_version: CONTEXT_SCHEMA_VERSION,
544            generated_at_ms: 0,
545            tool: sample_tool(),
546            mode: "context".into(),
547            budget_tokens: 0,
548            used_tokens: 0,
549            utilization_pct: 0.0,
550            strategy: String::new(),
551            rank_by: String::new(),
552            file_count: 0,
553            files: vec![],
554            rank_by_effective: None,
555            fallback_reason: None,
556            excluded_by_policy: vec![],
557            token_estimation: None,
558            bundle_audit: None,
559        };
560        let value = serde_json::to_value(&receipt).unwrap();
561        assert!(value.get("rank_by_effective").is_none());
562        assert!(value.get("fallback_reason").is_none());
563        assert!(value.get("excluded_by_policy").is_none());
564        assert!(value.get("token_estimation").is_none());
565        assert!(value.get("bundle_audit").is_none());
566    }
567
568    #[test]
569    fn context_receipt_field_names_stable() {
570        let receipt = ContextReceipt {
571            schema_version: CONTEXT_SCHEMA_VERSION,
572            generated_at_ms: 0,
573            tool: sample_tool(),
574            mode: "context".into(),
575            budget_tokens: 100,
576            used_tokens: 50,
577            utilization_pct: 0.5,
578            strategy: "s".into(),
579            rank_by: "r".into(),
580            file_count: 0,
581            files: vec![],
582            rank_by_effective: None,
583            fallback_reason: None,
584            excluded_by_policy: vec![],
585            token_estimation: None,
586            bundle_audit: None,
587        };
588        let value = serde_json::to_value(&receipt).unwrap();
589        for key in [
590            "schema_version",
591            "generated_at_ms",
592            "tool",
593            "mode",
594            "budget_tokens",
595            "used_tokens",
596            "utilization_pct",
597            "strategy",
598            "rank_by",
599            "file_count",
600            "files",
601        ] {
602            assert!(
603                value.get(key).is_some(),
604                "missing key `{key}` in ContextReceipt"
605            );
606        }
607    }
608
609    // ── ContextFileRow ──────────────────────────────────────────────
610    #[test]
611    fn context_file_row_omits_default_policy() {
612        let row = sample_context_file_row();
613        let value = serde_json::to_value(&row).unwrap();
614        assert!(value.get("policy").is_none());
615        assert!(value.get("rank_reason").is_none());
616        assert!(value.get("effective_tokens").is_none());
617        assert!(value.get("policy_reason").is_none());
618        assert!(value.get("classifications").is_none());
619    }
620
621    #[test]
622    fn context_file_row_keeps_non_default_fields() {
623        let row = ContextFileRow {
624            policy: InclusionPolicy::HeadTail,
625            effective_tokens: Some(40),
626            policy_reason: Some("budget".into()),
627            rank_reason: "high churn".into(),
628            classifications: vec![FileClassification::Generated],
629            ..sample_context_file_row()
630        };
631        let value = serde_json::to_value(&row).unwrap();
632        assert_eq!(value["policy"], "head_tail");
633        assert_eq!(value["effective_tokens"], 40);
634        assert_eq!(value["policy_reason"], "budget");
635        assert_eq!(value["rank_reason"], "high churn");
636        assert_eq!(value["classifications"][0], "generated");
637    }
638
639    #[test]
640    fn context_file_row_serde_roundtrip() {
641        let row = sample_context_file_row();
642        let json = serde_json::to_string(&row).unwrap();
643        let back: ContextFileRow = serde_json::from_str(&json).unwrap();
644        assert_eq!(back.path, row.path);
645        assert_eq!(back.tokens, row.tokens);
646        assert_eq!(back.policy, row.policy);
647    }
648
649    // ── ContextLogRecord ────────────────────────────────────────────
650    #[test]
651    fn context_log_record_serde_roundtrip() {
652        let rec = ContextLogRecord {
653            schema_version: CONTEXT_SCHEMA_VERSION,
654            generated_at_ms: 1_700_000_000_000,
655            tool: sample_tool(),
656            budget_tokens: 10_000,
657            used_tokens: 9_000,
658            utilization_pct: 90.0,
659            strategy: "greedy".into(),
660            rank_by: "value".into(),
661            file_count: 12,
662            total_bytes: 35_000,
663            output_destination: "stdout".into(),
664        };
665        let json = serde_json::to_string(&rec).unwrap();
666        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
667        for key in [
668            "schema_version",
669            "generated_at_ms",
670            "tool",
671            "budget_tokens",
672            "used_tokens",
673            "utilization_pct",
674            "strategy",
675            "rank_by",
676            "file_count",
677            "total_bytes",
678            "output_destination",
679        ] {
680            assert!(
681                value.get(key).is_some(),
682                "missing key `{key}` in ContextLogRecord"
683            );
684        }
685        let back: ContextLogRecord = serde_json::from_str(&json).unwrap();
686        assert_eq!(back.file_count, 12);
687        assert_eq!(back.output_destination, "stdout");
688    }
689
690    // ── TokenEstimationMeta ─────────────────────────────────────────
691    #[test]
692    fn token_estimation_default_divisor_constants() {
693        assert_eq!(TokenEstimationMeta::DEFAULT_BPT_EST, 4.0);
694        assert_eq!(TokenEstimationMeta::DEFAULT_BPT_LOW, 3.0);
695        assert_eq!(TokenEstimationMeta::DEFAULT_BPT_HIGH, 5.0);
696    }
697
698    #[test]
699    fn token_estimation_invariant_min_le_est_le_max() {
700        let est = TokenEstimationMeta::from_bytes(12_345, 4.0);
701        assert!(est.tokens_min <= est.tokens_est);
702        assert!(est.tokens_est <= est.tokens_max);
703    }
704
705    #[test]
706    fn token_estimation_serde_roundtrip_keeps_invariant() {
707        let est = TokenEstimationMeta::from_bytes_with_bounds(10_000, 4.0, 3.0, 5.0);
708        let json = serde_json::to_string(&est).unwrap();
709        let back: TokenEstimationMeta = serde_json::from_str(&json).unwrap();
710        assert_eq!(back.source_bytes, 10_000);
711        assert_eq!(back.bytes_per_token_est, 4.0);
712        assert_eq!(back.tokens_min, est.tokens_min);
713        assert_eq!(back.tokens_est, est.tokens_est);
714        assert_eq!(back.tokens_max, est.tokens_max);
715        assert!(back.tokens_min <= back.tokens_est);
716        assert!(back.tokens_est <= back.tokens_max);
717    }
718
719    #[test]
720    fn token_estimation_accepts_legacy_aliases() {
721        let json = r#"{
722            "bytes_per_token_est": 4.0,
723            "bytes_per_token_low": 3.0,
724            "bytes_per_token_high": 5.0,
725            "tokens_high": 200,
726            "tokens_est": 250,
727            "tokens_low": 333,
728            "source_bytes": 1000
729        }"#;
730        let est: TokenEstimationMeta = serde_json::from_str(json).unwrap();
731        assert_eq!(est.tokens_min, 200);
732        assert_eq!(est.tokens_est, 250);
733        assert_eq!(est.tokens_max, 333);
734    }
735
736    // ── TokenAudit ──────────────────────────────────────────────────
737    #[test]
738    fn token_audit_serde_roundtrip() {
739        let audit = TokenAudit::from_output(5_000, 4_500);
740        let json = serde_json::to_string(&audit).unwrap();
741        let back: TokenAudit = serde_json::from_str(&json).unwrap();
742        assert_eq!(back.output_bytes, 5_000);
743        assert_eq!(back.overhead_bytes, 500);
744        assert!(back.tokens_min <= back.tokens_est);
745        assert!(back.tokens_est <= back.tokens_max);
746    }
747
748    #[test]
749    fn token_audit_accepts_legacy_aliases() {
750        let json = r#"{
751            "output_bytes": 1000,
752            "tokens_high": 200,
753            "tokens_est": 250,
754            "tokens_low": 333,
755            "overhead_bytes": 100,
756            "overhead_pct": 0.1
757        }"#;
758        let audit: TokenAudit = serde_json::from_str(json).unwrap();
759        assert_eq!(audit.tokens_min, 200);
760        assert_eq!(audit.tokens_max, 333);
761    }
762
763    // ── FileClassification ──────────────────────────────────────────
764    #[test]
765    fn file_classification_uses_snake_case() {
766        assert_eq!(
767            serde_json::to_string(&FileClassification::DataBlob).unwrap(),
768            "\"data_blob\""
769        );
770        assert_eq!(
771            serde_json::to_string(&FileClassification::Sourcemap).unwrap(),
772            "\"sourcemap\""
773        );
774    }
775
776    #[test]
777    fn file_classification_all_variants_roundtrip() {
778        for variant in [
779            FileClassification::Generated,
780            FileClassification::Fixture,
781            FileClassification::Vendored,
782            FileClassification::Lockfile,
783            FileClassification::Minified,
784            FileClassification::DataBlob,
785            FileClassification::Sourcemap,
786        ] {
787            let json = serde_json::to_string(&variant).unwrap();
788            let back: FileClassification = serde_json::from_str(&json).unwrap();
789            assert_eq!(back, variant);
790        }
791    }
792
793    #[test]
794    fn file_classification_ord_is_stable() {
795        let mut variants = [
796            FileClassification::Sourcemap,
797            FileClassification::Generated,
798            FileClassification::Lockfile,
799        ];
800        variants.sort();
801        assert_eq!(variants[0], FileClassification::Generated);
802        assert_eq!(variants[1], FileClassification::Lockfile);
803        assert_eq!(variants[2], FileClassification::Sourcemap);
804    }
805
806    // ── InclusionPolicy ─────────────────────────────────────────────
807    #[test]
808    fn inclusion_policy_default_is_full() {
809        assert_eq!(InclusionPolicy::default(), InclusionPolicy::Full);
810    }
811
812    #[test]
813    fn inclusion_policy_uses_snake_case() {
814        assert_eq!(
815            serde_json::to_string(&InclusionPolicy::HeadTail).unwrap(),
816            "\"head_tail\""
817        );
818        assert_eq!(
819            serde_json::to_string(&InclusionPolicy::Full).unwrap(),
820            "\"full\""
821        );
822    }
823
824    #[test]
825    fn inclusion_policy_all_variants_roundtrip() {
826        for variant in [
827            InclusionPolicy::Full,
828            InclusionPolicy::HeadTail,
829            InclusionPolicy::Summary,
830            InclusionPolicy::Skip,
831        ] {
832            let json = serde_json::to_string(&variant).unwrap();
833            let back: InclusionPolicy = serde_json::from_str(&json).unwrap();
834            assert_eq!(back, variant);
835        }
836    }
837
838    #[test]
839    fn is_default_policy_helper_only_true_for_full() {
840        assert!(is_default_policy(&InclusionPolicy::Full));
841        assert!(!is_default_policy(&InclusionPolicy::HeadTail));
842        assert!(!is_default_policy(&InclusionPolicy::Summary));
843        assert!(!is_default_policy(&InclusionPolicy::Skip));
844    }
845
846    // ── PolicyExcludedFile ──────────────────────────────────────────
847    #[test]
848    fn policy_excluded_file_serde_roundtrip() {
849        let f = PolicyExcludedFile {
850            path: "vendor/big.json".into(),
851            original_tokens: 10_000,
852            policy: InclusionPolicy::Skip,
853            reason: "data_blob".into(),
854            classifications: vec![FileClassification::DataBlob, FileClassification::Vendored],
855        };
856        let json = serde_json::to_string(&f).unwrap();
857        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
858        assert_eq!(value["policy"], "skip");
859        assert_eq!(value["classifications"][0], "data_blob");
860        assert_eq!(value["classifications"][1], "vendored");
861        let back: PolicyExcludedFile = serde_json::from_str(&json).unwrap();
862        assert_eq!(back.path, f.path);
863        assert_eq!(back.policy, f.policy);
864    }
865
866    // ── HandoffManifest ─────────────────────────────────────────────
867    #[test]
868    fn handoff_manifest_minimal_serde_roundtrip() {
869        let m = HandoffManifest {
870            schema_version: HANDOFF_SCHEMA_VERSION,
871            generated_at_ms: 1_700_000_000_000,
872            tool: sample_tool(),
873            mode: "handoff".into(),
874            inputs: vec![".".into()],
875            output_dir: "/tmp/out".into(),
876            budget_tokens: 8_000,
877            used_tokens: 4_000,
878            utilization_pct: 50.0,
879            strategy: "value-greedy".into(),
880            rank_by: "value".into(),
881            capabilities: vec![CapabilityStatus {
882                name: "git".into(),
883                status: CapabilityState::Available,
884                reason: None,
885            }],
886            artifacts: vec![ArtifactEntry {
887                name: "summary.md".into(),
888                path: "out/summary.md".into(),
889                description: "Markdown summary".into(),
890                bytes: 256,
891                hash: None,
892            }],
893            included_files: vec![sample_context_file_row()],
894            excluded_paths: vec![],
895            excluded_patterns: vec![],
896            smart_excluded_files: vec![],
897            total_files: 10,
898            bundled_files: 5,
899            intelligence_preset: "compact".into(),
900            rank_by_effective: None,
901            fallback_reason: None,
902            excluded_by_policy: vec![],
903            token_estimation: None,
904            code_audit: None,
905        };
906        let json = serde_json::to_string(&m).unwrap();
907        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
908        for key in [
909            "schema_version",
910            "generated_at_ms",
911            "tool",
912            "mode",
913            "inputs",
914            "output_dir",
915            "budget_tokens",
916            "used_tokens",
917            "utilization_pct",
918            "strategy",
919            "rank_by",
920            "capabilities",
921            "artifacts",
922            "included_files",
923            "excluded_paths",
924            "excluded_patterns",
925            "smart_excluded_files",
926            "total_files",
927            "bundled_files",
928            "intelligence_preset",
929        ] {
930            assert!(
931                value.get(key).is_some(),
932                "missing key `{key}` in HandoffManifest"
933            );
934        }
935        assert!(value.get("rank_by_effective").is_none());
936        assert!(value.get("fallback_reason").is_none());
937        assert!(value.get("excluded_by_policy").is_none());
938        assert!(value.get("token_estimation").is_none());
939        assert!(value.get("code_audit").is_none());
940        let back: HandoffManifest = serde_json::from_str(&json).unwrap();
941        assert_eq!(back.schema_version, HANDOFF_SCHEMA_VERSION);
942        assert_eq!(back.bundled_files, 5);
943        assert_eq!(back.intelligence_preset, "compact");
944    }
945
946    // ── ContextBundleManifest ───────────────────────────────────────
947    #[test]
948    fn context_bundle_manifest_serde_roundtrip() {
949        let m = ContextBundleManifest {
950            schema_version: CONTEXT_BUNDLE_SCHEMA_VERSION,
951            generated_at_ms: 0,
952            tool: sample_tool(),
953            mode: "context".into(),
954            budget_tokens: 0,
955            used_tokens: 0,
956            utilization_pct: 0.0,
957            strategy: "value".into(),
958            rank_by: "value".into(),
959            file_count: 0,
960            bundle_bytes: 0,
961            artifacts: vec![],
962            included_files: vec![],
963            excluded_paths: vec![ContextExcludedPath {
964                path: "secret".into(),
965                reason: "redacted".into(),
966            }],
967            excluded_patterns: vec!["target".into()],
968            rank_by_effective: None,
969            fallback_reason: None,
970            excluded_by_policy: vec![],
971            token_estimation: None,
972            bundle_audit: None,
973        };
974        let json = serde_json::to_string(&m).unwrap();
975        let back: ContextBundleManifest = serde_json::from_str(&json).unwrap();
976        assert_eq!(back.excluded_paths.len(), 1);
977        assert_eq!(back.excluded_paths[0].path, "secret");
978        assert_eq!(back.excluded_patterns, vec!["target".to_string()]);
979    }
980
981    // ── ContextExcludedPath / HandoffExcludedPath / SmartExcludedFile ──
982    #[test]
983    fn context_excluded_path_serde_roundtrip() {
984        let v = ContextExcludedPath {
985            path: "p".into(),
986            reason: "r".into(),
987        };
988        let json = serde_json::to_string(&v).unwrap();
989        let back: ContextExcludedPath = serde_json::from_str(&json).unwrap();
990        assert_eq!(back.path, "p");
991        assert_eq!(back.reason, "r");
992    }
993
994    #[test]
995    fn handoff_excluded_path_serde_roundtrip() {
996        let v = HandoffExcludedPath {
997            path: "p".into(),
998            reason: "r".into(),
999        };
1000        let json = serde_json::to_string(&v).unwrap();
1001        let back: HandoffExcludedPath = serde_json::from_str(&json).unwrap();
1002        assert_eq!(back.path, "p");
1003        assert_eq!(back.reason, "r");
1004    }
1005
1006    #[test]
1007    fn smart_excluded_file_serde_roundtrip() {
1008        let v = SmartExcludedFile {
1009            path: "vendor/x.min.js".into(),
1010            reason: "minified".into(),
1011            tokens: 999,
1012        };
1013        let json = serde_json::to_string(&v).unwrap();
1014        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1015        for key in ["path", "reason", "tokens"] {
1016            assert!(value.get(key).is_some());
1017        }
1018        let back: SmartExcludedFile = serde_json::from_str(&json).unwrap();
1019        assert_eq!(back.tokens, 999);
1020    }
1021
1022    // ── HandoffIntelligence + sub-types ─────────────────────────────
1023    #[test]
1024    fn handoff_intelligence_full_roundtrip() {
1025        let intel = HandoffIntelligence {
1026            tree: Some("root\n  a\n  b".into()),
1027            tree_depth: Some(2),
1028            hotspots: Some(vec![HandoffHotspot {
1029                path: "src/main.rs".into(),
1030                commits: 10,
1031                lines: 100,
1032                score: 42,
1033            }]),
1034            complexity: Some(HandoffComplexity {
1035                total_functions: 100,
1036                avg_function_length: 12.5,
1037                max_function_length: 80,
1038                avg_cyclomatic: 3.5,
1039                max_cyclomatic: 30,
1040                high_risk_files: 2,
1041            }),
1042            derived: Some(HandoffDerived {
1043                total_files: 50,
1044                total_code: 5_000,
1045                total_lines: 6_500,
1046                total_tokens: 12_000,
1047                lang_count: 3,
1048                dominant_lang: "Rust".into(),
1049                dominant_pct: 80.0,
1050            }),
1051            warnings: vec!["no git".into()],
1052        };
1053        let json = serde_json::to_string(&intel).unwrap();
1054        let back: HandoffIntelligence = serde_json::from_str(&json).unwrap();
1055        assert_eq!(back.tree_depth, Some(2));
1056        let hotspots = back.hotspots.expect("hotspots present");
1057        assert_eq!(hotspots.len(), 1);
1058        assert_eq!(hotspots[0].score, 42);
1059        let complexity = back.complexity.expect("complexity present");
1060        assert_eq!(complexity.high_risk_files, 2);
1061        let derived = back.derived.expect("derived present");
1062        assert_eq!(derived.dominant_lang, "Rust");
1063        assert_eq!(back.warnings, vec!["no git".to_string()]);
1064    }
1065
1066    #[test]
1067    fn handoff_intelligence_all_none_serializes() {
1068        let intel = HandoffIntelligence {
1069            tree: None,
1070            tree_depth: None,
1071            hotspots: None,
1072            complexity: None,
1073            derived: None,
1074            warnings: vec![],
1075        };
1076        let json = serde_json::to_string(&intel).unwrap();
1077        let back: HandoffIntelligence = serde_json::from_str(&json).unwrap();
1078        assert!(back.tree.is_none());
1079        assert!(back.hotspots.is_none());
1080        assert!(back.warnings.is_empty());
1081    }
1082
1083    // ── CapabilityState / CapabilityStatus ──────────────────────────
1084    #[test]
1085    fn capability_state_uses_snake_case() {
1086        assert_eq!(
1087            serde_json::to_string(&CapabilityState::Available).unwrap(),
1088            "\"available\""
1089        );
1090        assert_eq!(
1091            serde_json::to_string(&CapabilityState::Skipped).unwrap(),
1092            "\"skipped\""
1093        );
1094        assert_eq!(
1095            serde_json::to_string(&CapabilityState::Unavailable).unwrap(),
1096            "\"unavailable\""
1097        );
1098    }
1099
1100    #[test]
1101    fn capability_state_all_variants_roundtrip() {
1102        for variant in [
1103            CapabilityState::Available,
1104            CapabilityState::Skipped,
1105            CapabilityState::Unavailable,
1106        ] {
1107            let json = serde_json::to_string(&variant).unwrap();
1108            let back: CapabilityState = serde_json::from_str(&json).unwrap();
1109            assert_eq!(back, variant);
1110        }
1111    }
1112
1113    #[test]
1114    fn capability_status_with_reason_keeps_field() {
1115        let s = CapabilityStatus {
1116            name: "git".into(),
1117            status: CapabilityState::Unavailable,
1118            reason: Some("not a repo".into()),
1119        };
1120        let value = serde_json::to_value(&s).unwrap();
1121        assert_eq!(value["reason"], "not a repo");
1122        let back: CapabilityStatus = serde_json::from_str(&value.to_string()).unwrap();
1123        assert_eq!(back.reason.as_deref(), Some("not a repo"));
1124    }
1125
1126    #[test]
1127    fn capability_status_without_reason_omits_field() {
1128        let s = CapabilityStatus {
1129            name: "git".into(),
1130            status: CapabilityState::Available,
1131            reason: None,
1132        };
1133        let value = serde_json::to_value(&s).unwrap();
1134        assert!(value.get("reason").is_none());
1135    }
1136
1137    // ── ArtifactEntry / ArtifactHash ────────────────────────────────
1138    #[test]
1139    fn artifact_entry_with_hash_roundtrip() {
1140        let a = ArtifactEntry {
1141            name: "bundle.txt".into(),
1142            path: "out/bundle.txt".into(),
1143            description: "Concatenated source".into(),
1144            bytes: 1_024,
1145            hash: Some(ArtifactHash {
1146                algo: "sha256".into(),
1147                hash: "deadbeef".into(),
1148            }),
1149        };
1150        let json = serde_json::to_string(&a).unwrap();
1151        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1152        for key in ["name", "path", "description", "bytes", "hash"] {
1153            assert!(
1154                value.get(key).is_some(),
1155                "missing key `{key}` in ArtifactEntry"
1156            );
1157        }
1158        assert_eq!(value["hash"]["algo"], "sha256");
1159        let back: ArtifactEntry = serde_json::from_str(&json).unwrap();
1160        let h = back.hash.expect("hash present");
1161        assert_eq!(h.algo, "sha256");
1162        assert_eq!(h.hash, "deadbeef");
1163    }
1164
1165    #[test]
1166    fn artifact_entry_without_hash_omits_field() {
1167        let a = ArtifactEntry {
1168            name: "x".into(),
1169            path: "y".into(),
1170            description: "z".into(),
1171            bytes: 0,
1172            hash: None,
1173        };
1174        let value = serde_json::to_value(&a).unwrap();
1175        assert!(value.get("hash").is_none());
1176    }
1177}