1use serde::{Deserialize, Serialize};
8
9use crate::ToolInfo;
10
11pub const HANDOFF_SCHEMA_VERSION: u32 = 5;
17
18pub const CONTEXT_BUNDLE_SCHEMA_VERSION: u32 = 2;
24
25pub 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 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub rank_by_effective: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub fallback_reason: Option<String>,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub excluded_by_policy: Vec<PolicyExcludedFile>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub token_estimation: Option<TokenEstimationMeta>,
57 #[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 #[serde(default, skip_serializing_if = "is_default_policy")]
76 pub policy: InclusionPolicy,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub effective_tokens: Option<usize>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub policy_reason: Option<String>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub classifications: Vec<FileClassification>,
86}
87
88#[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#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct TokenEstimationMeta {
114 pub bytes_per_token_est: f64,
116 pub bytes_per_token_low: f64,
118 pub bytes_per_token_high: f64,
120 #[serde(alias = "tokens_high")]
122 pub tokens_min: usize,
123 pub tokens_est: usize,
125 #[serde(alias = "tokens_low")]
127 pub tokens_max: usize,
128 pub source_bytes: usize,
130}
131
132impl TokenEstimationMeta {
133 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct TokenAudit {
173 pub output_bytes: u64,
175 #[serde(alias = "tokens_high")]
177 pub tokens_min: usize,
178 pub tokens_est: usize,
180 #[serde(alias = "tokens_low")]
182 pub tokens_max: usize,
183 pub overhead_bytes: u64,
185 pub overhead_pct: f64,
187}
188
189impl TokenAudit {
190 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum FileClassification {
241 Generated,
243 Fixture,
245 Vendored,
247 Lockfile,
249 Minified,
251 DataBlob,
253 Sourcemap,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
259#[serde(rename_all = "snake_case")]
260pub enum InclusionPolicy {
261 #[default]
263 Full,
264 HeadTail,
266 Summary,
268 Skip,
270}
271
272pub(crate) fn is_default_policy(policy: &InclusionPolicy) -> bool {
274 *policy == InclusionPolicy::Full
275}
276
277#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub rank_by_effective: Option<String>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub fallback_reason: Option<String>,
316 #[serde(default, skip_serializing_if = "Vec::is_empty")]
318 pub excluded_by_policy: Vec<PolicyExcludedFile>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub token_estimation: Option<TokenEstimationMeta>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub code_audit: Option<TokenAudit>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct SmartExcludedFile {
330 pub path: String,
331 pub reason: String,
332 pub tokens: usize,
333}
334
335#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub rank_by_effective: Option<String>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub fallback_reason: Option<String>,
359 #[serde(default, skip_serializing_if = "Vec::is_empty")]
361 pub excluded_by_policy: Vec<PolicyExcludedFile>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub token_estimation: Option<TokenEstimationMeta>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub bundle_audit: Option<TokenAudit>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct ContextExcludedPath {
373 pub path: String,
374 pub reason: String,
375}
376
377#[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#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct HandoffExcludedPath {
391 pub path: String,
392 pub reason: String,
393}
394
395#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
438#[serde(rename_all = "snake_case")]
439pub enum CapabilityState {
440 Available,
442 Skipped,
444 Unavailable,
446}
447
448#[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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}