fallow_cov_protocol/lib.rs
1//! Versioned envelope types shared between the public `fallow` CLI and the
2//! closed-source `fallow-cov` production-coverage sidecar.
3//!
4//! The public CLI builds a [`Request`] from its static analysis output, spawns
5//! the sidecar, writes the request to stdin, and reads a [`Response`] from
6//! stdout. Both sides depend on this crate to guarantee contract alignment.
7//!
8//! # Versioning
9//!
10//! The top-level `protocol_version` field is a full semver string. Major
11//! bumps indicate breaking changes; consumers MUST reject mismatched majors.
12//! Minor bumps add optional fields; consumers MUST forward-accept unknown
13//! fields and SHOULD map unknown enum variants to [`Feature::Unknown`],
14//! [`ReportVerdict::Unknown`], or [`Verdict::Unknown`] rather than erroring.
15//!
16//! # 0.2 overview
17//!
18//! This is the first production-shaped contract. The top-level
19//! [`ReportVerdict`] (previously `Verdict`) is unchanged in meaning but was
20//! renamed to avoid colliding with per-finding [`Verdict`]. Each
21//! [`Finding`] and [`HotPath`] now carries a deterministic [`finding_id`] /
22//! [`hot_path_id`] hash, a full [`Evidence`] block, and — for findings — a
23//! per-function verdict and nullable invocation count. [`Confidence`]
24//! gained `VeryHigh` and `None` variants to match the decision table in
25//! `.internal/spec-production-coverage.md`.
26//!
27//! [`StaticFunction::static_used`] and [`StaticFunction::test_covered`] are
28//! intentionally required (no `#[serde(default)]`) — a silent default would
29//! hide every `safe_to_delete` finding, so 0.1-shape requests must fail
30//! deserialization instead of parsing into a wrong answer.
31
32#![forbid(unsafe_code)]
33
34use serde::{Deserialize, Serialize};
35use sha2::{Digest, Sha256};
36
37/// Current protocol version. Bumped per the semver rules above.
38pub const PROTOCOL_VERSION: &str = "0.3.0";
39
40// -- Request envelope -------------------------------------------------------
41
42/// Sent by the public CLI to the sidecar via stdin.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Request {
45 /// Semver string of the protocol version this request targets.
46 pub protocol_version: String,
47 /// License material the sidecar validates before running coverage analysis.
48 pub license: License,
49 /// Absolute path of the project root under analysis.
50 pub project_root: String,
51 /// One or more coverage artifacts the sidecar should ingest.
52 pub coverage_sources: Vec<CoverageSource>,
53 /// Static analysis output the public CLI already produced for this run.
54 pub static_findings: StaticFindings,
55 /// Optional runtime knobs; all fields default to forward-compatible values.
56 #[serde(default)]
57 pub options: Options,
58}
59
60/// The license material the sidecar should validate.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct License {
63 /// Full JWT string, already stripped of whitespace.
64 pub jwt: String,
65}
66
67/// A single coverage artifact on disk.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(tag = "kind", rename_all = "kebab-case")]
70pub enum CoverageSource {
71 /// A single V8 `ScriptCoverage` JSON file.
72 V8 {
73 /// Absolute path to the V8 coverage JSON file.
74 path: String,
75 },
76 /// A single Istanbul JSON file.
77 Istanbul {
78 /// Absolute path to the Istanbul coverage JSON file.
79 path: String,
80 },
81 /// A directory containing multiple V8 dumps to merge in memory.
82 V8Dir {
83 /// Absolute path to the directory containing V8 dump files.
84 path: String,
85 },
86}
87
88/// Static analysis output the public CLI already produced.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct StaticFindings {
91 /// One entry per source file the CLI analyzed.
92 pub files: Vec<StaticFile>,
93}
94
95/// Static analysis results for a single source file.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct StaticFile {
98 /// Path to the source file, relative to [`Request::project_root`].
99 pub path: String,
100 /// Functions the CLI discovered in this file.
101 pub functions: Vec<StaticFunction>,
102}
103
104/// Static analysis results for a single function within a [`StaticFile`].
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct StaticFunction {
107 /// Function identifier as reported by the static analyzer. May be an
108 /// anonymous placeholder (e.g. `"<anonymous>"`) when the source has no
109 /// name at the definition site.
110 pub name: String,
111 /// 1-indexed line where the function body starts.
112 pub start_line: u32,
113 /// 1-indexed line where the function body ends (inclusive).
114 pub end_line: u32,
115 /// Cyclomatic complexity of the function, as computed by the CLI.
116 pub cyclomatic: u32,
117 /// Whether this function is statically referenced by the module graph.
118 /// Drives [`Evidence::static_status`] and gates [`Verdict::SafeToDelete`].
119 /// Required: a missing field would silently default to "used" and hide
120 /// every `safe_to_delete` finding.
121 pub static_used: bool,
122 /// Whether this function is covered by the project's test suite.
123 /// Drives [`Evidence::test_coverage`]. Required for the same reason as
124 /// [`StaticFunction::static_used`].
125 pub test_covered: bool,
126}
127
128/// Runtime knobs. All fields are optional so new options can be added without
129/// a breaking change.
130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131pub struct Options {
132 /// When true the sidecar computes and returns [`Response::hot_paths`].
133 /// When false, hot-path computation is skipped entirely.
134 #[serde(default)]
135 pub include_hot_paths: bool,
136 /// Minimum invocation count a function must have to qualify as a hot path.
137 /// `None` defers to the sidecar's spec default.
138 #[serde(default)]
139 pub min_invocations_for_hot: Option<u64>,
140 /// Minimum total trace volume before `safe_to_delete` / `review_required`
141 /// verdicts are allowed at high/very-high confidence. Below this the
142 /// sidecar caps confidence at [`Confidence::Medium`]. Spec default `5000`.
143 #[serde(default)]
144 pub min_observation_volume: Option<u32>,
145 /// Fraction of total `trace_count` below which an invoked function is
146 /// classified as [`Verdict::LowTraffic`] instead of `active`. Spec default
147 /// `0.001` (0.1%).
148 #[serde(default)]
149 pub low_traffic_threshold: Option<f64>,
150 /// Total number of traces / request-equivalents the coverage dump covers.
151 /// Used as the denominator for the low-traffic ratio and gates the
152 /// minimum-observation-volume cap. When `None` the sidecar falls back to
153 /// the sum of observed invocations in the current request.
154 #[serde(default)]
155 pub trace_count: Option<u64>,
156 /// Number of days of observation the coverage dump represents. Surfaced
157 /// verbatim in [`Summary::period_days`] and [`Evidence::observation_days`].
158 #[serde(default)]
159 pub period_days: Option<u32>,
160 /// Number of distinct production deployments that contributed coverage.
161 /// Surfaced verbatim in [`Summary::deployments_seen`] and
162 /// [`Evidence::deployments_observed`].
163 #[serde(default)]
164 pub deployments_seen: Option<u32>,
165 /// Total observation window in seconds. Finer-grained than
166 /// [`Self::period_days`]; used to populate
167 /// [`CaptureQuality::window_seconds`]. When `None` the sidecar falls back
168 /// to `period_days * 86_400`. Added in protocol 0.3.0.
169 #[serde(default)]
170 pub window_seconds: Option<u64>,
171 /// Number of distinct production instances that contributed coverage.
172 /// Used to populate [`CaptureQuality::instances_observed`]. When `None`
173 /// the sidecar falls back to [`Self::deployments_seen`]. Added in
174 /// protocol 0.3.0.
175 #[serde(default)]
176 pub instances_observed: Option<u32>,
177}
178
179// -- Response envelope ------------------------------------------------------
180
181/// Emitted by the sidecar to stdout.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct Response {
184 /// Semver string of the protocol version the sidecar produced.
185 pub protocol_version: String,
186 /// Top-level report verdict summarizing the overall state of the run.
187 pub verdict: ReportVerdict,
188 /// Aggregate statistics across the whole analysis.
189 pub summary: Summary,
190 /// Per-function findings, one entry per observed or tracked function.
191 pub findings: Vec<Finding>,
192 /// Hot-path findings, populated only when [`Options::include_hot_paths`]
193 /// was set on the request. Defaults to empty.
194 #[serde(default)]
195 pub hot_paths: Vec<HotPath>,
196 /// Grace-period watermark the CLI should render in human output, if any.
197 #[serde(default)]
198 pub watermark: Option<Watermark>,
199 /// Non-fatal errors the sidecar emitted while processing the request.
200 #[serde(default)]
201 pub errors: Vec<DiagnosticMessage>,
202 /// Warnings the sidecar emitted while processing the request.
203 #[serde(default)]
204 pub warnings: Vec<DiagnosticMessage>,
205}
206
207/// Top-level report verdict (was `Verdict` in 0.1). Summarises the overall
208/// state of the run; per-finding verdicts live on [`Finding::verdict`].
209/// Unknown variants are forward-mapped to [`ReportVerdict::Unknown`].
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(rename_all = "kebab-case")]
212pub enum ReportVerdict {
213 /// No action required — production coverage confirms the codebase.
214 Clean,
215 /// One or more hot paths need attention (regression / drift).
216 HotPathChangesNeeded,
217 /// At least one finding indicates cold code that should be removed or
218 /// reviewed.
219 ColdCodeDetected,
220 /// The license JWT has expired but the sidecar is still operating inside
221 /// the configured grace window. Output is advisory.
222 LicenseExpiredGrace,
223 /// Sentinel for forward-compatibility with newer sidecars.
224 #[serde(other)]
225 Unknown,
226}
227
228/// Aggregate statistics describing the observed coverage dump.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Summary {
231 /// Number of functions the sidecar could observe in the V8 dump.
232 pub functions_tracked: u64,
233 /// Functions that received at least one invocation.
234 pub functions_hit: u64,
235 /// Functions that were tracked but never invoked.
236 pub functions_unhit: u64,
237 /// Functions the sidecar could not track (lazy-parsed, worker thread, etc.).
238 pub functions_untracked: u64,
239 /// Ratio of `functions_hit / functions_tracked`, expressed as percent.
240 pub coverage_percent: f64,
241 /// Total number of observed invocations across all functions in the
242 /// current request. Denominator for low-traffic classification.
243 pub trace_count: u64,
244 /// Days of observation covered by the supplied dump.
245 pub period_days: u32,
246 /// Distinct deployments contributing to the supplied dump.
247 pub deployments_seen: u32,
248 /// Quality of the capture window. Populated by the sidecar so the CLI
249 /// can render a "short window" warning alongside low-confidence verdicts,
250 /// and so the upgrade prompt can quantify the delta cloud mode would
251 /// provide. Optional for forward compatibility with 0.2.x sidecars;
252 /// 0.3.x always sets it. Added in protocol 0.3.0 per ADR 009 step 6b.
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub capture_quality: Option<CaptureQuality>,
255}
256
257/// Capture-quality telemetry surfaced alongside the aggregate summary.
258///
259/// First-touch local-mode captures (`fallow health --production-coverage-dir`)
260/// tend to produce short windows (minutes to an hour) against a single
261/// instance. Lazy-parsed scripts do not appear in V8 dumps unless they
262/// actually executed during the capture window, which a first-time user
263/// will read as "the tool is broken" rather than "the capture window is
264/// too short." This struct gives the CLI enough information to explain the
265/// state honestly and to quantify what continuous cloud monitoring would add.
266///
267/// Added in protocol 0.3.0 per ADR 009 step 6b, deliverable 2 of 3.
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
269pub struct CaptureQuality {
270 /// Total observation window in seconds. Finer-grained than
271 /// [`Summary::period_days`], which rounds up to whole days. A 12-minute
272 /// local capture reports `window_seconds: 720` and `period_days: 1`.
273 pub window_seconds: u64,
274 /// Number of distinct production instances that contributed to the
275 /// dump. Matches [`Summary::deployments_seen`] in the typical case but
276 /// is emitted separately so future captures can distinguish "one
277 /// deployment seen across many instances" from "many deployments".
278 pub instances_observed: u32,
279 /// True when the untracked-function ratio exceeds
280 /// [`Self::LAZY_PARSE_THRESHOLD_PERCENT`]. Signals that the CLI should
281 /// render a "short window" warning: many functions appearing as
282 /// untracked most likely reflect lazy-parsed code rather than
283 /// unreachable code, and the capture window is not long enough to
284 /// distinguish the two.
285 pub lazy_parse_warning: bool,
286 /// `functions_untracked / functions_tracked` as a percentage. Rounded
287 /// to two decimal places for JSON reproducibility. Provided so the CLI
288 /// can render the exact ratio that triggered the warning.
289 pub untracked_ratio_percent: f64,
290}
291
292impl CaptureQuality {
293 /// Threshold above which [`Self::lazy_parse_warning`] fires. Chosen so
294 /// a short window (minutes) against a typical Node app trips the
295 /// warning, while a multi-day continuous capture does not.
296 pub const LAZY_PARSE_THRESHOLD_PERCENT: f64 = 30.0;
297}
298
299/// A per-function finding combining static analysis and runtime coverage.
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct Finding {
302 /// Deterministic content hash of shape `fallow:prod:<hash>`. See
303 /// [`finding_id`] for the canonical helper.
304 pub id: String,
305 /// Path to the source file, relative to [`Request::project_root`].
306 pub file: String,
307 /// Function name as reported by the static analyzer. Matches
308 /// [`StaticFunction::name`].
309 pub function: String,
310 /// 1-indexed line number the function starts on. Included in the ID hash
311 /// so anonymous functions with identical names but different locations
312 /// get distinct IDs.
313 pub line: u32,
314 /// Per-finding verdict. Describes what the agent should do with this
315 /// specific function.
316 pub verdict: Verdict,
317 /// Raw invocation count from the V8 dump. `None` when the function was
318 /// not tracked (lazy-parsed, worker-thread isolate, etc.).
319 pub invocations: Option<u64>,
320 /// Confidence the sidecar has in this finding's [`Finding::verdict`].
321 pub confidence: Confidence,
322 /// Evidence rows the sidecar used to arrive at the finding.
323 pub evidence: Evidence,
324 /// Machine-readable next-step hints for AI agents.
325 #[serde(default)]
326 pub actions: Vec<Action>,
327}
328
329/// Per-finding verdict. Replaces the 0.1 `CallState` enum.
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332pub enum Verdict {
333 /// Statically unused AND never invoked in production with coverage tracked.
334 SafeToDelete,
335 /// Used somewhere statically / by tests / by an untracked call site but
336 /// never invoked in production. Needs a human look.
337 ReviewRequired,
338 /// V8 could not observe the function (lazy-parsed, worker thread,
339 /// dynamic code). Nothing can be said about runtime behaviour.
340 CoverageUnavailable,
341 /// Invoked in production but below the configured low-traffic threshold
342 /// relative to `trace_count`. Effectively dead in the current period.
343 LowTraffic,
344 /// Function was invoked above the low-traffic threshold — not dead.
345 Active,
346 /// Sentinel for forward-compatibility.
347 #[serde(other)]
348 Unknown,
349}
350
351/// Confidence the sidecar attaches to a [`Finding::verdict`].
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum Confidence {
355 /// Combined static + runtime signal: statically unused AND tracked AND
356 /// zero invocations. Strongest delete signal the sidecar emits.
357 VeryHigh,
358 /// Strong signal — one of static or runtime is dispositive, the other
359 /// agrees.
360 High,
361 /// Signals agree but observation volume or coverage fidelity tempers the
362 /// call.
363 Medium,
364 /// Weak signal — a single data point suggests the verdict but other
365 /// evidence is missing or ambiguous.
366 Low,
367 /// Explicit absence of confidence (e.g. coverage unavailable).
368 None,
369 /// Sentinel for forward-compatibility.
370 #[serde(other)]
371 Unknown,
372}
373
374/// Supporting evidence for a [`Finding`]. Mirrors the rows of the decision
375/// table in `.internal/spec-production-coverage.md` so the CLI can render the
376/// "why" behind each verdict without re-deriving it.
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct Evidence {
379 /// `"unused"` when the CLI marked the function statically unreachable,
380 /// `"used"` otherwise.
381 pub static_status: String,
382 /// `"covered"` or `"not_covered"` by the project's test suite.
383 pub test_coverage: String,
384 /// `"tracked"` when V8 observed the function, `"untracked"` otherwise.
385 pub v8_tracking: String,
386 /// Populated when `v8_tracking == "untracked"`. Values mirror the spec:
387 /// `"lazy_parsed"`, `"worker_thread"`, `"dynamic_eval"`, `"unknown"`.
388 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub untracked_reason: Option<String>,
390 /// Days of observation the decision rests on. Echoes [`Summary::period_days`].
391 pub observation_days: u32,
392 /// Distinct deployments the decision rests on. Echoes [`Summary::deployments_seen`].
393 pub deployments_observed: u32,
394}
395
396/// A function the sidecar identified as a hot path in the current dump.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct HotPath {
399 /// Deterministic content hash of shape `fallow:hot:<hash>`. See
400 /// [`hot_path_id`] for the canonical helper.
401 pub id: String,
402 /// Path to the source file, relative to [`Request::project_root`].
403 pub file: String,
404 /// Function name as reported by the static analyzer.
405 pub function: String,
406 /// 1-indexed line the function starts on.
407 pub line: u32,
408 /// Raw invocation count from the V8 dump.
409 pub invocations: u64,
410 /// Percentile rank of this function's invocation count over the
411 /// invocation distribution of the current response's hot paths. `100`
412 /// means the busiest function, `0` the quietest that still qualified.
413 pub percentile: u8,
414}
415
416/// Machine-readable next-step hint for AI agents.
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct Action {
419 /// Short identifier for the action kind (e.g. `"delete"`, `"inline"`,
420 /// `"review"`). Free-form on the wire to keep forward compatibility.
421 pub kind: String,
422 /// Human-readable one-liner describing the suggested action.
423 pub description: String,
424 /// Whether the CLI can apply this action non-interactively.
425 #[serde(default)]
426 pub auto_fixable: bool,
427}
428
429/// What to render in the human output when the license is in the grace window.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431#[serde(rename_all = "kebab-case")]
432pub enum Watermark {
433 /// The trial period has ended.
434 TrialExpired,
435 /// A paid license has expired but the sidecar is still inside the grace
436 /// window.
437 LicenseExpiredGrace,
438 /// Sentinel for forward-compatibility.
439 #[serde(other)]
440 Unknown,
441}
442
443/// Error / warning surfaced by the sidecar.
444#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct DiagnosticMessage {
446 /// Stable machine-readable diagnostic code (e.g. `"COV_DUMP_PARSE"`).
447 pub code: String,
448 /// Human-readable description of the diagnostic.
449 pub message: String,
450}
451
452// -- Stable ID helpers -----------------------------------------------------
453
454/// Compute the deterministic [`Finding::id`] for a production-coverage finding.
455///
456/// Emits `fallow:prod:<hash>` where `<hash>` is the first 8 hex characters of
457/// `SHA-256(file + function + line + "prod")`. The concatenation is plain,
458/// unseparated UTF-8. The canonical order MUST stay identical across protocol
459/// revisions; changing it breaks ID stability across runs and invalidates any
460/// consumer that persists IDs (CI deduplication, suppression, agent
461/// cross-references).
462#[must_use]
463pub fn finding_id(file: &str, function: &str, line: u32) -> String {
464 format!("fallow:prod:{}", content_hash(file, function, line, "prod"))
465}
466
467/// Compute the deterministic [`HotPath::id`] for a hot-path finding. Uses the
468/// same canonical order as [`finding_id`] with kind `"hot"`, emitting
469/// `fallow:hot:<hash>`.
470#[must_use]
471pub fn hot_path_id(file: &str, function: &str, line: u32) -> String {
472 format!("fallow:hot:{}", content_hash(file, function, line, "hot"))
473}
474
475/// Canonical content hash shared by the stable ID helpers. The input order
476/// (file, function, line, kind) and truncation (first 4 SHA-256 bytes → 8 hex
477/// chars) are part of the wire contract; see [`finding_id`] for the rationale.
478fn content_hash(file: &str, function: &str, line: u32, kind: &str) -> String {
479 let mut hasher = Sha256::new();
480 hasher.update(file.as_bytes());
481 hasher.update(function.as_bytes());
482 hasher.update(line.to_string().as_bytes());
483 hasher.update(kind.as_bytes());
484 let digest = hasher.finalize();
485 hex_prefix(&digest)
486}
487
488/// Encode the first four bytes of `digest` as lowercase hex — exactly eight
489/// characters. Kept separate so the truncation length is easy to audit. Total
490/// by construction: `HEX` is ASCII and `char::from(u8)` is infallible, so the
491/// helper never panics.
492fn hex_prefix(digest: &[u8]) -> String {
493 const HEX: &[u8; 16] = b"0123456789abcdef";
494 let mut out = String::with_capacity(8);
495 for &byte in digest.iter().take(4) {
496 out.push(char::from(HEX[usize::from(byte >> 4)]));
497 out.push(char::from(HEX[usize::from(byte & 0x0f)]));
498 }
499 out
500}
501
502// -- License features -------------------------------------------------------
503
504/// Feature flags present in the license JWT's `features` claim.
505///
506/// Wire format stays a string array (forward-compatible); new variants are
507/// additive in minor protocol bumps.
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(rename_all = "snake_case")]
510pub enum Feature {
511 /// Production coverage intelligence (the primary sidecar feature).
512 ProductionCoverage,
513 /// Portfolio dashboard for cross-project rollups. Deferred.
514 PortfolioDashboard,
515 /// MCP cloud tools integration. Deferred.
516 McpCloudTools,
517 /// Cross-repo aggregation and deduplication. Deferred.
518 CrossRepoAggregation,
519 /// Sentinel for forward-compatibility.
520 #[serde(other)]
521 Unknown,
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn version_constant_is_v0_3() {
530 assert!(PROTOCOL_VERSION.starts_with("0.3."));
531 }
532
533 #[test]
534 fn unknown_report_verdict_round_trips() {
535 let json = r#""something-new""#;
536 let verdict: ReportVerdict = serde_json::from_str(json).unwrap();
537 assert!(matches!(verdict, ReportVerdict::Unknown));
538 }
539
540 #[test]
541 fn unknown_verdict_round_trips() {
542 let json = r#""future_state""#;
543 let verdict: Verdict = serde_json::from_str(json).unwrap();
544 assert!(matches!(verdict, Verdict::Unknown));
545 }
546
547 #[test]
548 fn unknown_confidence_round_trips() {
549 let json = r#""ultra_high""#;
550 let confidence: Confidence = serde_json::from_str(json).unwrap();
551 assert!(matches!(confidence, Confidence::Unknown));
552 }
553
554 #[test]
555 fn unknown_feature_round_trips() {
556 let json = r#""future_feature""#;
557 let feature: Feature = serde_json::from_str(json).unwrap();
558 assert!(matches!(feature, Feature::Unknown));
559 }
560
561 #[test]
562 fn unknown_watermark_round_trips() {
563 let json = r#""something-else""#;
564 let watermark: Watermark = serde_json::from_str(json).unwrap();
565 assert!(matches!(watermark, Watermark::Unknown));
566 }
567
568 #[test]
569 fn coverage_source_kebab_case() {
570 let json = r#"{"kind":"v8-dir","path":"/tmp/dumps"}"#;
571 let src: CoverageSource = serde_json::from_str(json).unwrap();
572 assert!(matches!(src, CoverageSource::V8Dir { .. }));
573 }
574
575 #[test]
576 fn response_allows_unknown_fields() {
577 let json = r#"{
578 "protocol_version": "0.2.0",
579 "verdict": "clean",
580 "summary": {
581 "functions_tracked": 0,
582 "functions_hit": 0,
583 "functions_unhit": 0,
584 "functions_untracked": 0,
585 "coverage_percent": 0.0,
586 "trace_count": 0,
587 "period_days": 0,
588 "deployments_seen": 0
589 },
590 "findings": [],
591 "future_top_level_field": 42
592 }"#;
593 let response: Response = serde_json::from_str(json).unwrap();
594 assert_eq!(response.protocol_version, "0.2.0");
595 }
596
597 #[test]
598 fn finding_id_is_deterministic() {
599 let first = finding_id("src/a.ts", "foo", 42);
600 let second = finding_id("src/a.ts", "foo", 42);
601 assert_eq!(first, second);
602 assert!(first.starts_with("fallow:prod:"));
603 assert_eq!(first.len(), "fallow:prod:".len() + 8);
604 }
605
606 #[test]
607 fn capture_quality_round_trips() {
608 let q = CaptureQuality {
609 window_seconds: 720,
610 instances_observed: 1,
611 lazy_parse_warning: true,
612 untracked_ratio_percent: 42.5,
613 };
614 let json = serde_json::to_string(&q).unwrap();
615 let parsed: CaptureQuality = serde_json::from_str(&json).unwrap();
616 assert_eq!(q, parsed);
617 }
618
619 #[test]
620 fn summary_without_capture_quality_deserializes() {
621 // 0.2.x sidecars produced this shape; 0.3.x deserialization must
622 // still accept it so a mixed rollout (newer CLI, older sidecar)
623 // does not hard-fail.
624 let json = r#"{
625 "functions_tracked": 10,
626 "functions_hit": 5,
627 "functions_unhit": 5,
628 "functions_untracked": 0,
629 "coverage_percent": 50.0,
630 "trace_count": 100,
631 "period_days": 1,
632 "deployments_seen": 1
633 }"#;
634 let summary: Summary = serde_json::from_str(json).unwrap();
635 assert!(summary.capture_quality.is_none());
636 }
637
638 #[test]
639 fn summary_with_capture_quality_round_trips() {
640 let summary = Summary {
641 functions_tracked: 10,
642 functions_hit: 5,
643 functions_unhit: 5,
644 functions_untracked: 3,
645 coverage_percent: 50.0,
646 trace_count: 100,
647 period_days: 1,
648 deployments_seen: 1,
649 capture_quality: Some(CaptureQuality {
650 window_seconds: 720,
651 instances_observed: 1,
652 lazy_parse_warning: true,
653 untracked_ratio_percent: 30.0,
654 }),
655 };
656 let json = serde_json::to_string(&summary).unwrap();
657 let parsed: Summary = serde_json::from_str(&json).unwrap();
658 assert_eq!(summary.capture_quality, parsed.capture_quality);
659 }
660
661 #[test]
662 fn lazy_parse_threshold_is_30_percent() {
663 // Anchored so a bump forces a deliberate decision and a CHANGELOG
664 // entry rather than a silent tweak.
665 assert!((CaptureQuality::LAZY_PARSE_THRESHOLD_PERCENT - 30.0).abs() < f64::EPSILON);
666 }
667
668 #[test]
669 fn hot_path_id_differs_from_finding_id() {
670 let f = finding_id("src/a.ts", "foo", 42);
671 let h = hot_path_id("src/a.ts", "foo", 42);
672 assert_ne!(f[f.len() - 8..], h[h.len() - 8..]);
673 }
674
675 #[test]
676 fn finding_id_changes_with_line() {
677 assert_ne!(
678 finding_id("src/a.ts", "foo", 10),
679 finding_id("src/a.ts", "foo", 11),
680 );
681 }
682
683 #[test]
684 fn finding_id_changes_with_file() {
685 assert_ne!(
686 finding_id("src/a.ts", "foo", 42),
687 finding_id("src/b.ts", "foo", 42),
688 );
689 }
690
691 #[test]
692 fn finding_id_changes_with_function() {
693 assert_ne!(
694 finding_id("src/a.ts", "foo", 42),
695 finding_id("src/a.ts", "bar", 42),
696 );
697 }
698
699 #[test]
700 fn finding_id_is_lowercase_hex_ascii() {
701 // Canonical form is lowercase hex — downstream dedup keys on string
702 // equality, so an accidental uppercase switch would break persisted IDs.
703 let id = finding_id("src/a.ts", "foo", 42);
704 let hash = &id["fallow:prod:".len()..];
705 assert!(
706 hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
707 "expected lowercase hex, got {hash}"
708 );
709 }
710
711 #[test]
712 fn evidence_round_trips_with_untracked_reason() {
713 let evidence = Evidence {
714 static_status: "used".to_owned(),
715 test_coverage: "not_covered".to_owned(),
716 v8_tracking: "untracked".to_owned(),
717 untracked_reason: Some("lazy_parsed".to_owned()),
718 observation_days: 30,
719 deployments_observed: 14,
720 };
721 let json = serde_json::to_string(&evidence).unwrap();
722 assert!(json.contains("\"untracked_reason\":\"lazy_parsed\""));
723 let back: Evidence = serde_json::from_str(&json).unwrap();
724 assert_eq!(back.untracked_reason.as_deref(), Some("lazy_parsed"));
725 }
726
727 #[test]
728 fn static_function_requires_static_used_and_test_covered() {
729 // Belt-and-suspenders: a 0.1-shape request (no static_used / test_covered)
730 // must fail deserialization rather than silently defaulting to "used + covered"
731 // which would hide every safe_to_delete finding.
732 let json = r#"{"name":"foo","start_line":1,"end_line":2,"cyclomatic":1}"#;
733 let result: Result<StaticFunction, _> = serde_json::from_str(json);
734 let err = result
735 .expect_err("missing static_used / test_covered must fail")
736 .to_string();
737 assert!(
738 err.contains("static_used") || err.contains("test_covered"),
739 "unexpected error text: {err}"
740 );
741 }
742
743 #[test]
744 fn options_defaults_when_fields_omitted() {
745 let json = "{}";
746 let options: Options = serde_json::from_str(json).unwrap();
747 assert!(!options.include_hot_paths);
748 assert!(options.min_invocations_for_hot.is_none());
749 assert!(options.min_observation_volume.is_none());
750 assert!(options.low_traffic_threshold.is_none());
751 assert!(options.trace_count.is_none());
752 assert!(options.period_days.is_none());
753 assert!(options.deployments_seen.is_none());
754 }
755
756 #[test]
757 fn evidence_omits_untracked_reason_when_none() {
758 let evidence = Evidence {
759 static_status: "unused".to_owned(),
760 test_coverage: "covered".to_owned(),
761 v8_tracking: "tracked".to_owned(),
762 untracked_reason: None,
763 observation_days: 30,
764 deployments_observed: 14,
765 };
766 let json = serde_json::to_string(&evidence).unwrap();
767 assert!(
768 !json.contains("untracked_reason"),
769 "expected untracked_reason omitted, got {json}"
770 );
771 }
772}