Skip to main content

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}