Skip to main content

zccache_protocol/
messages.rs

1//! Protocol message definitions.
2
3use serde::{Deserialize, Serialize};
4use std::sync::Arc;
5use zccache_core::NormalizedPath;
6
7/// A request from client to daemon.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub enum Request {
10    /// Health check.
11    Ping,
12    /// Request daemon shutdown.
13    Shutdown,
14    /// Request daemon status/statistics.
15    Status,
16    /// Look up a cached artifact by cache key.
17    Lookup {
18        /// Hex-encoded cache key.
19        cache_key: String,
20    },
21    /// Store a compilation artifact.
22    Store {
23        /// Hex-encoded cache key.
24        cache_key: String,
25        /// The artifact data to store.
26        artifact: ArtifactData,
27    },
28    /// Start a new session with the daemon.
29    SessionStart {
30        /// Client process ID.
31        client_pid: u32,
32        /// Client working directory.
33        working_dir: NormalizedPath,
34        /// Optional path to a log file for this session.
35        log_file: Option<NormalizedPath>,
36        /// Whether to track per-session statistics.
37        track_stats: bool,
38        /// Path for per-session JSONL compile journal (must end in .jsonl).
39        journal_path: Option<NormalizedPath>,
40        /// Issue #256: opt in to the extended journal schema. When true,
41        /// the daemon populates crate_name, crate_type, output_ext, and
42        /// self_profile_ns on every compile journal line for the duration
43        /// of this session. When false, behavior is identical to releases
44        /// before the flag existed (no new allocations, no new fields).
45        profile: bool,
46    },
47    /// Compile a source file within an existing session.
48    Compile {
49        /// Session ID from a prior SessionStart (UUID string).
50        session_id: String,
51        /// Compiler arguments (e.g., ["-c", "hello.cpp", "-o", "hello.o"]).
52        args: Vec<String>,
53        /// Working directory for the compilation.
54        cwd: NormalizedPath,
55        /// Path to the compiler executable (required).
56        compiler: NormalizedPath,
57        /// Client environment variables to pass to the compiler process.
58        /// If `None`, the daemon's own environment is inherited (backward compat).
59        /// If `Some`, the compiler process uses exactly these env vars.
60        env: Option<Vec<(String, String)>>,
61        /// Bytes the wrapper read from its own stdin, ferried to the compiler
62        /// child's stdin over IPC. Empty = no stdin (`Stdio::null` on the
63        /// daemon side). cargo's RUSTC_WRAPPER path normally yields zero
64        /// bytes here; the field exists so that `rustc -` and similar
65        /// stdin-consuming invocations work transparently.
66        stdin: Vec<u8>,
67    },
68    /// End a session.
69    SessionEnd {
70        /// Session ID to end (UUID string).
71        session_id: String,
72    },
73    /// Clear all caches (artifacts, metadata, dep graph).
74    Clear,
75    /// Single-roundtrip ephemeral compile: session start + compile + session end
76    /// in one message. Used by the CLI in drop-in wrapper mode to avoid 3 IPC
77    /// roundtrips per invocation.
78    CompileEphemeral {
79        /// Client process ID.
80        client_pid: u32,
81        /// Client working directory.
82        working_dir: NormalizedPath,
83        /// Path to the compiler executable.
84        compiler: NormalizedPath,
85        /// Compiler arguments (e.g., ["-c", "hello.cpp", "-o", "hello.o"]).
86        args: Vec<String>,
87        /// Working directory for the compilation.
88        cwd: NormalizedPath,
89        /// Client environment variables to pass to the compiler process.
90        env: Option<Vec<(String, String)>>,
91        /// Bytes the wrapper read from its own stdin, ferried to the compiler
92        /// child's stdin over IPC. Empty = `Stdio::null` on the daemon side.
93        /// See `Request::Compile` for context.
94        stdin: Vec<u8>,
95    },
96    /// Single-roundtrip ephemeral link/archive: used for `zccache ar ...` or
97    /// `zccache ld ...` in drop-in wrapper mode.
98    LinkEphemeral {
99        /// Client process ID.
100        client_pid: u32,
101        /// Path to the linker/archiver tool (ar, ld, lib.exe, link.exe, etc.).
102        tool: NormalizedPath,
103        /// Tool arguments (e.g., ["rcs", "libfoo.a", "a.o", "b.o"]).
104        args: Vec<String>,
105        /// Working directory for the link operation.
106        cwd: NormalizedPath,
107        /// Client environment variables.
108        env: Option<Vec<(String, String)>>,
109    },
110    /// Query per-session statistics without ending the session.
111    /// NOTE: Appended at end to preserve bincode variant indices.
112    SessionStats {
113        /// Session ID to query (UUID string).
114        session_id: String,
115    },
116    /// Check if files have changed since last successful fingerprint.
117    /// NOTE: Appended at end to preserve bincode variant indices.
118    FingerprintCheck {
119        /// Path to the cache file (e.g., .cache/lint.json).
120        cache_file: NormalizedPath,
121        /// Cache algorithm: "hash" or "two-layer".
122        cache_type: String,
123        /// Root directory to scan.
124        root: NormalizedPath,
125        /// File extensions to include (without dot, e.g., "rs", "cpp").
126        /// Empty = all files. Conflicts with `include_globs`.
127        extensions: Vec<String>,
128        /// Glob patterns for files to include (e.g., "**/*.rs").
129        /// Empty = use extensions filter.
130        include_globs: Vec<String>,
131        /// Patterns or directory names to exclude.
132        exclude: Vec<String>,
133    },
134    /// Mark the previous fingerprint check as successful.
135    FingerprintMarkSuccess {
136        /// Path to the cache file.
137        cache_file: NormalizedPath,
138    },
139    /// Mark the previous fingerprint check as failed.
140    FingerprintMarkFailure {
141        /// Path to the cache file.
142        cache_file: NormalizedPath,
143    },
144    /// Invalidate a fingerprint cache (delete all state).
145    FingerprintInvalidate {
146        /// Path to the cache file.
147        cache_file: NormalizedPath,
148    },
149    /// List all cached Rust artifacts with their output paths.
150    /// NOTE: Appended at end to preserve bincode variant indices.
151    ListRustArtifacts,
152}
153
154/// A response from daemon to client.
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub enum Response {
157    /// Response to Ping.
158    Pong,
159    /// Shutdown acknowledged.
160    ShuttingDown,
161    /// Daemon status information.
162    Status(DaemonStatus),
163    /// Cache lookup result.
164    LookupResult(LookupResult),
165    /// Store result.
166    StoreResult(StoreResult),
167    /// Session successfully started.
168    SessionStarted {
169        /// Assigned session ID (UUID string).
170        session_id: String,
171        /// Path to the per-session JSONL journal file (if journal was requested).
172        journal_path: Option<NormalizedPath>,
173    },
174    /// Result of a compilation request.
175    CompileResult {
176        /// Compiler exit code.
177        exit_code: i32,
178        /// Captured stdout (Arc-wrapped to avoid copies on cache hits).
179        stdout: Arc<Vec<u8>>,
180        /// Captured stderr (Arc-wrapped to avoid copies on cache hits).
181        stderr: Arc<Vec<u8>>,
182        /// Whether this was served from cache.
183        cached: bool,
184    },
185    /// Session ended successfully.
186    SessionEnded {
187        /// Per-session stats, if the session opted in to tracking.
188        stats: Option<SessionStats>,
189    },
190    /// Result of a link/archive request.
191    LinkResult {
192        /// Tool exit code.
193        exit_code: i32,
194        /// Captured stdout (Arc-wrapped to avoid copies on cache hits).
195        stdout: Arc<Vec<u8>>,
196        /// Captured stderr (Arc-wrapped to avoid copies on cache hits).
197        stderr: Arc<Vec<u8>>,
198        /// Whether this was served from cache.
199        cached: bool,
200        /// Non-determinism warning (if tool invocation uses non-deterministic flags).
201        warning: Option<String>,
202    },
203    /// An error occurred processing the request.
204    Error {
205        /// Human-readable error message.
206        message: String,
207    },
208    /// Cache cleared successfully.
209    Cleared {
210        /// Number of in-memory artifacts removed.
211        artifacts_removed: u64,
212        /// Number of metadata cache entries cleared.
213        metadata_cleared: u64,
214        /// Number of dep graph contexts cleared.
215        dep_graph_contexts_cleared: u64,
216        /// Bytes freed from on-disk artifact cache.
217        on_disk_bytes_freed: u64,
218    },
219    /// Mid-session statistics snapshot.
220    /// NOTE: Appended at end to preserve bincode variant indices.
221    SessionStatsResult {
222        /// Per-session stats, if the session exists and opted in to tracking.
223        stats: Option<SessionStats>,
224    },
225    /// Result of a fingerprint check.
226    /// NOTE: Appended at end to preserve bincode variant indices.
227    FingerprintCheckResult {
228        /// "skip" or "run".
229        decision: String,
230        /// Reason for run (e.g., "no cache file", "content changed").
231        reason: Option<String>,
232        /// Files that changed (if available).
233        changed_files: Vec<String>,
234    },
235    /// Fingerprint mark/invalidate acknowledged.
236    FingerprintAck,
237    /// List of cached Rust artifacts.
238    /// NOTE: Appended at end to preserve bincode variant indices.
239    RustArtifactList { artifacts: Vec<RustArtifactInfo> },
240}
241
242/// Daemon status information.
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244pub struct DaemonStatus {
245    /// Daemon version (e.g. "1.0.8"). Used by CLI to detect stale daemons.
246    pub version: String,
247    /// Number of artifacts in cache.
248    pub artifact_count: u64,
249    /// Total size of cached artifacts in bytes.
250    pub cache_size_bytes: u64,
251    /// Number of entries in the metadata cache.
252    pub metadata_entries: u64,
253    /// Daemon uptime in seconds.
254    pub uptime_secs: u64,
255    /// Total cache hits since startup.
256    pub cache_hits: u64,
257    /// Total cache misses since startup.
258    pub cache_misses: u64,
259    /// Total compile requests received.
260    pub total_compilations: u64,
261    /// Non-cacheable invocations (linking, preprocessing, etc.).
262    pub non_cacheable: u64,
263    /// Compilations that exited with non-zero status.
264    pub compile_errors: u64,
265    /// Estimated wall-clock time saved in milliseconds.
266    pub time_saved_ms: u64,
267    /// Total link/archive requests received.
268    pub total_links: u64,
269    /// Link cache hits.
270    pub link_hits: u64,
271    /// Link cache misses.
272    pub link_misses: u64,
273    /// Non-cacheable link invocations.
274    pub link_non_cacheable: u64,
275    /// Number of compilation contexts in the dependency graph.
276    pub dep_graph_contexts: u64,
277    /// Number of tracked files in the dependency graph.
278    pub dep_graph_files: u64,
279    /// Total sessions created since daemon start.
280    pub sessions_total: u64,
281    /// Currently active sessions.
282    pub sessions_active: u64,
283    /// Path to the cache directory.
284    pub cache_dir: NormalizedPath,
285    /// On-disk depgraph snapshot format version.
286    pub dep_graph_version: u32,
287    /// Size of the depgraph snapshot file on disk (0 = not persisted).
288    pub dep_graph_disk_size: u64,
289    /// Whether the in-memory dep graph is backed by a persisted snapshot.
290    ///
291    /// `true` if the graph was loaded from disk on startup OR has been
292    /// successfully written to disk since startup (periodic save or shutdown).
293    /// `false` on a fresh daemon that has not yet flushed its first snapshot.
294    pub dep_graph_persisted: bool,
295}
296
297/// Result of a cache lookup.
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299pub enum LookupResult {
300    /// Cache hit.
301    Hit {
302        /// The cached artifact data.
303        artifact: ArtifactData,
304    },
305    /// Cache miss.
306    Miss,
307}
308
309/// Result of storing an artifact.
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311pub enum StoreResult {
312    /// Successfully stored.
313    Stored,
314    /// Already existed in cache.
315    AlreadyExists,
316}
317
318/// Artifact data exchanged over the protocol.
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320pub struct ArtifactData {
321    /// The output files (filename to contents).
322    pub outputs: Vec<ArtifactOutput>,
323    /// Captured stdout from the compiler.
324    pub stdout: Arc<Vec<u8>>,
325    /// Captured stderr from the compiler.
326    pub stderr: Arc<Vec<u8>>,
327    /// Compiler exit code.
328    pub exit_code: i32,
329}
330
331/// Per-session statistics, returned when the session opted in to tracking.
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333pub struct SessionStats {
334    /// Wall-clock duration of the session in milliseconds.
335    pub duration_ms: u64,
336    /// Total compile requests in this session.
337    pub compilations: u64,
338    /// Cache hits in this session.
339    pub hits: u64,
340    /// Cache misses (cold compiles) in this session.
341    pub misses: u64,
342    /// Non-cacheable invocations (linking, preprocessing, etc.).
343    pub non_cacheable: u64,
344    /// Compilations that exited with non-zero status.
345    pub errors: u64,
346    /// Estimated wall-clock time saved in milliseconds.
347    pub time_saved_ms: u64,
348    /// Distinct source files compiled.
349    pub unique_sources: u64,
350    /// Total artifact bytes served from cache.
351    pub bytes_read: u64,
352    /// Total artifact bytes stored into cache.
353    pub bytes_written: u64,
354    /// Daemon-wide phase-timing aggregate. `None` from older daemons that
355    /// don't populate the field; `Some` from PROTOCOL_VERSION >= 9 daemons.
356    ///
357    /// Aggregate is daemon-wide totals since the last
358    /// `PhaseProfiler::reset()` (which is called on `Request::Clear`). For
359    /// fresh-daemon perf scenarios this is equivalent to "this session's
360    /// phase totals". For long-lived daemons handling overlapping sessions,
361    /// totals cross-contaminate — that's acceptable for v1 and revisited if
362    /// a real consumer needs per-session isolation.
363    pub phase_profile: Option<PhaseProfileSummary>,
364}
365
366/// Aggregate phase-timing totals from the daemon's PhaseProfiler.
367///
368/// Totals are in nanoseconds. Divide hit-path totals by `hit_count` and
369/// miss-path totals by `miss_count` to derive per-compile averages.
370///
371/// Use case: a perf harness collects this from a warm-rebuild session to
372/// identify which phase dominates the warm-side wall time (e.g.
373/// `write_output_ns` for artifact materialization vs `depgraph_check_ns`
374/// for depgraph lookups).
375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
376pub struct PhaseProfileSummary {
377    /// Number of cache-hit compiles that contributed to the hit-path totals.
378    pub hit_count: u64,
379    /// Number of cache-miss compiles that contributed to the miss-path totals.
380    pub miss_count: u64,
381    // ── Hit-path totals (ns) ──
382    /// Argument parsing.
383    pub parse_args_ns: u64,
384    /// Build compile context + register with depgraph.
385    pub build_context_ns: u64,
386    /// Source-file hash via the metadata cache fast path.
387    pub hash_source_ns: u64,
388    /// Header hashes via the metadata cache fast path.
389    pub hash_headers_ns: u64,
390    /// Depgraph verdict lookup.
391    pub depgraph_check_ns: u64,
392    /// Request-level cache lookup.
393    pub request_cache_lookup_ns: u64,
394    /// Cross-root request validation.
395    pub cross_root_validate_ns: u64,
396    /// In-memory artifact-store lookup.
397    pub artifact_lookup_ns: u64,
398    /// Write cached outputs to disk (hardlink-first, copy fallback).
399    pub write_output_ns: u64,
400    /// Stats recording + session bookkeeping.
401    pub bookkeeping_ns: u64,
402    /// Wall-clock total of the hit path.
403    pub total_hit_ns: u64,
404    // ── Miss-path totals (ns) ──
405    /// Run the actual compiler subprocess.
406    pub compiler_exec_ns: u64,
407    /// Scan included files post-compile.
408    pub include_scan_ns: u64,
409    /// Hash all inputs for the artifact key.
410    pub hash_all_ns: u64,
411    /// Persist the new artifact to disk.
412    pub artifact_store_ns: u64,
413    /// Wall-clock total of the miss path.
414    pub total_miss_ns: u64,
415}
416
417/// Where an artifact output's bytes live on the daemon's filesystem at the
418/// moment a request is built.
419///
420/// `Bytes` is the only variant any current client emits — `Path` is reserved
421/// for future sccache-emulation paths where the client already has the bytes
422/// on disk and the daemon can hardlink directly via `persist_artifact_file`
423/// (falling back to copy on cross-volume failure).
424///
425/// The variant was introduced pre-emptively in PR for issue #296 so that
426/// landing the eventual `Request::Store` handler won't require a second
427/// `PROTOCOL_VERSION` bump. See `crates/zccache-daemon/src/server.rs` —
428/// `CachedPayload` is the internal sibling of this type and predates it.
429#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
430pub enum ArtifactPayload {
431    /// Bytes shipped inline in the IPC message. Used by every current call
432    /// site; future remote-daemon scenarios may also need this.
433    Bytes(Arc<Vec<u8>>),
434    /// Path on the daemon's filesystem. The daemon hardlinks from this path
435    /// into the cache (falling back to copy on cross-volume failure). Path
436    /// must be absolute and readable by the daemon process (same user).
437    /// No current client emits this variant.
438    Path(NormalizedPath),
439}
440
441impl ArtifactPayload {
442    /// Size in bytes of the underlying output. For `Path`, stats the file;
443    /// returns 0 on I/O error (matches the prior `unwrap_or_default()`
444    /// semantics elsewhere in the daemon for missing-output cases).
445    #[must_use]
446    pub fn size_bytes(&self) -> u64 {
447        match self {
448            Self::Bytes(b) => b.len() as u64,
449            Self::Path(p) => std::fs::metadata(p.as_path()).map(|m| m.len()).unwrap_or(0),
450        }
451    }
452
453    /// Returns `Some` of the inline bytes when this is the `Bytes` variant.
454    /// Useful for daemon-internal sites that still want the byte path —
455    /// `None` signals "the bytes live on disk; route through a hardlink/read
456    /// helper instead."
457    #[must_use]
458    pub fn as_bytes(&self) -> Option<&Arc<Vec<u8>>> {
459        match self {
460            Self::Bytes(b) => Some(b),
461            Self::Path(_) => None,
462        }
463    }
464}
465
466/// A single output file from compilation.
467#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
468pub struct ArtifactOutput {
469    /// Relative filename (e.g., "foo.o").
470    pub name: String,
471    /// Where the bytes live — inline in the message or on disk for hardlink.
472    /// See `ArtifactPayload` for the variant rationale.
473    pub payload: ArtifactPayload,
474}
475
476/// Information about a cached Rust compilation artifact.
477#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
478pub struct RustArtifactInfo {
479    /// Cache key hex.
480    pub cache_key: String,
481    /// Output file names (e.g., ["libfoo-abc123.rlib", "libfoo-abc123.rmeta", "foo-abc123.d"]).
482    pub output_names: Vec<String>,
483    /// Number of payload files.
484    pub payload_count: usize,
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    /// Helper: roundtrip a value through bincode.
492    fn roundtrip<T: Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug>(
493        val: &T,
494    ) {
495        let bytes = bincode::serialize(val).unwrap();
496        let decoded: T = bincode::deserialize(&bytes).unwrap();
497        assert_eq!(*val, decoded);
498    }
499
500    #[test]
501    fn session_stats_roundtrip() {
502        let stats = SessionStats {
503            duration_ms: 12345,
504            compilations: 100,
505            hits: 80,
506            misses: 15,
507            non_cacheable: 5,
508            errors: 2,
509            time_saved_ms: 8000,
510            unique_sources: 42,
511            bytes_read: 1024 * 1024,
512            bytes_written: 512 * 1024,
513            phase_profile: None,
514        };
515        roundtrip(&stats);
516    }
517
518    #[test]
519    fn session_stats_default_zeros() {
520        let stats = SessionStats {
521            duration_ms: 0,
522            compilations: 0,
523            hits: 0,
524            misses: 0,
525            non_cacheable: 0,
526            errors: 0,
527            time_saved_ms: 0,
528            unique_sources: 0,
529            bytes_read: 0,
530            bytes_written: 0,
531            phase_profile: None,
532        };
533        roundtrip(&stats);
534    }
535
536    #[test]
537    fn session_stats_with_phase_profile_roundtrip() {
538        // Regression guard for PROTOCOL_VERSION 9 — a populated phase_profile
539        // must round-trip both bincode (IPC wire) and serde-json (the form
540        // soldr writes to last-session-stats.json).
541        let stats = SessionStats {
542            duration_ms: 12345,
543            compilations: 146,
544            hits: 103,
545            misses: 12,
546            non_cacheable: 31,
547            errors: 3,
548            time_saved_ms: 223,
549            unique_sources: 115,
550            bytes_read: 143_812_577,
551            bytes_written: 62_500_000,
552            phase_profile: Some(PhaseProfileSummary {
553                hit_count: 103,
554                miss_count: 12,
555                parse_args_ns: 4_000_000,
556                build_context_ns: 19_000_000,
557                hash_source_ns: 6_000_000,
558                hash_headers_ns: 11_000_000,
559                depgraph_check_ns: 28_000_000,
560                request_cache_lookup_ns: 2_500_000,
561                cross_root_validate_ns: 1_200_000,
562                artifact_lookup_ns: 8_700_000,
563                write_output_ns: 540_000_000,
564                bookkeeping_ns: 3_300_000,
565                total_hit_ns: 623_700_000,
566                compiler_exec_ns: 11_400_000_000,
567                include_scan_ns: 270_000_000,
568                hash_all_ns: 95_000_000,
569                artifact_store_ns: 120_000_000,
570                total_miss_ns: 11_885_000_000,
571            }),
572        };
573        roundtrip(&stats);
574
575        // serde-json round-trip — written to last-session-stats.json and
576        // read by both `zccache analyze` and the perf harness's
577        // `perf_local.py render_summary`.
578        let json = serde_json::to_string(&stats).expect("serialize");
579        let decoded: SessionStats = serde_json::from_str(&json).expect("deserialize");
580        assert_eq!(stats, decoded);
581
582        // An old-daemon-style JSON that omits phase_profile must decode to
583        // None (back-compat with PROTOCOL_VERSION 8 consumers that haven't
584        // upgraded the field expectation).
585        let legacy = r#"{
586            "duration_ms": 0, "compilations": 0, "hits": 0, "misses": 0,
587            "non_cacheable": 0, "errors": 0, "time_saved_ms": 0,
588            "unique_sources": 0, "bytes_read": 0, "bytes_written": 0
589        }"#;
590        let decoded: SessionStats = serde_json::from_str(legacy).expect("legacy decode");
591        assert!(decoded.phase_profile.is_none());
592    }
593
594    #[test]
595    fn daemon_status_expanded_roundtrip() {
596        let status = DaemonStatus {
597            version: env!("CARGO_PKG_VERSION").to_string(),
598            artifact_count: 892,
599            cache_size_bytes: 147_000_000,
600            metadata_entries: 5430,
601            uptime_secs: 8040,
602            cache_hits: 1089,
603            cache_misses: 143,
604            total_compilations: 1247,
605            non_cacheable: 15,
606            compile_errors: 3,
607            time_saved_ms: 750_000,
608            total_links: 50,
609            link_hits: 38,
610            link_misses: 10,
611            link_non_cacheable: 2,
612            dep_graph_contexts: 892,
613            dep_graph_files: 4201,
614            sessions_total: 41,
615            sessions_active: 3,
616            cache_dir: "/home/user/.zccache".into(),
617            dep_graph_version: 1,
618            dep_graph_disk_size: 2_500_000,
619            dep_graph_persisted: true,
620        };
621        roundtrip(&status);
622    }
623
624    #[test]
625    fn session_start_with_track_stats_roundtrip() {
626        let req = Request::SessionStart {
627            client_pid: 1234,
628            working_dir: "/home/user/project".into(),
629            log_file: None,
630            track_stats: true,
631            journal_path: None,
632            profile: false,
633        };
634        roundtrip(&req);
635
636        let req_no_stats = Request::SessionStart {
637            client_pid: 1234,
638            working_dir: "/home/user/project".into(),
639            log_file: None,
640            track_stats: false,
641            journal_path: None,
642            profile: false,
643        };
644        roundtrip(&req_no_stats);
645    }
646
647    #[test]
648    fn session_start_with_journal_path_roundtrip() {
649        let req = Request::SessionStart {
650            client_pid: 5678,
651            working_dir: "/home/user/project".into(),
652            log_file: None,
653            track_stats: false,
654            journal_path: Some("/tmp/build.jsonl".into()),
655            profile: false,
656        };
657        roundtrip(&req);
658
659        let req_no_journal = Request::SessionStart {
660            client_pid: 5678,
661            working_dir: "/home/user/project".into(),
662            log_file: None,
663            track_stats: false,
664            journal_path: None,
665            profile: false,
666        };
667        roundtrip(&req_no_journal);
668    }
669
670    #[test]
671    fn session_started_with_journal_path_roundtrip() {
672        let resp = Response::SessionStarted {
673            session_id: "550e8400-e29b-41d4-a716-446655440000".into(),
674            journal_path: Some("/home/user/.zccache/logs/sessions/test.jsonl".into()),
675        };
676        roundtrip(&resp);
677
678        let resp_no_journal = Response::SessionStarted {
679            session_id: "550e8400-e29b-41d4-a716-446655440000".into(),
680            journal_path: None,
681        };
682        roundtrip(&resp_no_journal);
683    }
684
685    #[test]
686    fn session_ended_with_stats_roundtrip() {
687        let stats = SessionStats {
688            duration_ms: 34000,
689            compilations: 32,
690            hits: 28,
691            misses: 3,
692            non_cacheable: 1,
693            errors: 0,
694            time_saved_ms: 8200,
695            unique_sources: 30,
696            bytes_read: 2_000_000,
697            bytes_written: 500_000,
698            phase_profile: None,
699        };
700        let resp = Response::SessionEnded { stats: Some(stats) };
701        roundtrip(&resp);
702
703        let resp_no_stats = Response::SessionEnded { stats: None };
704        roundtrip(&resp_no_stats);
705    }
706
707    #[test]
708    fn clear_request_roundtrip() {
709        roundtrip(&Request::Clear);
710    }
711
712    #[test]
713    fn cleared_response_roundtrip() {
714        roundtrip(&Response::Cleared {
715            artifacts_removed: 42,
716            metadata_cleared: 100,
717            dep_graph_contexts_cleared: 25,
718            on_disk_bytes_freed: 1024 * 1024,
719        });
720    }
721
722    #[test]
723    fn compile_ephemeral_roundtrip() {
724        roundtrip(&Request::CompileEphemeral {
725            client_pid: 9876,
726            working_dir: "/home/user/project".into(),
727            compiler: "/usr/bin/clang++".into(),
728            args: vec!["-c".into(), "main.cpp".into(), "-o".into(), "main.o".into()],
729            cwd: "/home/user/project/build".into(),
730            env: Some(vec![("PATH".into(), "/usr/bin".into())]),
731            stdin: Vec::new(),
732        });
733        // Non-empty stdin payload must round-trip byte-for-byte — including
734        // embedded NULs and binary bytes — so `rustc -` style invocations
735        // through the wrapper see the same input the parent sent us.
736        roundtrip(&Request::CompileEphemeral {
737            client_pid: 1,
738            working_dir: ".".into(),
739            compiler: "gcc".into(),
740            args: vec![],
741            cwd: ".".into(),
742            env: None,
743            stdin: b"hello\x00world\nbinary\xff\xfe".to_vec(),
744        });
745    }
746
747    #[test]
748    fn link_ephemeral_roundtrip() {
749        roundtrip(&Request::LinkEphemeral {
750            client_pid: 5555,
751            tool: "/usr/bin/ar".into(),
752            args: vec!["rcs".into(), "libfoo.a".into(), "a.o".into(), "b.o".into()],
753            cwd: "/home/user/project/build".into(),
754            env: Some(vec![("PATH".into(), "/usr/bin".into())]),
755        });
756        roundtrip(&Request::LinkEphemeral {
757            client_pid: 1,
758            tool: "lib.exe".into(),
759            args: vec!["/OUT:foo.lib".into(), "a.obj".into()],
760            cwd: ".".into(),
761            env: None,
762        });
763    }
764
765    #[test]
766    fn link_result_roundtrip() {
767        roundtrip(&Response::LinkResult {
768            exit_code: 0,
769            stdout: Arc::new(vec![]),
770            stderr: Arc::new(vec![]),
771            cached: true,
772            warning: None,
773        });
774        roundtrip(&Response::LinkResult {
775            exit_code: 0,
776            stdout: Arc::new(vec![]),
777            stderr: Arc::new(b"some warning".to_vec()),
778            cached: false,
779            warning: Some("non-deterministic: missing D flag".into()),
780        });
781    }
782
783    #[test]
784    fn session_stats_request_roundtrip() {
785        roundtrip(&Request::SessionStats {
786            session_id: "550e8400-e29b-41d4-a716-446655440000".into(),
787        });
788    }
789
790    #[test]
791    fn session_stats_result_roundtrip() {
792        let stats = SessionStats {
793            duration_ms: 5000,
794            compilations: 10,
795            hits: 7,
796            misses: 2,
797            non_cacheable: 1,
798            errors: 0,
799            time_saved_ms: 3000,
800            unique_sources: 9,
801            bytes_read: 50_000,
802            bytes_written: 20_000,
803            phase_profile: None,
804        };
805        roundtrip(&Response::SessionStatsResult { stats: Some(stats) });
806        roundtrip(&Response::SessionStatsResult { stats: None });
807    }
808
809    #[test]
810    fn existing_request_variants_still_work() {
811        roundtrip(&Request::Ping);
812        roundtrip(&Request::Shutdown);
813        roundtrip(&Request::Status);
814        roundtrip(&Request::SessionEnd {
815            session_id: "550e8400-e29b-41d4-a716-446655440000".into(),
816        });
817        roundtrip(&Request::Compile {
818            session_id: "550e8400-e29b-41d4-a716-446655440000".into(),
819            args: vec!["-c".into(), "foo.c".into()],
820            cwd: "/tmp".into(),
821            compiler: "/usr/bin/gcc".into(),
822            env: None,
823            stdin: Vec::new(),
824        });
825    }
826
827    #[test]
828    fn existing_response_variants_still_work() {
829        roundtrip(&Response::Pong);
830        roundtrip(&Response::ShuttingDown);
831        roundtrip(&Response::CompileResult {
832            exit_code: 0,
833            stdout: Arc::new(vec![]),
834            stderr: Arc::new(vec![]),
835            cached: true,
836        });
837        roundtrip(&Response::Error {
838            message: "test".into(),
839        });
840    }
841
842    #[test]
843    fn daemon_status_version_field_roundtrips() {
844        let with_version = DaemonStatus {
845            version: "1.2.3".to_string(),
846            artifact_count: 0,
847            cache_size_bytes: 0,
848            metadata_entries: 0,
849            uptime_secs: 0,
850            cache_hits: 0,
851            cache_misses: 0,
852            total_compilations: 0,
853            non_cacheable: 0,
854            compile_errors: 0,
855            time_saved_ms: 0,
856            total_links: 0,
857            link_hits: 0,
858            link_misses: 0,
859            link_non_cacheable: 0,
860            dep_graph_contexts: 0,
861            dep_graph_files: 0,
862            sessions_total: 0,
863            sessions_active: 0,
864            cache_dir: "".into(),
865            dep_graph_version: 0,
866            dep_graph_disk_size: 0,
867            dep_graph_persisted: false,
868        };
869        roundtrip(&with_version);
870    }
871
872    // Compile-time check: PROTOCOL_VERSION must be positive.
873    const _: () = assert!(crate::PROTOCOL_VERSION > 0);
874    // Compile-time check: PROTOCOL_VERSION == 9 after SessionStats gained
875    // `phase_profile: Option<PhaseProfileSummary>` (perf observability for
876    // per-session phase aggregates). v8 was the prior pin after
877    // Compile/CompileEphemeral gained `stdin: Vec<u8>` and ArtifactPayload
878    // replaced ArtifactOutput.data: Arc<Vec<u8>> (issue #296 Option B).
879    const _FINGERPRINT_VERSION: () = assert!(crate::PROTOCOL_VERSION == 9);
880
881    #[test]
882    fn fingerprint_check_roundtrip() {
883        roundtrip(&Request::FingerprintCheck {
884            cache_file: "/tmp/lint.json".into(),
885            cache_type: "two-layer".into(),
886            root: "/home/user/project/src".into(),
887            extensions: vec!["rs".into(), "toml".into()],
888            include_globs: vec![],
889            exclude: vec![".git".into(), "target".into()],
890        });
891        roundtrip(&Request::FingerprintCheck {
892            cache_file: "cache.json".into(),
893            cache_type: "hash".into(),
894            root: ".".into(),
895            extensions: vec![],
896            include_globs: vec!["**/*.cpp".into(), "**/*.h".into()],
897            exclude: vec![],
898        });
899    }
900
901    #[test]
902    fn fingerprint_mark_success_roundtrip() {
903        roundtrip(&Request::FingerprintMarkSuccess {
904            cache_file: "/tmp/lint.json".into(),
905        });
906    }
907
908    #[test]
909    fn fingerprint_mark_failure_roundtrip() {
910        roundtrip(&Request::FingerprintMarkFailure {
911            cache_file: "/tmp/lint.json".into(),
912        });
913    }
914
915    #[test]
916    fn fingerprint_invalidate_roundtrip() {
917        roundtrip(&Request::FingerprintInvalidate {
918            cache_file: "/tmp/lint.json".into(),
919        });
920    }
921
922    #[test]
923    fn fingerprint_check_result_roundtrip() {
924        roundtrip(&Response::FingerprintCheckResult {
925            decision: "skip".into(),
926            reason: None,
927            changed_files: vec![],
928        });
929        roundtrip(&Response::FingerprintCheckResult {
930            decision: "run".into(),
931            reason: Some("content changed".into()),
932            changed_files: vec!["src/main.rs".into(), "src/lib.rs".into()],
933        });
934        roundtrip(&Response::FingerprintCheckResult {
935            decision: "run".into(),
936            reason: Some("no cache file".into()),
937            changed_files: vec![],
938        });
939    }
940
941    #[test]
942    fn fingerprint_ack_roundtrip() {
943        roundtrip(&Response::FingerprintAck);
944    }
945
946    #[test]
947    fn list_rust_artifacts_request_roundtrip() {
948        roundtrip(&Request::ListRustArtifacts);
949    }
950
951    #[test]
952    fn rust_artifact_list_response_roundtrip() {
953        roundtrip(&Response::RustArtifactList {
954            artifacts: vec![
955                RustArtifactInfo {
956                    cache_key: "abc123def456".into(),
957                    output_names: vec![
958                        "libfoo-abc123.rlib".into(),
959                        "libfoo-abc123.rmeta".into(),
960                        "foo-abc123.d".into(),
961                    ],
962                    payload_count: 3,
963                },
964                RustArtifactInfo {
965                    cache_key: "deadbeef".into(),
966                    output_names: vec!["libbar-deadbeef.rlib".into()],
967                    payload_count: 1,
968                },
969            ],
970        });
971        // Empty list
972        roundtrip(&Response::RustArtifactList { artifacts: vec![] });
973    }
974
975    #[test]
976    fn rust_artifact_info_roundtrip() {
977        roundtrip(&RustArtifactInfo {
978            cache_key: "0123456789abcdef".into(),
979            output_names: vec!["test.o".into()],
980            payload_count: 1,
981        });
982    }
983
984    #[test]
985    fn artifact_clone_shares_payload_via_arc() {
986        let bytes = Arc::new(vec![1u8, 2, 3, 4]);
987        let artifact = ArtifactData {
988            outputs: vec![ArtifactOutput {
989                name: "test.o".into(),
990                payload: ArtifactPayload::Bytes(Arc::clone(&bytes)),
991            }],
992            stdout: Arc::new(vec![5, 6]),
993            stderr: Arc::new(vec![7, 8]),
994            exit_code: 0,
995        };
996
997        let cloned = artifact.clone();
998
999        // Arc::clone bumps refcount — both point to the same allocation.
1000        let orig_inner = artifact.outputs[0].payload.as_bytes().unwrap();
1001        let cloned_inner = cloned.outputs[0].payload.as_bytes().unwrap();
1002        assert!(Arc::ptr_eq(orig_inner, cloned_inner));
1003        assert!(Arc::ptr_eq(orig_inner, &bytes));
1004        assert!(Arc::ptr_eq(&artifact.stdout, &cloned.stdout));
1005        assert!(Arc::ptr_eq(&artifact.stderr, &cloned.stderr));
1006    }
1007
1008    #[test]
1009    fn artifact_payload_size_bytes_for_bytes_variant() {
1010        let p = ArtifactPayload::Bytes(Arc::new(vec![0u8; 1234]));
1011        assert_eq!(p.size_bytes(), 1234);
1012    }
1013
1014    #[test]
1015    fn artifact_payload_size_bytes_for_path_variant() {
1016        let tmp = tempfile::NamedTempFile::new().expect("tempfile");
1017        std::fs::write(tmp.path(), vec![0u8; 4321]).expect("write");
1018        let p = ArtifactPayload::Path(NormalizedPath::from(tmp.path()));
1019        assert_eq!(p.size_bytes(), 4321);
1020    }
1021
1022    #[test]
1023    fn artifact_payload_size_bytes_for_missing_path_is_zero() {
1024        let p = ArtifactPayload::Path(NormalizedPath::from(std::path::Path::new(
1025            "/this/path/does/not/exist/zccache",
1026        )));
1027        assert_eq!(p.size_bytes(), 0);
1028    }
1029
1030    #[test]
1031    fn artifact_payload_round_trips_through_bincode() {
1032        let bytes_variant = ArtifactPayload::Bytes(Arc::new(b"hello".to_vec()));
1033        let encoded = bincode::serialize(&bytes_variant).expect("serialize bytes");
1034        let decoded: ArtifactPayload = bincode::deserialize(&encoded).expect("deserialize bytes");
1035        assert_eq!(decoded, bytes_variant);
1036
1037        let path_variant = ArtifactPayload::Path(NormalizedPath::from(std::path::Path::new(
1038            "/tmp/some/place.rlib",
1039        )));
1040        let encoded = bincode::serialize(&path_variant).expect("serialize path");
1041        let decoded: ArtifactPayload = bincode::deserialize(&encoded).expect("deserialize path");
1042        assert_eq!(decoded, path_variant);
1043    }
1044
1045    #[test]
1046    fn arc_vec_u8_roundtrip_matches_plain_vec() {
1047        // Prove Arc<Vec<u8>> serializes identically to Vec<u8>.
1048        let plain: Vec<u8> = vec![0xDE, 0xAD, 0xBE, 0xEF];
1049        let arc_wrapped: Arc<Vec<u8>> = Arc::new(plain.clone());
1050
1051        let plain_bytes = bincode::serialize(&plain).unwrap();
1052        let arc_bytes = bincode::serialize(&arc_wrapped).unwrap();
1053        assert_eq!(
1054            plain_bytes, arc_bytes,
1055            "Arc<Vec<u8>> must serialize identically to Vec<u8>"
1056        );
1057
1058        // Deserialize Arc bytes back as plain Vec and vice versa.
1059        let decoded_plain: Vec<u8> = bincode::deserialize(&arc_bytes).unwrap();
1060        let decoded_arc: Arc<Vec<u8>> = bincode::deserialize(&plain_bytes).unwrap();
1061        assert_eq!(decoded_plain, plain);
1062        assert_eq!(*decoded_arc, plain);
1063    }
1064}