1use std::collections::BTreeMap;
24
25use serde::{Deserialize, Serialize};
26use serde_json::Value as JsonValue;
27use uuid::Uuid;
28
29pub type DeviceId = Uuid;
42
43#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
49#[serde(rename_all = "snake_case")]
50pub enum RecordSource {
51 StaticAnalysis,
53 ClaudeEnrich,
55 SessionHook,
57 DeveloperManual,
59 Import,
61}
62
63#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
70#[serde(rename_all = "snake_case")]
71pub enum AgentKind {
72 Claude,
74 Codex,
76 Cli,
78 Supervisor,
81 Unknown,
83}
84
85#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
87#[serde(rename_all = "snake_case")]
88pub enum Category {
89 Gotcha,
90 File,
91 Decision,
92 Stage,
93 Dependency,
94 DevNote,
95 Session,
96 Analytics,
97 Policy,
98}
99
100#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
107#[serde(rename_all = "snake_case")]
108pub enum Priority {
109 Low,
110 Normal,
111 High,
112 Critical,
113}
114
115#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
129#[serde(rename_all = "snake_case")]
130pub enum QualityTier {
131 Suppressed,
132 Poor,
133 Acceptable,
134 Good,
135 Excellent,
136}
137
138#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
140#[serde(rename_all = "snake_case")]
141pub enum QualitySignal {
142 HasImperativeVerb,
144 HasCausality,
145 HasSeveritySet,
146 HasReference,
147 RuleLengthAdequate,
148 ReasonLengthAdequate,
149 AffectedFilesSpecified,
150 HasSpecificIdentifier,
151 VaguePhrasing,
153 NoActionableRule,
154 NoReason,
155 TooShort,
156 DuplicatesFilePurpose,
157}
158
159#[derive(Serialize, Deserialize, Debug, Clone)]
181pub struct QualityScore {
182 pub value: f32,
184 pub tier: QualityTier,
185 pub signals: Vec<QualitySignal>,
186 pub computed_at: u64,
189}
190
191impl QualityScore {
192 pub fn layer0_default() -> Self {
194 Self {
195 value: 0.10,
196 tier: QualityTier::Suppressed,
197 signals: vec![],
198 computed_at: 0,
199 }
200 }
201
202 pub fn doc_comment_default() -> Self {
209 Self {
210 value: 0.40,
211 tier: QualityTier::Acceptable,
212 signals: vec![],
213 computed_at: 0,
214 }
215 }
216
217 pub fn developer_entry_default() -> Self {
228 Self {
229 value: 0.65,
230 tier: QualityTier::Good,
231 signals: vec![],
232 computed_at: 0,
233 }
234 }
235
236 pub fn cochange_default() -> Self {
237 Self {
238 value: 0.40,
239 tier: QualityTier::Acceptable,
240 signals: vec![],
241 computed_at: 0,
242 }
243 }
244
245 pub fn cochange_strong() -> Self {
252 Self {
253 value: 0.60,
254 tier: QualityTier::Acceptable,
255 signals: vec![],
256 computed_at: 0,
257 }
258 }
259
260 pub fn tier_from_value(value: f32) -> QualityTier {
270 if !value.is_finite() || value < 0.2 {
273 QualityTier::Suppressed
274 } else if value < 0.4 {
275 QualityTier::Poor
276 } else if value < 0.7 {
277 QualityTier::Acceptable
278 } else if value < 0.9 {
279 QualityTier::Good
280 } else {
281 QualityTier::Excellent
282 }
283 }
284}
285
286#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
302#[serde(rename_all = "snake_case")]
303pub enum StalenessTier {
304 Fresh,
305 Aging,
306 Stale,
307 Liability,
309 Tombstone,
312}
313
314#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
318#[serde(rename_all = "snake_case")]
319pub enum StalenessSignal {
320 NotAccessedDays(u32),
321 LinesChangedPct(f32),
323 EntryPointsChanged(u32),
324 ImportsChanged(u32),
325 FileDeleted,
326 FileRenamed {
327 new_path: String,
328 },
329 DependencyBumped {
330 dep: String,
331 old_ver: String,
332 new_ver: String,
333 },
334 LinkedFileChanged {
335 path: String,
336 },
337 CascadeFromDecision(String),
339 TodosChanged,
341 UnsafeCountChanged(i32),
343 UnwrapCountChanged(i32),
345 GitCommitsSince(u32),
347}
348
349impl std::fmt::Display for StalenessSignal {
350 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351 match self {
352 Self::NotAccessedDays(d) => write!(f, "not accessed for {d} days"),
353 Self::LinesChangedPct(pct) => write!(f, "{:.0}% of lines changed", pct * 100.0),
354 Self::EntryPointsChanged(n) => write!(f, "{n} entry points changed"),
355 Self::ImportsChanged(n) => write!(f, "{n} imports changed"),
356 Self::FileDeleted => write!(f, "source file deleted"),
357 Self::FileRenamed { new_path } => write!(f, "file renamed to {new_path}"),
358 Self::DependencyBumped {
359 dep,
360 old_ver,
361 new_ver,
362 } => write!(f, "{dep} bumped {old_ver} \u{2192} {new_ver}"),
363 Self::LinkedFileChanged { path } => write!(f, "linked file {path} changed"),
364 Self::CascadeFromDecision(key) => write!(f, "cascaded from {key}"),
365 Self::TodosChanged => write!(f, "TODOs changed"),
366 Self::UnsafeCountChanged(delta) => write!(f, "unsafe count changed by {delta}"),
367 Self::UnwrapCountChanged(delta) => write!(f, "unwrap count changed by {delta}"),
368 Self::GitCommitsSince(n) => write!(f, "{n} commits since last confirmation"),
369 }
370 }
371}
372
373#[derive(Serialize, Deserialize, Debug, Clone)]
390pub struct StalenessScore {
391 pub value: f32,
393 pub tier: StalenessTier,
394 pub signals: Vec<StalenessSignal>,
395 pub computed_at: u64,
398 pub last_record_sha: String,
401}
402
403impl StalenessScore {
404 pub fn fresh() -> Self {
406 Self {
407 value: 0.0,
408 tier: StalenessTier::Fresh,
409 signals: vec![],
410 computed_at: 0,
411 last_record_sha: String::new(),
412 }
413 }
414
415 pub fn tier_from_value(value: f32) -> StalenessTier {
425 if !value.is_finite() {
426 return StalenessTier::Stale;
427 }
428 if value < 0.2 {
429 StalenessTier::Fresh
430 } else if value < 0.4 {
431 StalenessTier::Aging
432 } else if value < 0.7 {
433 StalenessTier::Stale
434 } else if value < 0.9 {
435 StalenessTier::Liability
436 } else {
437 StalenessTier::Tombstone
438 }
439 }
440}
441
442#[derive(Serialize, Deserialize, Debug, Clone)]
476pub struct ConfidenceScore {
477 pub value: f32,
479 pub confirmation_count: u32,
481 pub contributor_count: u32,
483 pub last_challenged: Option<u64>,
485 pub challenge_count: u32,
486}
487
488impl ConfidenceScore {
489 pub fn base_for_source(source: &RecordSource) -> f32 {
491 match source {
492 RecordSource::DeveloperManual => 0.80,
493 RecordSource::Import => 0.70,
494 RecordSource::ClaudeEnrich => 0.60,
495 RecordSource::SessionHook => 0.50,
496 RecordSource::StaticAnalysis => 0.10,
497 }
498 }
499
500 pub fn for_new_record(source: &RecordSource) -> Self {
506 Self {
507 value: Self::base_for_source(source),
508 confirmation_count: 0,
509 contributor_count: 1,
510 last_challenged: None,
511 challenge_count: 0,
512 }
513 }
514}
515
516#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
522#[serde(rename_all = "snake_case")]
523pub enum TombstoneReason {
524 FileDeleted,
525 FileRenamed {
526 new_path: String,
527 },
528 ManualDeletion,
529 Superseded,
530 MigrationRepair,
532}
533
534#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
538#[serde(rename_all = "snake_case")]
539pub enum RecordLifecycle {
540 Active,
541 Tombstoned { reason: TombstoneReason, at: u64 },
542 Superseded { by_key: String },
543}
544
545#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
554pub struct RecordVersion {
555 pub device_id: DeviceId,
557 pub logical_clock: u64,
559 pub wall_clock: u64,
561}
562
563#[derive(Serialize, Deserialize, Debug, Clone)]
579pub struct Record {
580 pub key: String,
582 pub value: String,
585 pub category: Category,
586 pub priority: Priority,
587 pub tags: Vec<String>,
589 pub created_at: u64,
591 pub updated_at: u64,
593 pub ref_url: Option<String>,
595 pub staleness: StalenessScore,
596 pub lifecycle: RecordLifecycle,
597 pub version: RecordVersion,
600 pub quality: QualityScore,
601 pub access_count: u32,
603 pub last_accessed: u64,
605 pub source: RecordSource,
606 pub confidence: ConfidenceScore,
607 pub gap_analysis_score: f32,
609 #[serde(default)]
620 pub payload: Option<JsonValue>,
621}
622
623impl Record {
624 pub fn device_id(&self) -> DeviceId {
628 self.version.device_id
629 }
630
631 pub fn payload_as<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
636 self.payload
637 .as_ref()
638 .and_then(|p| serde_json::from_value(p.clone()).ok())
639 }
640
641 pub fn layer0_file_stub(
648 key: impl Into<String>,
649 device_id: DeviceId,
650 logical_clock: u64,
651 wall_clock: u64,
652 ) -> Self {
653 Self {
654 key: key.into(),
655 value: String::new(),
656 category: Category::File,
657 priority: Priority::Normal,
658 tags: vec![],
659 created_at: wall_clock,
660 updated_at: wall_clock,
661 ref_url: None,
662 staleness: StalenessScore::fresh(),
663 lifecycle: RecordLifecycle::Active,
664 version: RecordVersion {
665 device_id,
666 logical_clock,
667 wall_clock,
668 },
669 quality: QualityScore::layer0_default(),
670 access_count: 0,
671 last_accessed: 0,
672 source: RecordSource::StaticAnalysis,
673 confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
674 gap_analysis_score: 0.0,
675 payload: None,
676 }
677 }
678}
679
680#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
686#[serde(rename_all = "snake_case")]
687pub enum TodoKind {
688 Todo,
689 Fixme,
690 Hack,
691 Note,
692 Deprecated,
693}
694
695#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
697pub struct TodoComment {
698 pub text: String,
699 pub line: u32,
700 pub kind: TodoKind,
701}
702
703#[derive(Serialize, Deserialize, Debug, Clone)]
709pub struct FileRecord {
710 pub path: String,
711 pub purpose: String,
713 pub entry_points: Vec<String>,
715 pub imports: Vec<String>,
717 pub gotcha_keys: Vec<String>,
719 pub decision_keys: Vec<String>,
721 pub todos: Vec<TodoComment>,
722 pub unsafe_count: u32,
723 pub unwrap_count: u32,
724 pub change_frequency: u32,
726 pub last_author: Option<String>,
727 pub is_hotspot: bool,
729 pub token_cost_estimate: u32,
731 pub last_modified_session: u64,
733 #[serde(default)]
736 pub content_hash: Option<String>,
737 #[serde(default)]
739 pub line_count: u32,
740 #[serde(default)]
744 pub blast_radius: Option<crate::analysis::blast_radius::BlastRadius>,
745 #[serde(default)]
748 pub propagated_staleness: Option<crate::analysis::propagation::PropagatedStaleness>,
749}
750
751impl FileRecord {
752 #[allow(clippy::too_many_arguments)]
758 pub fn layer0_stub(
759 path: impl Into<String>,
760 entry_points: Vec<String>,
761 imports: Vec<String>,
762 todos: Vec<TodoComment>,
763 unsafe_count: u32,
764 unwrap_count: u32,
765 change_frequency: u32,
766 last_author: Option<String>,
767 is_hotspot: bool,
768 token_cost_estimate: u32,
769 last_modified_session: u64,
770 ) -> Self {
771 Self {
772 path: path.into(),
773 purpose: String::new(),
774 entry_points,
775 imports,
776 gotcha_keys: vec![],
777 decision_keys: vec![],
778 todos,
779 unsafe_count,
780 unwrap_count,
781 change_frequency,
782 last_author,
783 is_hotspot,
784 token_cost_estimate,
785 last_modified_session,
786 content_hash: None,
787 line_count: 0,
788 blast_radius: None,
789 propagated_staleness: None,
790 }
791 }
792}
793
794#[derive(Serialize, Deserialize, Debug, Clone)]
807pub struct GotchaRecord {
808 pub rule: String,
810 pub reason: String,
812 pub severity: Priority,
813 #[serde(default)]
814 pub affected_files: Vec<String>,
815 #[serde(default)]
816 pub ref_url: Option<String>,
817 #[serde(default)]
819 pub discovered_session: u64,
820 #[serde(default)]
823 pub confirmed: bool,
824 #[serde(default)]
844 pub confirmed_content: BTreeMap<String, String>,
845}
846
847#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
855#[serde(deny_unknown_fields)]
856pub struct PolicyTrigger {
857 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub tool: Option<String>,
859 #[serde(default, skip_serializing_if = "Option::is_none")]
860 pub host_glob: Option<String>,
861 #[serde(default, skip_serializing_if = "Option::is_none")]
862 pub target_path_glob: Option<String>,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
870 pub command_glob: Option<String>,
871}
872
873#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
874#[serde(rename_all = "snake_case")]
875pub enum PolicyMode {
876 Steer,
877 Block,
878}
879
880#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
882#[serde(rename_all = "snake_case")]
883pub enum PolicyStage {
884 Off,
885 Shadow,
886 Enforce,
887}
888
889#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
890#[serde(rename_all = "snake_case")]
891pub enum ReceiptSource {
892 DbIntrospection,
893 HookContext,
894 MemGet,
895}
896
897pub const CODEX_PRODUCIBLE_SOURCES: &[ReceiptSource] =
911 &[ReceiptSource::MemGet, ReceiptSource::HookContext];
912
913const DEFAULT_POLICY_TTL_SECS: u64 = 900;
917
918fn default_policy_ttl_secs() -> u64 {
919 DEFAULT_POLICY_TTL_SECS
920}
921
922fn default_policy_freshness() -> PolicyFreshness {
923 PolicyFreshness {
924 ttl_secs: DEFAULT_POLICY_TTL_SECS,
925 fingerprint: false,
926 }
927}
928
929fn default_policy_requires() -> PolicyRequires {
932 PolicyRequires {
933 key: String::new(),
934 via: Vec::new(),
935 freshness: default_policy_freshness(),
936 }
937}
938
939#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
940pub struct PolicyFreshness {
941 #[serde(default = "default_policy_ttl_secs")]
942 pub ttl_secs: u64,
943 #[serde(default)]
944 pub fingerprint: bool,
945}
946
947#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
948pub struct PolicyRequires {
949 #[serde(default)]
950 pub key: String,
951 #[serde(default)]
952 pub via: Vec<ReceiptSource>,
953 #[serde(default = "default_policy_freshness")]
954 pub freshness: PolicyFreshness,
955}
956
957#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
959pub struct PolicyRecord {
960 pub name: String,
961 pub rule: String,
962 pub reason: String,
963 pub scope: String,
964 pub mode: PolicyMode,
965 pub trigger: PolicyTrigger,
966 #[serde(default = "default_policy_requires")]
967 pub requires: PolicyRequires,
968 pub stage: PolicyStage,
969 pub severity: Priority,
970 pub created_by: String,
971}
972
973#[derive(Serialize, Deserialize, Debug, Clone)]
982pub struct StaleReviewEntry {
983 pub key: String,
984 pub staleness_value: f32,
985 pub tier: StalenessTier,
986 pub last_updated: u64,
987 pub signals: Vec<String>,
988}
989
990#[derive(Serialize, Deserialize, Debug, Clone)]
992pub struct StaleReviewPayload {
993 pub session_timestamp: u64,
994 pub entries: Vec<StaleReviewEntry>,
995}
996
997#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1006#[serde(rename_all = "snake_case")]
1007pub enum GapType {
1008 HotFileNoRecord,
1010 HotFileNoPurpose,
1012 HotFileNoGotchas,
1014 FrequentlyReadNoEnrich,
1016 OrphanedDecision,
1018 DependencyUnknown,
1020 CoChangePairUnmapped,
1022 StaleHotspot,
1024 HotFileNoTests,
1026 HighFanInNoContract,
1028}
1029
1030#[derive(Serialize, Deserialize, Debug, Clone)]
1034pub struct KnowledgeGap {
1035 pub key: String,
1037 pub gap_type: GapType,
1038 pub risk_score: f32,
1040 pub description: String,
1041 pub action_hint: String,
1043}
1044
1045#[derive(Serialize, Deserialize, Debug, Clone)]
1064pub struct ContextPacket {
1065 pub stage: Option<Record>,
1067 pub critical_gotchas: Vec<Record>,
1072 pub file_records: Vec<FileRecord>,
1074 pub related_decisions: Vec<Record>,
1076 pub recent_session: Option<String>,
1078 pub token_estimate: u32,
1080 pub stale_warnings: Vec<String>,
1082 pub unconfirmed_candidates: Vec<String>,
1084 pub knowledge_gaps: Vec<KnowledgeGap>,
1086 pub compliance_rate: Option<f32>,
1088 pub injection_string: String,
1090}
1091
1092#[derive(Serialize, Deserialize, Debug, Clone)]
1114pub struct OnboardingScore {
1115 pub estimated_minutes: f32,
1116 pub critical_files_covered: f32,
1118 pub gotcha_coverage: f32,
1120 pub decision_coverage: f32,
1122 pub avg_confidence: f32,
1124 pub computed_at: u64,
1125}
1126
1127#[cfg(test)]
1132mod tests {
1133 use super::*;
1134
1135 fn device_id() -> DeviceId {
1138 Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
1139 }
1140
1141 fn sample_record() -> Record {
1142 Record {
1143 key: "gotcha:inference-async".to_string(),
1144 value: "Never call .await inside a rayon::spawn closure — it panics.".to_string(),
1145 category: Category::Gotcha,
1146 priority: Priority::Critical,
1147 tags: vec!["async".to_string(), "rayon".to_string()],
1148 created_at: 1_710_520_800,
1149 updated_at: 1_710_520_800,
1150 ref_url: Some("https://github.com/example/issue/42".to_string()),
1151 staleness: StalenessScore::fresh(),
1152 lifecycle: RecordLifecycle::Active,
1153 version: RecordVersion {
1154 device_id: device_id(),
1155 logical_clock: 1,
1156 wall_clock: 1_710_520_800,
1157 },
1158 quality: QualityScore {
1159 value: 0.85,
1160 tier: QualityTier::Good,
1161 signals: vec![
1162 QualitySignal::HasImperativeVerb,
1163 QualitySignal::HasCausality,
1164 ],
1165 computed_at: 1_710_520_800,
1166 },
1167 access_count: 0,
1168 last_accessed: 0,
1169 source: RecordSource::DeveloperManual,
1170 confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
1171 gap_analysis_score: 0.0,
1172 payload: None,
1173 }
1174 }
1175
1176 fn sample_file_record() -> FileRecord {
1177 FileRecord {
1178 path: "src/store/db.rs".to_string(),
1179 purpose: "Initialises SurrealKV trees and exposes the Store handle.".to_string(),
1180 entry_points: vec!["Store::open".to_string()],
1181 imports: vec!["surrealkv".to_string()],
1182 gotcha_keys: vec!["gotcha:inference-async".to_string()],
1183 decision_keys: vec![],
1184 todos: vec![TodoComment {
1185 text: "add fsync benchmark".to_string(),
1186 line: 42,
1187 kind: TodoKind::Todo,
1188 }],
1189 unsafe_count: 0,
1190 unwrap_count: 1,
1191 change_frequency: 12,
1192 last_author: Some("ioni".to_string()),
1193 is_hotspot: false,
1194 token_cost_estimate: 180,
1195 last_modified_session: 1_710_520_800,
1196 content_hash: None,
1197 line_count: 0,
1198 blast_radius: None,
1199 propagated_staleness: None,
1200 }
1201 }
1202
1203 fn sample_context_packet() -> ContextPacket {
1204 ContextPacket {
1205 stage: None,
1206 critical_gotchas: vec![sample_record()],
1207 file_records: vec![sample_file_record()],
1208 related_decisions: vec![],
1209 recent_session: Some(
1210 "Implemented storage layer. SurrealKV tree opened cleanly.".to_string(),
1211 ),
1212 token_estimate: 420,
1213 stale_warnings: vec![],
1214 unconfirmed_candidates: vec!["file:src/analysis/walker.rs".to_string()],
1215 knowledge_gaps: vec![KnowledgeGap {
1216 key: "file:src/analysis/parser.rs".to_string(),
1217 gap_type: GapType::HotFileNoGotchas,
1218 risk_score: 0.72,
1219 description: "Hot file with 23 commits in 60d and no gotchas".to_string(),
1220 action_hint: "mati gotcha add src/analysis/parser.rs".to_string(),
1221 }],
1222 compliance_rate: None,
1223 injection_string: String::new(),
1224 }
1225 }
1226
1227 fn assert_serde_roundtrip<T>(value: &T)
1231 where
1232 T: Serialize + for<'de> Deserialize<'de>,
1233 {
1234 let json1 = serde_json::to_string(value).expect("serialization failed");
1235 let restored: T = serde_json::from_str(&json1).expect("deserialization failed");
1236 let json2 = serde_json::to_string(&restored).expect("re-serialization failed");
1237 assert_eq!(json1, json2, "serde round-trip produced different JSON");
1238 }
1239
1240 #[test]
1243 fn record_serde_roundtrip() {
1244 assert_serde_roundtrip(&sample_record());
1245 }
1246
1247 #[test]
1248 fn file_record_serde_roundtrip() {
1249 assert_serde_roundtrip(&sample_file_record());
1250 }
1251
1252 #[test]
1256 fn file_record_backward_compat_no_blast_radius() {
1257 let json = r#"{
1258 "path": "src/main.rs",
1259 "purpose": "Entry point",
1260 "entry_points": ["main"],
1261 "imports": [],
1262 "gotcha_keys": [],
1263 "decision_keys": [],
1264 "todos": [],
1265 "unsafe_count": 0,
1266 "unwrap_count": 0,
1267 "change_frequency": 5,
1268 "last_author": "dev",
1269 "is_hotspot": false,
1270 "token_cost_estimate": 100,
1271 "last_modified_session": 1710520800
1272 }"#;
1273 let fr: FileRecord = serde_json::from_str(json).unwrap();
1274 assert!(fr.blast_radius.is_none());
1275 assert_eq!(fr.path, "src/main.rs");
1276 assert_eq!(fr.content_hash, None);
1277 assert_eq!(fr.line_count, 0);
1278 }
1279
1280 #[test]
1281 fn gotcha_record_serde_roundtrip() {
1282 let gotcha = GotchaRecord {
1283 rule: "Never hold a write transaction across an await point.".to_string(),
1284 reason: "SurrealKV write txns are not Send; the future will not compile.".to_string(),
1285 severity: Priority::Critical,
1286 affected_files: vec!["src/store/db.rs".to_string()],
1287 ref_url: Some("https://github.com/example/issue/99".to_string()),
1288 discovered_session: 1_710_520_800,
1289 confirmed: true,
1290 confirmed_content: Default::default(),
1291 };
1292 assert_serde_roundtrip(&gotcha);
1293 }
1294
1295 #[test]
1296 fn policy_record_roundtrips_through_record_messagepack() {
1297 let policy = PolicyRecord {
1298 name: "Production query safety".into(),
1299 rule: "Consult the production schema before querying it.".into(),
1300 reason: "Queries can target incompatible production tables because schemas drift."
1301 .into(),
1302 scope: "repo".into(),
1303 mode: PolicyMode::Block,
1304 trigger: PolicyTrigger {
1305 tool: Some("db_client".into()),
1306 host_glob: Some("*prod*".into()),
1307 target_path_glob: None,
1308 command_glob: None,
1309 },
1310 requires: PolicyRequires {
1311 key: "schema:orders_db".into(),
1312 via: vec![ReceiptSource::DbIntrospection, ReceiptSource::MemGet],
1313 freshness: PolicyFreshness {
1314 ttl_secs: 900,
1315 fingerprint: false,
1316 },
1317 },
1318 stage: PolicyStage::Enforce,
1319 severity: Priority::Critical,
1320 created_by: "developer".into(),
1321 };
1322 let record = Record {
1323 key: "policy:production-query-safety".into(),
1324 value: policy.rule.clone(),
1325 category: Category::Policy,
1326 priority: policy.severity.clone(),
1327 tags: vec![],
1328 created_at: 1_710_520_800,
1329 updated_at: 1_710_520_800,
1330 ref_url: None,
1331 staleness: StalenessScore::fresh(),
1332 lifecycle: RecordLifecycle::Active,
1333 version: RecordVersion {
1334 device_id: device_id(),
1335 logical_clock: 1,
1336 wall_clock: 1_710_520_800,
1337 },
1338 quality: QualityScore::layer0_default(),
1339 access_count: 0,
1340 last_accessed: 0,
1341 source: RecordSource::DeveloperManual,
1342 confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
1343 gap_analysis_score: 0.0,
1344 payload: Some(serde_json::to_value(&policy).unwrap()),
1345 };
1346 let bytes = rmp_serde::to_vec_named(&record).unwrap();
1347 let restored: Record = rmp_serde::from_slice(&bytes).unwrap();
1348 assert_eq!(restored.category, Category::Policy);
1349 assert_eq!(restored.payload_as::<PolicyRecord>().unwrap(), policy);
1350 }
1351
1352 #[test]
1353 fn policy_requires_defaults_when_payload_omits_it() {
1354 let full = PolicyRecord {
1358 name: "n".into(),
1359 rule: "r".into(),
1360 reason: "why".into(),
1361 scope: "repo".into(),
1362 mode: PolicyMode::Steer,
1363 trigger: PolicyTrigger {
1364 tool: Some("path".into()),
1365 host_glob: None,
1366 target_path_glob: Some("src/secrets/**".into()),
1367 command_glob: None,
1368 },
1369 requires: default_policy_requires(),
1370 stage: PolicyStage::Off,
1371 severity: Priority::High,
1372 created_by: "developer".into(),
1373 };
1374 let mut value = serde_json::to_value(&full).unwrap();
1375 value.as_object_mut().unwrap().remove("requires");
1376 let parsed: PolicyRecord = serde_json::from_value(value).unwrap();
1377 assert_eq!(parsed.requires, default_policy_requires());
1378 assert_eq!(parsed.requires.freshness.ttl_secs, DEFAULT_POLICY_TTL_SECS);
1379 assert!(parsed.requires.key.is_empty());
1380 }
1381
1382 #[test]
1383 fn policy_requires_default_matches_cli_literal() {
1384 let cli = r#"{"key":"","via":[],"freshness":{"ttl_secs":900}}"#;
1387 let from_cli: PolicyRequires = serde_json::from_str(cli).unwrap();
1388 assert_eq!(from_cli, default_policy_requires());
1389 }
1390
1391 #[test]
1392 fn context_packet_serde_roundtrip() {
1393 assert_serde_roundtrip(&sample_context_packet());
1394 }
1395
1396 #[test]
1399 fn record_lifecycle_tombstoned_serde() {
1400 let lifecycle = RecordLifecycle::Tombstoned {
1401 reason: TombstoneReason::FileDeleted,
1402 at: 1_710_520_800,
1403 };
1404 assert_serde_roundtrip(&lifecycle);
1405 }
1406
1407 #[test]
1408 fn record_lifecycle_superseded_serde() {
1409 let lifecycle = RecordLifecycle::Superseded {
1410 by_key: "gotcha:inference-async-v2".to_string(),
1411 };
1412 assert_serde_roundtrip(&lifecycle);
1413 }
1414
1415 #[test]
1416 fn tombstone_reason_file_renamed_serde() {
1417 let reason = TombstoneReason::FileRenamed {
1418 new_path: "src/store/backend.rs".to_string(),
1419 };
1420 assert_serde_roundtrip(&reason);
1421 }
1422
1423 #[test]
1426 fn staleness_signal_dependency_bumped_serde() {
1427 let signal = StalenessSignal::DependencyBumped {
1428 dep: "tokio".to_string(),
1429 old_ver: "1.40".to_string(),
1430 new_ver: "1.50".to_string(),
1431 };
1432 assert_serde_roundtrip(&signal);
1433 }
1434
1435 #[test]
1436 fn staleness_signal_file_renamed_serde() {
1437 let signal = StalenessSignal::FileRenamed {
1438 new_path: "src/store/backend.rs".to_string(),
1439 };
1440 assert_serde_roundtrip(&signal);
1441 }
1442
1443 #[test]
1444 fn staleness_signal_cascade_serde() {
1445 let signal = StalenessSignal::CascadeFromDecision("decision:storage-engine".to_string());
1446 assert_serde_roundtrip(&signal);
1447 }
1448
1449 #[test]
1450 fn staleness_score_fresh_default() {
1451 let s = StalenessScore::fresh();
1452 assert_eq!(s.tier, StalenessTier::Fresh);
1453 assert_eq!(s.value, 0.0);
1454 assert!(s.signals.is_empty());
1455 assert_eq!(s.computed_at, 0, "0 = not yet computed sentinel");
1456 assert!(s.last_record_sha.is_empty());
1457 }
1458
1459 #[test]
1462 fn quality_tier_ranges() {
1463 assert_eq!(QualityScore::tier_from_value(0.00), QualityTier::Suppressed);
1464 assert_eq!(QualityScore::tier_from_value(0.10), QualityTier::Suppressed);
1465 assert_eq!(QualityScore::tier_from_value(0.19), QualityTier::Suppressed);
1466 assert_eq!(QualityScore::tier_from_value(0.20), QualityTier::Poor);
1467 assert_eq!(QualityScore::tier_from_value(0.30), QualityTier::Poor);
1468 assert_eq!(QualityScore::tier_from_value(0.39), QualityTier::Poor);
1469 assert_eq!(QualityScore::tier_from_value(0.40), QualityTier::Acceptable);
1470 assert_eq!(QualityScore::tier_from_value(0.55), QualityTier::Acceptable);
1471 assert_eq!(QualityScore::tier_from_value(0.69), QualityTier::Acceptable);
1472 assert_eq!(QualityScore::tier_from_value(0.70), QualityTier::Good);
1473 assert_eq!(QualityScore::tier_from_value(0.80), QualityTier::Good);
1474 assert_eq!(QualityScore::tier_from_value(0.89), QualityTier::Good);
1475 assert_eq!(QualityScore::tier_from_value(0.90), QualityTier::Excellent);
1477 assert_eq!(QualityScore::tier_from_value(0.95), QualityTier::Excellent);
1478 assert_eq!(QualityScore::tier_from_value(1.00), QualityTier::Excellent);
1479 }
1480
1481 #[test]
1484 fn confidence_base_scores_by_source() {
1485 assert_eq!(
1486 ConfidenceScore::base_for_source(&RecordSource::DeveloperManual),
1487 0.80
1488 );
1489 assert_eq!(
1490 ConfidenceScore::base_for_source(&RecordSource::Import),
1491 0.70
1492 );
1493 assert_eq!(
1494 ConfidenceScore::base_for_source(&RecordSource::ClaudeEnrich),
1495 0.60
1496 );
1497 assert_eq!(
1498 ConfidenceScore::base_for_source(&RecordSource::SessionHook),
1499 0.50
1500 );
1501 assert_eq!(
1502 ConfidenceScore::base_for_source(&RecordSource::StaticAnalysis),
1503 0.10
1504 );
1505 }
1506
1507 #[test]
1508 fn confidence_for_new_record_value_matches_base() {
1509 let source = RecordSource::ClaudeEnrich;
1510 let score = ConfidenceScore::for_new_record(&source);
1511 assert_eq!(score.value, ConfidenceScore::base_for_source(&source));
1512 assert_eq!(score.confirmation_count, 0);
1513 assert_eq!(score.contributor_count, 1);
1514 assert!(score.last_challenged.is_none());
1515 assert_eq!(score.challenge_count, 0);
1516 }
1517
1518 #[test]
1521 fn priority_total_ordering() {
1522 assert!(Priority::Critical > Priority::High);
1523 assert!(Priority::High > Priority::Normal);
1524 assert!(Priority::Normal > Priority::Low);
1525 assert!(Priority::Critical > Priority::Low);
1526 assert_eq!(Priority::High, Priority::High);
1527 }
1528
1529 #[test]
1532 fn record_device_id_accessor_matches_version() {
1533 let rec = sample_record();
1534 assert_eq!(rec.device_id(), rec.version.device_id);
1535 }
1536
1537 #[test]
1540 fn quality_tier_non_finite_is_suppressed() {
1541 assert_eq!(
1544 QualityScore::tier_from_value(f32::NAN),
1545 QualityTier::Suppressed
1546 );
1547 assert_eq!(
1548 QualityScore::tier_from_value(f32::INFINITY),
1549 QualityTier::Suppressed
1550 );
1551 assert_eq!(
1552 QualityScore::tier_from_value(f32::NEG_INFINITY),
1553 QualityTier::Suppressed
1554 );
1555 }
1556
1557 #[test]
1558 fn quality_tier_out_of_range_finite_saturates() {
1559 assert_eq!(QualityScore::tier_from_value(-1.0), QualityTier::Suppressed);
1561 assert_eq!(
1562 QualityScore::tier_from_value(-0.001),
1563 QualityTier::Suppressed
1564 );
1565 assert_eq!(QualityScore::tier_from_value(1.001), QualityTier::Excellent);
1566 assert_eq!(QualityScore::tier_from_value(100.0), QualityTier::Excellent);
1567 }
1568
1569 #[test]
1570 fn layer0_default_quality_is_suppressed_tier() {
1571 let q = QualityScore::layer0_default();
1572 assert_eq!(q.tier, QualityTier::Suppressed);
1573 assert_eq!(q.value, 0.10);
1574 assert!(q.signals.is_empty());
1575 assert_eq!(q.computed_at, 0, "0 = not yet computed sentinel");
1576 }
1577
1578 #[test]
1581 fn confidence_for_new_record_all_sources_correct() {
1582 let cases: &[(RecordSource, f32)] = &[
1583 (RecordSource::DeveloperManual, 0.80),
1584 (RecordSource::Import, 0.70),
1585 (RecordSource::ClaudeEnrich, 0.60),
1586 (RecordSource::SessionHook, 0.50),
1587 (RecordSource::StaticAnalysis, 0.10),
1588 ];
1589 for (source, expected) in cases {
1590 let score = ConfidenceScore::for_new_record(source);
1591 assert!(
1592 (score.value - expected).abs() < f32::EPSILON,
1593 "{source:?}: expected {expected}, got {}",
1594 score.value
1595 );
1596 assert_eq!(score.confirmation_count, 0);
1597 assert_eq!(score.contributor_count, 1);
1598 assert!(score.last_challenged.is_none());
1599 assert_eq!(score.challenge_count, 0);
1600 }
1601 }
1602
1603 #[test]
1604 fn confidence_base_scores_are_all_distinct() {
1605 let scores: Vec<f32> = [
1606 RecordSource::DeveloperManual,
1607 RecordSource::Import,
1608 RecordSource::ClaudeEnrich,
1609 RecordSource::SessionHook,
1610 RecordSource::StaticAnalysis,
1611 ]
1612 .iter()
1613 .map(ConfidenceScore::base_for_source)
1614 .collect();
1615
1616 for i in 0..scores.len() {
1617 for j in (i + 1)..scores.len() {
1618 assert!(
1619 (scores[i] - scores[j]).abs() > f32::EPSILON,
1620 "sources {i} and {j} have identical base score {}",
1621 scores[i]
1622 );
1623 }
1624 }
1625 }
1626
1627 #[test]
1630 fn priority_exhaustive_pairwise_ordering() {
1631 use std::cmp::Ordering::*;
1632 let pairs = [
1633 (Priority::Low, Priority::Normal, Less),
1634 (Priority::Low, Priority::High, Less),
1635 (Priority::Low, Priority::Critical, Less),
1636 (Priority::Normal, Priority::High, Less),
1637 (Priority::Normal, Priority::Critical, Less),
1638 (Priority::High, Priority::Critical, Less),
1639 (Priority::Low, Priority::Low, Equal),
1640 (Priority::Normal, Priority::Normal, Equal),
1641 (Priority::High, Priority::High, Equal),
1642 (Priority::Critical, Priority::Critical, Equal),
1643 ];
1644 for (a, b, expected) in pairs {
1645 assert_eq!(
1646 a.cmp(&b),
1647 expected,
1648 "{a:?}.cmp({b:?}) should be {expected:?}"
1649 );
1650 if expected == Less {
1652 assert_eq!(b.cmp(&a), std::cmp::Ordering::Greater, "{b:?}.cmp({a:?})");
1653 }
1654 }
1655 }
1656
1657 #[test]
1660 fn staleness_all_signal_variants_serde() {
1661 let signals: Vec<StalenessSignal> = vec![
1662 StalenessSignal::NotAccessedDays(30),
1663 StalenessSignal::LinesChangedPct(0.75),
1664 StalenessSignal::EntryPointsChanged(2),
1665 StalenessSignal::ImportsChanged(5),
1666 StalenessSignal::FileDeleted,
1667 StalenessSignal::FileRenamed {
1668 new_path: "src/foo.rs".to_string(),
1669 },
1670 StalenessSignal::DependencyBumped {
1671 dep: "tokio".to_string(),
1672 old_ver: "1.40".to_string(),
1673 new_ver: "1.50".to_string(),
1674 },
1675 StalenessSignal::LinkedFileChanged {
1676 path: "src/bar.rs".to_string(),
1677 },
1678 StalenessSignal::CascadeFromDecision("decision:arch".to_string()),
1679 StalenessSignal::TodosChanged,
1680 StalenessSignal::UnsafeCountChanged(3),
1681 StalenessSignal::UnwrapCountChanged(-2),
1682 StalenessSignal::GitCommitsSince(7),
1683 ];
1684 for signal in &signals {
1685 let json = serde_json::to_string(signal).expect("serialize");
1686 let restored: StalenessSignal = serde_json::from_str(&json).expect("deserialize");
1687 let json2 = serde_json::to_string(&restored).expect("re-serialize");
1688 assert_eq!(json, json2, "roundtrip failed for: {json}");
1689 }
1690 }
1691
1692 #[test]
1695 fn tombstone_reason_all_variants_serde() {
1696 let reasons = vec![
1697 TombstoneReason::FileDeleted,
1698 TombstoneReason::FileRenamed {
1699 new_path: "src/new.rs".to_string(),
1700 },
1701 TombstoneReason::ManualDeletion,
1702 TombstoneReason::Superseded,
1703 TombstoneReason::MigrationRepair,
1704 ];
1705 for reason in &reasons {
1706 assert_serde_roundtrip(reason);
1707 }
1708 }
1709
1710 #[test]
1713 fn category_serializes_as_snake_case() {
1714 let cases = [
1715 (Category::Gotcha, "\"gotcha\""),
1716 (Category::File, "\"file\""),
1717 (Category::Decision, "\"decision\""),
1718 (Category::Stage, "\"stage\""),
1719 (Category::Dependency, "\"dependency\""),
1720 (Category::DevNote, "\"dev_note\""),
1721 (Category::Session, "\"session\""),
1722 (Category::Analytics, "\"analytics\""),
1723 ];
1724 for (cat, expected_json) in cases {
1725 let json = serde_json::to_string(&cat).unwrap();
1726 assert_eq!(json, expected_json, "Category::{cat:?}");
1727 }
1728 }
1729
1730 #[test]
1731 fn record_source_serializes_as_snake_case() {
1732 let cases = [
1733 (RecordSource::StaticAnalysis, "\"static_analysis\""),
1734 (RecordSource::ClaudeEnrich, "\"claude_enrich\""),
1735 (RecordSource::SessionHook, "\"session_hook\""),
1736 (RecordSource::DeveloperManual, "\"developer_manual\""),
1737 (RecordSource::Import, "\"import\""),
1738 ];
1739 for (src, expected_json) in cases {
1740 let json = serde_json::to_string(&src).unwrap();
1741 assert_eq!(json, expected_json, "RecordSource::{src:?}");
1742 }
1743 }
1744
1745 #[test]
1746 fn staleness_tier_serializes_as_snake_case() {
1747 let cases = [
1749 (StalenessTier::Fresh, "\"fresh\""),
1750 (StalenessTier::Aging, "\"aging\""),
1751 (StalenessTier::Stale, "\"stale\""),
1752 (StalenessTier::Liability, "\"liability\""),
1753 (StalenessTier::Tombstone, "\"tombstone\""),
1754 ];
1755 for (tier, expected_json) in cases {
1756 let json = serde_json::to_string(&tier).unwrap();
1757 assert_eq!(json, expected_json, "StalenessTier::{tier:?}");
1758 }
1759 }
1760
1761 #[test]
1764 fn gotcha_record_layer0_stub_is_unconfirmed() {
1765 let stub = GotchaRecord {
1768 rule: "Do not call .await inside rayon::spawn.".to_string(),
1769 reason: "rayon threads have no tokio runtime.".to_string(),
1770 severity: Priority::Critical,
1771 affected_files: vec!["src/analysis/walker.rs".to_string()],
1772 ref_url: None,
1773 discovered_session: 0,
1774 confirmed: false,
1775 confirmed_content: Default::default(),
1776 };
1777 assert!(
1778 !stub.confirmed,
1779 "Layer 0 stubs must be unconfirmed on construction"
1780 );
1781
1782 let json = serde_json::to_string(&stub).unwrap();
1784 let restored: GotchaRecord = serde_json::from_str(&json).unwrap();
1785 assert!(
1786 !restored.confirmed,
1787 "confirmed flag must survive serde roundtrip"
1788 );
1789 assert!(json.contains("\"confirmed\":false"), "wire format: {json}");
1791 }
1792
1793 #[test]
1794 fn gotcha_record_confirmed_true_roundtrips() {
1795 let confirmed = GotchaRecord {
1796 rule: "Use SurrealKV::with_versioning(true, 0) for indefinite retention.".to_string(),
1797 reason: "0 means retain all versions forever, not disabled.".to_string(),
1798 severity: Priority::High,
1799 affected_files: vec!["src/store/db.rs".to_string()],
1800 ref_url: Some("https://github.com/example/issue/5".to_string()),
1801 discovered_session: 1_710_520_800,
1802 confirmed: true,
1803 confirmed_content: Default::default(),
1804 };
1805 assert_serde_roundtrip(&confirmed);
1806 let json = serde_json::to_string(&confirmed).unwrap();
1807 assert!(json.contains("\"confirmed\":true"));
1808 }
1809
1810 #[test]
1813 fn staleness_score_fully_populated_serde() {
1814 let s = StalenessScore {
1815 value: 0.87,
1816 tier: StalenessTier::Liability,
1817 signals: vec![
1818 StalenessSignal::NotAccessedDays(90),
1819 StalenessSignal::LinesChangedPct(0.6),
1820 StalenessSignal::EntryPointsChanged(3),
1821 StalenessSignal::FileRenamed {
1822 new_path: "src/store/backend.rs".to_string(),
1823 },
1824 ],
1825 computed_at: 1_710_520_800,
1826 last_record_sha: "deadbeefcafe0123".to_string(),
1827 };
1828 assert_serde_roundtrip(&s);
1829 let json = serde_json::to_string(&s).unwrap();
1830 let restored: StalenessScore = serde_json::from_str(&json).unwrap();
1831 assert_eq!(restored.tier, StalenessTier::Liability);
1832 assert_eq!(restored.signals.len(), 4);
1833 assert_eq!(restored.last_record_sha, "deadbeefcafe0123");
1834 }
1835
1836 #[test]
1837 fn quality_score_with_all_positive_signals_serde() {
1838 let q = QualityScore {
1839 value: 0.92,
1840 tier: QualityTier::Excellent,
1841 signals: vec![
1842 QualitySignal::HasImperativeVerb,
1843 QualitySignal::HasCausality,
1844 QualitySignal::HasSeveritySet,
1845 QualitySignal::HasReference,
1846 QualitySignal::RuleLengthAdequate,
1847 QualitySignal::ReasonLengthAdequate,
1848 QualitySignal::AffectedFilesSpecified,
1849 QualitySignal::HasSpecificIdentifier,
1850 ],
1851 computed_at: 1_710_520_800,
1852 };
1853 assert_serde_roundtrip(&q);
1854 let json = serde_json::to_string(&q).unwrap();
1855 let restored: QualityScore = serde_json::from_str(&json).unwrap();
1856 assert_eq!(restored.tier, QualityTier::Excellent);
1857 assert_eq!(restored.signals.len(), 8);
1858 }
1859
1860 #[test]
1861 fn confidence_score_with_challenge_history_serde() {
1862 let c = ConfidenceScore {
1864 value: 0.45,
1865 confirmation_count: 1,
1866 contributor_count: 3,
1867 last_challenged: Some(1_710_500_000),
1868 challenge_count: 2,
1869 };
1870 let json = serde_json::to_string(&c).unwrap();
1871 let restored: ConfidenceScore = serde_json::from_str(&json).unwrap();
1872 assert_eq!(restored.last_challenged, Some(1_710_500_000));
1873 assert_eq!(restored.challenge_count, 2);
1874 assert_eq!(restored.contributor_count, 3);
1875 let json2 = serde_json::to_string(&restored).unwrap();
1876 assert_eq!(json, json2);
1877 }
1878
1879 #[test]
1880 fn record_ref_url_none_does_not_become_some() {
1881 let mut r = sample_record();
1883 r.ref_url = None;
1884 let json = serde_json::to_string(&r).unwrap();
1885 let restored: Record = serde_json::from_str(&json).unwrap();
1886 assert!(
1887 restored.ref_url.is_none(),
1888 "ref_url: None must not become Some after roundtrip"
1889 );
1890 assert!(
1891 json.contains("\"ref_url\":null"),
1892 "wire format must encode None as null"
1893 );
1894 }
1895
1896 #[test]
1897 fn context_packet_zero_knowledge_case_serde() {
1898 let empty = ContextPacket {
1900 stage: None,
1901 critical_gotchas: vec![],
1902 file_records: vec![],
1903 related_decisions: vec![],
1904 recent_session: None,
1905 token_estimate: 0,
1906 stale_warnings: vec![],
1907 unconfirmed_candidates: vec![],
1908 knowledge_gaps: vec![],
1909 compliance_rate: None,
1910 injection_string: String::new(),
1911 };
1912 assert_serde_roundtrip(&empty);
1913 let json = serde_json::to_string(&empty).unwrap();
1914 let restored: ContextPacket = serde_json::from_str(&json).unwrap();
1915 assert!(restored.critical_gotchas.is_empty());
1916 assert!(restored.file_records.is_empty());
1917 assert!(restored.stage.is_none());
1918 assert_eq!(restored.token_estimate, 0);
1919 }
1920
1921 #[test]
1922 fn record_tags_empty_and_many_both_survive_serde() {
1923 let mut r = sample_record();
1924
1925 r.tags = vec![];
1926 let json_empty = serde_json::to_string(&r).unwrap();
1927 let restored_empty: Record = serde_json::from_str(&json_empty).unwrap();
1928 assert!(
1929 restored_empty.tags.is_empty(),
1930 "empty tags must remain empty"
1931 );
1932
1933 r.tags = (0..50).map(|i| format!("tag-{i:03}")).collect();
1934 let json_many = serde_json::to_string(&r).unwrap();
1935 let restored_many: Record = serde_json::from_str(&json_many).unwrap();
1936 assert_eq!(restored_many.tags.len(), 50);
1937 assert_eq!(restored_many.tags[0], "tag-000");
1938 assert_eq!(restored_many.tags[49], "tag-049");
1939 }
1940
1941 #[test]
1942 fn file_record_layer0_stub_serde() {
1943 let stub = FileRecord::layer0_stub(
1945 "src/analysis/walker.rs",
1946 vec![],
1947 vec!["ignore".to_string(), "rayon".to_string()],
1948 vec![],
1949 0,
1950 3,
1951 17,
1952 None,
1953 true,
1954 0,
1955 0,
1956 );
1957 assert_serde_roundtrip(&stub);
1958 let json = serde_json::to_string(&stub).unwrap();
1959 let restored: FileRecord = serde_json::from_str(&json).unwrap();
1960 assert!(
1961 restored.purpose.is_empty(),
1962 "empty purpose must remain empty"
1963 );
1964 assert!(restored.entry_points.is_empty());
1965 assert!(restored.last_author.is_none());
1966 assert!(restored.is_hotspot);
1967 assert_eq!(restored.unwrap_count, 3);
1968 }
1969
1970 #[test]
1971 fn layer0_file_record_builder_sets_suppressed_quality() {
1972 let record =
1973 Record::layer0_file_stub("file:src/analysis/walker.rs", device_id(), 7, 1_710_520_800);
1974
1975 assert_eq!(record.key, "file:src/analysis/walker.rs");
1976 assert_eq!(record.category, Category::File);
1977 assert!(record.value.is_empty());
1978 assert_eq!(record.quality.value, 0.10);
1979 assert_eq!(record.quality.tier, QualityTier::Suppressed);
1980 assert_eq!(record.source, RecordSource::StaticAnalysis);
1981 assert_eq!(record.confidence.value, 0.10);
1982 assert_eq!(record.confidence.contributor_count, 1);
1983 }
1984
1985 #[test]
1988 fn stale_review_entry_serde_roundtrip() {
1989 let entry = StaleReviewEntry {
1990 key: "file:src/store/db.rs".to_string(),
1991 staleness_value: 0.72,
1992 tier: StalenessTier::Stale,
1993 last_updated: 1_710_520_800,
1994 signals: vec![
1995 "not accessed for 45 days".to_string(),
1996 "3 entry points changed".to_string(),
1997 ],
1998 };
1999 assert_serde_roundtrip(&entry);
2000 }
2001
2002 #[test]
2003 fn stale_review_payload_serde_roundtrip() {
2004 let payload = StaleReviewPayload {
2005 session_timestamp: 1_710_520_800,
2006 entries: vec![
2007 StaleReviewEntry {
2008 key: "file:src/store/db.rs".to_string(),
2009 staleness_value: 0.72,
2010 tier: StalenessTier::Stale,
2011 last_updated: 1_710_500_000,
2012 signals: vec!["not accessed for 45 days".to_string()],
2013 },
2014 StaleReviewEntry {
2015 key: "gotcha:inference-async".to_string(),
2016 staleness_value: 0.85,
2017 tier: StalenessTier::Liability,
2018 last_updated: 1_710_400_000,
2019 signals: vec![
2020 "90 commits since last confirmation".to_string(),
2021 "75% of lines changed".to_string(),
2022 ],
2023 },
2024 ],
2025 };
2026 assert_serde_roundtrip(&payload);
2027 let json = serde_json::to_string(&payload).unwrap();
2028 let restored: StaleReviewPayload = serde_json::from_str(&json).unwrap();
2029 assert_eq!(restored.entries.len(), 2);
2030 assert_eq!(restored.session_timestamp, 1_710_520_800);
2031 }
2032
2033 #[test]
2034 fn stale_review_payload_empty_entries_serde() {
2035 let payload = StaleReviewPayload {
2036 session_timestamp: 1_710_520_800,
2037 entries: vec![],
2038 };
2039 assert_serde_roundtrip(&payload);
2040 let json = serde_json::to_string(&payload).unwrap();
2041 let restored: StaleReviewPayload = serde_json::from_str(&json).unwrap();
2042 assert!(restored.entries.is_empty());
2043 }
2044
2045 #[test]
2048 fn staleness_signal_git_commits_since_serde() {
2049 let signal = StalenessSignal::GitCommitsSince(42);
2050 assert_serde_roundtrip(&signal);
2051 let json = serde_json::to_string(&signal).unwrap();
2052 assert!(json.contains("git_commits_since"), "wire format: {json}");
2053 }
2054
2055 #[test]
2056 fn staleness_signal_git_commits_since_display() {
2057 let signal = StalenessSignal::GitCommitsSince(7);
2058 assert_eq!(signal.to_string(), "7 commits since last confirmation");
2059 }
2060
2061 #[test]
2062 fn staleness_signal_display_all_variants() {
2063 let signals: Vec<StalenessSignal> = vec![
2065 StalenessSignal::NotAccessedDays(30),
2066 StalenessSignal::LinesChangedPct(0.75),
2067 StalenessSignal::EntryPointsChanged(2),
2068 StalenessSignal::ImportsChanged(5),
2069 StalenessSignal::FileDeleted,
2070 StalenessSignal::FileRenamed {
2071 new_path: "src/foo.rs".to_string(),
2072 },
2073 StalenessSignal::DependencyBumped {
2074 dep: "tokio".to_string(),
2075 old_ver: "1.40".to_string(),
2076 new_ver: "1.50".to_string(),
2077 },
2078 StalenessSignal::LinkedFileChanged {
2079 path: "src/bar.rs".to_string(),
2080 },
2081 StalenessSignal::CascadeFromDecision("decision:arch".to_string()),
2082 StalenessSignal::TodosChanged,
2083 StalenessSignal::UnsafeCountChanged(3),
2084 StalenessSignal::UnwrapCountChanged(-2),
2085 StalenessSignal::GitCommitsSince(7),
2086 ];
2087 for signal in &signals {
2088 let display = signal.to_string();
2089 assert!(
2090 !display.is_empty(),
2091 "Display for {signal:?} should not be empty"
2092 );
2093 }
2094 }
2095}