Skip to main content

zccache_cli/
lib.rs

1#![allow(clippy::missing_errors_doc)]
2
3use std::path::Path;
4use zccache_core::NormalizedPath;
5
6#[cfg(feature = "python")]
7mod python;
8
9pub use zccache_download_client::{
10    ArchiveFormat, DownloadSource, FetchRequest, FetchResult, FetchState, FetchStateKind,
11    FetchStatus, WaitMode,
12};
13
14#[derive(Debug, Clone)]
15pub struct InoConvertOptions {
16    pub clang_args: Vec<String>,
17    pub inject_arduino_include: bool,
18}
19
20impl Default for InoConvertOptions {
21    fn default() -> Self {
22        Self {
23            clang_args: Vec::new(),
24            inject_arduino_include: true,
25        }
26    }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct InoConvertResult {
31    pub cache_hit: bool,
32    pub skipped_write: bool,
33}
34
35#[derive(Debug, Clone)]
36pub struct DownloadParams {
37    pub source: DownloadSource,
38    pub archive_path: Option<std::path::PathBuf>,
39    pub unarchive_path: Option<std::path::PathBuf>,
40    pub expected_sha256: Option<String>,
41    pub archive_format: ArchiveFormat,
42    pub max_connections: Option<usize>,
43    pub min_segment_size: Option<u64>,
44    pub wait_mode: WaitMode,
45    pub dry_run: bool,
46    pub force: bool,
47}
48
49impl DownloadParams {
50    #[must_use]
51    pub fn new(source: impl Into<DownloadSource>) -> Self {
52        Self {
53            source: source.into(),
54            archive_path: None,
55            unarchive_path: None,
56            expected_sha256: None,
57            archive_format: ArchiveFormat::Auto,
58            max_connections: None,
59            min_segment_size: None,
60            wait_mode: WaitMode::Block,
61            dry_run: false,
62            force: false,
63        }
64    }
65}
66
67pub fn run_ino_convert_cached(
68    input: &Path,
69    output: &Path,
70    options: &InoConvertOptions,
71) -> Result<InoConvertResult, Box<dyn std::error::Error>> {
72    let input_hash = zccache_hash::hash_file(input)?;
73    let mut hasher = zccache_hash::StreamHasher::new();
74    hasher.update(b"zccache-ino-convert-v1");
75    hasher.update(input_hash.as_bytes());
76    hasher.update(input.as_os_str().to_string_lossy().as_bytes());
77    hasher.update(if options.inject_arduino_include {
78        b"include-arduino-h"
79    } else {
80        b"no-arduino-h"
81    });
82    if let Some(libclang_hash) = zccache_compiler::arduino::libclang_hash() {
83        hasher.update(libclang_hash.as_bytes());
84    }
85    for arg in &options.clang_args {
86        hasher.update(arg.as_bytes());
87        hasher.update(b"\0");
88    }
89    let cache_key = hasher.finalize().to_hex();
90
91    let cache_dir = zccache_core::config::default_cache_dir().join("ino");
92    std::fs::create_dir_all(&cache_dir)?;
93    let cached_cpp = cache_dir.join(format!("{cache_key}.ino.cpp"));
94
95    if cached_cpp.exists() {
96        return restore_cached_ino_output(&cached_cpp, output);
97    }
98
99    let generated = zccache_compiler::arduino::generate_ino_cpp(
100        input,
101        &zccache_compiler::arduino::ArduinoConversionOptions {
102            clang_args: options.clang_args.clone(),
103            inject_arduino_include: options.inject_arduino_include,
104        },
105    )?;
106
107    write_file_atomically(&cached_cpp, generated.cpp.as_bytes())?;
108    restore_cached_ino_output(&cached_cpp, output).map(|_| InoConvertResult {
109        cache_hit: false,
110        skipped_write: false,
111    })
112}
113
114fn restore_cached_ino_output(
115    cached_cpp: &Path,
116    output: &Path,
117) -> Result<InoConvertResult, Box<dyn std::error::Error>> {
118    if output.exists() {
119        let output_hash = zccache_hash::hash_file(output)?;
120        let cached_hash = zccache_hash::hash_file(cached_cpp)?;
121        if output_hash == cached_hash {
122            return Ok(InoConvertResult {
123                cache_hit: true,
124                skipped_write: true,
125            });
126        }
127    }
128
129    if let Some(parent) = output.parent() {
130        std::fs::create_dir_all(parent)?;
131    }
132    std::fs::copy(cached_cpp, output)?;
133    Ok(InoConvertResult {
134        cache_hit: true,
135        skipped_write: false,
136    })
137}
138
139fn write_file_atomically(path: &Path, data: &[u8]) -> Result<(), std::io::Error> {
140    let parent = path.parent().unwrap_or_else(|| Path::new("."));
141    std::fs::create_dir_all(parent)?;
142
143    let tmp = tempfile::NamedTempFile::new_in(parent)?;
144    std::fs::write(tmp.path(), data)?;
145    match tmp.persist(path) {
146        Ok(_) => Ok(()),
147        Err(err) => Err(err.error),
148    }
149}
150
151fn resolve_endpoint(explicit: Option<&str>) -> String {
152    if let Some(ep) = explicit {
153        return ep.to_string();
154    }
155    if let Ok(ep) = std::env::var("ZCCACHE_ENDPOINT") {
156        return ep;
157    }
158    zccache_ipc::default_endpoint()
159}
160
161pub fn infer_download_archive_path(
162    source: &DownloadSource,
163    archive_format: ArchiveFormat,
164) -> std::path::PathBuf {
165    let file_name = infer_download_file_name(source, archive_format);
166    zccache_core::config::default_cache_dir()
167        .join("downloads")
168        .join("artifacts")
169        .join(file_name)
170        .into_path_buf()
171}
172
173#[must_use]
174pub fn build_download_request(params: DownloadParams) -> FetchRequest {
175    let archive_path = params
176        .archive_path
177        .unwrap_or_else(|| infer_download_archive_path(&params.source, params.archive_format));
178    let mut request = FetchRequest::new(params.source, archive_path);
179    request.destination_path_expanded = params.unarchive_path;
180    request.expected_sha256 = params.expected_sha256;
181    request.archive_format = params.archive_format;
182    request.wait_mode = params.wait_mode;
183    request.dry_run = params.dry_run;
184    request.force = params.force;
185    request.download_options.force = params.force;
186    request.download_options.max_connections = params.max_connections;
187    request.download_options.min_segment_size = params.min_segment_size;
188    request
189}
190
191pub fn client_download(
192    endpoint: Option<&str>,
193    params: DownloadParams,
194) -> Result<FetchResult, String> {
195    let request = build_download_request(params);
196    let client = zccache_download_client::DownloadClient::new(endpoint.map(ToOwned::to_owned));
197    client.fetch(request)
198}
199
200pub fn client_download_exists(
201    endpoint: Option<&str>,
202    params: DownloadParams,
203) -> Result<FetchState, String> {
204    let request = build_download_request(params);
205    let client = zccache_download_client::DownloadClient::new(endpoint.map(ToOwned::to_owned));
206    client.exists(&request)
207}
208
209fn infer_download_file_name(source: &DownloadSource, archive_format: ArchiveFormat) -> String {
210    let base = infer_source_file_name(source);
211    let hash = blake3::hash(download_source_key(source).as_bytes())
212        .to_hex()
213        .to_string();
214    let suffix = archive_suffix(archive_format);
215
216    if base.contains('.') || suffix.is_empty() {
217        format!("{hash}-{base}")
218    } else {
219        format!("{hash}-{base}{suffix}")
220    }
221}
222
223fn infer_source_file_name(source: &DownloadSource) -> String {
224    match source {
225        DownloadSource::Url(url) => {
226            infer_url_file_name(url).unwrap_or_else(|| "download".to_string())
227        }
228        DownloadSource::MultipartUrls(urls) => infer_multipart_file_name(urls),
229    }
230}
231
232fn infer_url_file_name(url: &str) -> Option<String> {
233    url.split(['?', '#'])
234        .next()
235        .and_then(|value| value.rsplit('/').next())
236        .filter(|value| !value.is_empty())
237        .map(sanitize_download_file_name)
238        .filter(|value| !value.is_empty())
239}
240
241fn infer_multipart_file_name(urls: &[String]) -> String {
242    let base = urls
243        .first()
244        .and_then(|url| infer_url_file_name(url))
245        .map(|name| strip_part_suffix(&name).to_string())
246        .filter(|name| !name.is_empty())
247        .unwrap_or_else(|| "multipart-download".to_string());
248    if base.contains('.') {
249        base
250    } else {
251        "multipart-download".to_string()
252    }
253}
254
255fn strip_part_suffix(value: &str) -> &str {
256    if let Some((base, suffix)) = value.rsplit_once(".part-") {
257        if !base.is_empty() && !suffix.is_empty() {
258            return base;
259        }
260    }
261    if let Some((base, suffix)) = value.rsplit_once(".part_") {
262        if !base.is_empty() && !suffix.is_empty() {
263            return base;
264        }
265    }
266    if let Some(index) = value.rfind(".part") {
267        let suffix = &value[index + ".part".len()..];
268        if !suffix.is_empty()
269            && suffix
270                .chars()
271                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
272        {
273            return &value[..index];
274        }
275    }
276    value
277}
278
279fn download_source_key(source: &DownloadSource) -> String {
280    match source {
281        DownloadSource::Url(url) => url.clone(),
282        DownloadSource::MultipartUrls(urls) => urls.join("\n"),
283    }
284}
285
286fn sanitize_download_file_name(value: &str) -> String {
287    value
288        .chars()
289        .map(|ch| match ch {
290            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
291            c if c.is_control() => '_',
292            c => c,
293        })
294        .collect()
295}
296
297fn archive_suffix(format: ArchiveFormat) -> &'static str {
298    match format {
299        ArchiveFormat::Auto | ArchiveFormat::None => "",
300        ArchiveFormat::Zst => ".zst",
301        ArchiveFormat::Zip => ".zip",
302        ArchiveFormat::Xz => ".xz",
303        ArchiveFormat::TarGz => ".tar.gz",
304        ArchiveFormat::TarXz => ".tar.xz",
305        ArchiveFormat::TarZst => ".tar.zst",
306        ArchiveFormat::SevenZip => ".7z",
307    }
308}
309
310fn run_async<T>(future: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
311    tokio::runtime::Builder::new_current_thread()
312        .enable_all()
313        .build()
314        .map_err(|e| format!("failed to create tokio runtime: {e}"))?
315        .block_on(future)
316}
317
318#[derive(Debug)]
319enum VersionCheck {
320    Ok,
321    Unreachable,
322    DaemonOlder { daemon_ver: String },
323    DaemonNewer,
324    CommError,
325}
326
327#[cfg(unix)]
328async fn connect_client(
329    endpoint: &str,
330) -> Result<zccache_ipc::IpcConnection, zccache_ipc::IpcError> {
331    zccache_ipc::connect(endpoint).await
332}
333
334#[cfg(windows)]
335async fn connect_client(
336    endpoint: &str,
337) -> Result<zccache_ipc::IpcClientConnection, zccache_ipc::IpcError> {
338    zccache_ipc::connect(endpoint).await
339}
340
341async fn check_daemon_version(endpoint: &str) -> VersionCheck {
342    let mut conn = match connect_client(endpoint).await {
343        Ok(c) => c,
344        Err(_) => return VersionCheck::Unreachable,
345    };
346    if conn.send(&zccache_protocol::Request::Status).await.is_err() {
347        return VersionCheck::CommError;
348    }
349    match conn.recv::<zccache_protocol::Response>().await {
350        Ok(Some(zccache_protocol::Response::Status(s))) => {
351            if s.version == zccache_core::VERSION {
352                return VersionCheck::Ok;
353            }
354            let client_ver = zccache_core::version::current();
355            match zccache_core::version::Version::parse(&s.version) {
356                Some(daemon_ver) => match daemon_ver.cmp(&client_ver) {
357                    std::cmp::Ordering::Equal => VersionCheck::Ok,
358                    std::cmp::Ordering::Greater => VersionCheck::DaemonNewer,
359                    std::cmp::Ordering::Less => VersionCheck::DaemonOlder {
360                        daemon_ver: s.version,
361                    },
362                },
363                None => VersionCheck::DaemonOlder {
364                    daemon_ver: s.version,
365                },
366            }
367        }
368        _ => VersionCheck::CommError,
369    }
370}
371
372async fn spawn_and_wait(endpoint: &str) -> Result<(), String> {
373    let daemon_bin = find_daemon_binary().ok_or("cannot find zccache-daemon binary")?;
374    spawn_daemon(&daemon_bin, endpoint)?;
375
376    for _ in 0..100 {
377        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
378        if connect_client(endpoint).await.is_ok() {
379            return Ok(());
380        }
381    }
382    Err("daemon started but not accepting connections after 10s".to_string())
383}
384
385/// Stop a stale daemon that is unreachable or version-incompatible.
386async fn stop_stale_daemon(endpoint: &str) {
387    if let Ok(mut conn) = connect_client(endpoint).await {
388        let _ = conn.send(&zccache_protocol::Request::Shutdown).await;
389        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
390    }
391
392    if let Some(pid) = zccache_ipc::check_running_daemon() {
393        if zccache_ipc::force_kill_process(pid).is_ok() {
394            for _ in 0..50 {
395                if !zccache_ipc::is_process_alive(pid) {
396                    break;
397                }
398                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
399            }
400        }
401        zccache_ipc::remove_lock_file();
402    }
403
404    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
405}
406
407async fn ensure_daemon(endpoint: &str) -> Result<(), String> {
408    match check_daemon_version(endpoint).await {
409        VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
410        VersionCheck::DaemonOlder { daemon_ver } => {
411            tracing::info!(
412                daemon_ver,
413                client_ver = zccache_core::VERSION,
414                "daemon is older than client, auto-recovering"
415            );
416            stop_stale_daemon(endpoint).await;
417            return spawn_and_wait(endpoint).await;
418        }
419        VersionCheck::CommError => {
420            tracing::info!("cannot communicate with daemon, auto-recovering");
421            stop_stale_daemon(endpoint).await;
422            return spawn_and_wait(endpoint).await;
423        }
424        VersionCheck::Unreachable => {}
425    }
426
427    if let Some(pid) = zccache_ipc::check_running_daemon() {
428        let mut backoff = std::time::Duration::from_millis(100);
429        for _ in 0..20 {
430            tokio::time::sleep(backoff).await;
431            backoff = (backoff * 2).min(std::time::Duration::from_millis(500));
432            match check_daemon_version(endpoint).await {
433                VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
434                VersionCheck::DaemonOlder { daemon_ver } => {
435                    tracing::info!(
436                        daemon_ver,
437                        client_ver = zccache_core::VERSION,
438                        "daemon is older than client during startup, auto-recovering"
439                    );
440                    stop_stale_daemon(endpoint).await;
441                    return spawn_and_wait(endpoint).await;
442                }
443                VersionCheck::CommError => {
444                    stop_stale_daemon(endpoint).await;
445                    return spawn_and_wait(endpoint).await;
446                }
447                VersionCheck::Unreachable => continue,
448            }
449        }
450        return Err(format!(
451            "daemon process {pid} exists but not accepting connections after retrying"
452        ));
453    }
454
455    spawn_and_wait(endpoint).await
456}
457
458fn find_daemon_binary() -> Option<NormalizedPath> {
459    let name = if cfg!(windows) {
460        "zccache-daemon.exe"
461    } else {
462        "zccache-daemon"
463    };
464
465    if let Ok(exe) = std::env::current_exe() {
466        if let Some(dir) = exe.parent() {
467            let candidate = dir.join(name);
468            if candidate.exists() {
469                return Some(candidate.into());
470            }
471        }
472    }
473
474    which_on_path(name)
475}
476
477fn which_on_path(name: &str) -> Option<NormalizedPath> {
478    let path_var = std::env::var_os("PATH")?;
479    for dir in std::env::split_paths(&path_var) {
480        let candidate = dir.join(name);
481        if candidate.is_file() {
482            return Some(candidate.into());
483        }
484        #[cfg(windows)]
485        if Path::new(name).extension().is_none() {
486            let with_exe = dir.join(format!("{name}.exe"));
487            if with_exe.is_file() {
488                return Some(with_exe.into());
489            }
490        }
491    }
492    None
493}
494
495fn spawn_daemon(bin: &Path, endpoint: &str) -> Result<(), String> {
496    let mut cmd = std::process::Command::new(bin);
497    cmd.args(["--foreground", "--endpoint", endpoint]);
498    cmd.stdin(std::process::Stdio::null());
499    cmd.stdout(std::process::Stdio::null());
500    cmd.stderr(std::process::Stdio::null());
501
502    #[cfg(windows)]
503    {
504        use std::os::windows::process::CommandExt;
505        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
506        cmd.creation_flags(CREATE_NO_WINDOW);
507        disable_handle_inheritance();
508    }
509
510    cmd.spawn()
511        .map_err(|e| format!("failed to spawn daemon: {e}"))?;
512
513    #[cfg(windows)]
514    restore_handle_inheritance();
515
516    Ok(())
517}
518
519#[cfg(windows)]
520fn disable_handle_inheritance() {
521    use std::os::windows::io::AsRawHandle;
522
523    extern "system" {
524        fn SetHandleInformation(handle: *mut std::ffi::c_void, mask: u32, flags: u32) -> i32;
525    }
526    const HANDLE_FLAG_INHERIT: u32 = 1;
527
528    unsafe {
529        let stdout = std::io::stdout().as_raw_handle();
530        let stderr = std::io::stderr().as_raw_handle();
531        let _ = SetHandleInformation(stdout.cast(), HANDLE_FLAG_INHERIT, 0);
532        let _ = SetHandleInformation(stderr.cast(), HANDLE_FLAG_INHERIT, 0);
533    }
534}
535
536#[cfg(windows)]
537fn restore_handle_inheritance() {
538    use std::os::windows::io::AsRawHandle;
539
540    extern "system" {
541        fn SetHandleInformation(handle: *mut std::ffi::c_void, mask: u32, flags: u32) -> i32;
542    }
543    const HANDLE_FLAG_INHERIT: u32 = 1;
544
545    unsafe {
546        let stdout = std::io::stdout().as_raw_handle();
547        let stderr = std::io::stderr().as_raw_handle();
548        let _ = SetHandleInformation(stdout.cast(), HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
549        let _ = SetHandleInformation(stderr.cast(), HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
550    }
551}
552
553#[derive(Debug, Clone)]
554pub struct SessionStartResponse {
555    pub session_id: String,
556    pub journal_path: Option<String>,
557}
558
559pub fn client_start(endpoint: Option<&str>) -> Result<(), String> {
560    let endpoint = resolve_endpoint(endpoint);
561    run_async(async move { ensure_daemon(&endpoint).await })
562}
563
564pub fn client_stop(endpoint: Option<&str>) -> Result<bool, String> {
565    let endpoint = resolve_endpoint(endpoint);
566    run_async(async move {
567        let mut conn = match connect_client(&endpoint).await {
568            Ok(c) => c,
569            Err(_) => return Ok(false),
570        };
571        conn.send(&zccache_protocol::Request::Shutdown)
572            .await
573            .map_err(|e| format!("failed to send to daemon: {e}"))?;
574        match conn.recv::<zccache_protocol::Response>().await {
575            Ok(Some(zccache_protocol::Response::ShuttingDown)) => Ok(true),
576            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
577            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
578            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
579            Err(e) => Err(format!("broken connection to daemon: {e}")),
580        }
581    })
582}
583
584pub fn client_status(endpoint: Option<&str>) -> Result<zccache_protocol::DaemonStatus, String> {
585    let endpoint = resolve_endpoint(endpoint);
586    run_async(async move {
587        let mut conn = connect_client(&endpoint)
588            .await
589            .map_err(|e| format!("daemon not running at {endpoint}: {e}"))?;
590        conn.send(&zccache_protocol::Request::Status)
591            .await
592            .map_err(|e| format!("failed to send to daemon: {e}"))?;
593        match conn.recv::<zccache_protocol::Response>().await {
594            Ok(Some(zccache_protocol::Response::Status(status))) => Ok(status),
595            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
596            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
597            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
598            Err(e) => Err(format!("broken connection to daemon: {e}")),
599        }
600    })
601}
602
603pub fn client_session_start(
604    endpoint: Option<&str>,
605    cwd: &Path,
606    log_file: Option<&Path>,
607    track_stats: bool,
608    journal_path: Option<&Path>,
609) -> Result<SessionStartResponse, String> {
610    let endpoint = resolve_endpoint(endpoint);
611    let cwd = cwd.to_path_buf();
612    let log_file = log_file.map(NormalizedPath::from);
613    let journal_path = journal_path.map(NormalizedPath::from);
614
615    run_async(async move {
616        ensure_daemon(&endpoint).await?;
617        let mut conn = connect_client(&endpoint)
618            .await
619            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
620        conn.send(&zccache_protocol::Request::SessionStart {
621            client_pid: std::process::id(),
622            working_dir: cwd.into(),
623            log_file,
624            track_stats,
625            journal_path,
626        })
627        .await
628        .map_err(|e| format!("failed to send to daemon: {e}"))?;
629
630        match conn.recv::<zccache_protocol::Response>().await {
631            Ok(Some(zccache_protocol::Response::SessionStarted {
632                session_id,
633                journal_path,
634            })) => Ok(SessionStartResponse {
635                session_id,
636                journal_path: journal_path.map(|p| p.display().to_string()),
637            }),
638            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
639            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
640            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
641            Err(e) => Err(format!("broken connection to daemon: {e}")),
642        }
643    })
644}
645
646pub fn client_session_end(
647    endpoint: Option<&str>,
648    session_id: &str,
649) -> Result<Option<zccache_protocol::SessionStats>, String> {
650    let endpoint = resolve_endpoint(endpoint);
651    let session_id = session_id.to_string();
652    run_async(async move {
653        let mut conn = connect_client(&endpoint)
654            .await
655            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
656        conn.send(&zccache_protocol::Request::SessionEnd {
657            session_id: session_id.clone(),
658        })
659        .await
660        .map_err(|e| format!("failed to send to daemon: {e}"))?;
661
662        match conn.recv::<zccache_protocol::Response>().await {
663            Ok(Some(zccache_protocol::Response::SessionEnded { stats })) => Ok(stats),
664            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
665            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
666            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
667            Err(e) => Err(format!("broken connection to daemon: {e}")),
668        }
669    })
670}
671
672pub fn client_session_stats(
673    endpoint: Option<&str>,
674    session_id: &str,
675) -> Result<Option<zccache_protocol::SessionStats>, String> {
676    let endpoint = resolve_endpoint(endpoint);
677    let session_id = session_id.to_string();
678    run_async(async move {
679        let mut conn = connect_client(&endpoint)
680            .await
681            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
682        conn.send(&zccache_protocol::Request::SessionStats {
683            session_id: session_id.clone(),
684        })
685        .await
686        .map_err(|e| format!("failed to send to daemon: {e}"))?;
687
688        match conn.recv::<zccache_protocol::Response>().await {
689            Ok(Some(zccache_protocol::Response::SessionStatsResult { stats })) => Ok(stats),
690            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
691            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
692            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
693            Err(e) => Err(format!("broken connection to daemon: {e}")),
694        }
695    })
696}
697
698#[derive(Debug, Clone)]
699pub struct FingerprintCheckResponse {
700    pub decision: String,
701    pub reason: Option<String>,
702    pub changed_files: Vec<String>,
703}
704
705pub fn fingerprint_check(
706    endpoint: Option<&str>,
707    cache_file: &Path,
708    cache_type: &str,
709    root: &Path,
710    extensions: &[String],
711    include_globs: &[String],
712    exclude: &[String],
713) -> Result<FingerprintCheckResponse, String> {
714    let endpoint = resolve_endpoint(endpoint);
715    let cache_file = cache_file.to_path_buf();
716    let cache_type = cache_type.to_string();
717    let root = root.to_path_buf();
718    let extensions = extensions.to_vec();
719    let include_globs = include_globs.to_vec();
720    let exclude = exclude.to_vec();
721
722    run_async(async move {
723        ensure_daemon(&endpoint).await?;
724        let mut conn = connect_client(&endpoint)
725            .await
726            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
727
728        conn.send(&zccache_protocol::Request::FingerprintCheck {
729            cache_file: cache_file.into(),
730            cache_type,
731            root: root.into(),
732            extensions,
733            include_globs,
734            exclude,
735        })
736        .await
737        .map_err(|e| format!("failed to send to daemon: {e}"))?;
738
739        match conn.recv::<zccache_protocol::Response>().await {
740            Ok(Some(zccache_protocol::Response::FingerprintCheckResult {
741                decision,
742                reason,
743                changed_files,
744            })) => Ok(FingerprintCheckResponse {
745                decision,
746                reason,
747                changed_files,
748            }),
749            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
750            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
751            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
752            Err(e) => Err(format!("broken connection to daemon: {e}")),
753        }
754    })
755}
756
757pub fn fingerprint_mark_success(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
758    fingerprint_mark(endpoint, cache_file, true)
759}
760
761pub fn fingerprint_mark_failure(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
762    fingerprint_mark(endpoint, cache_file, false)
763}
764
765fn fingerprint_mark(
766    endpoint: Option<&str>,
767    cache_file: &Path,
768    success: bool,
769) -> Result<(), String> {
770    let endpoint = resolve_endpoint(endpoint);
771    let cache_file = cache_file.to_path_buf();
772    run_async(async move {
773        ensure_daemon(&endpoint).await?;
774        let mut conn = connect_client(&endpoint)
775            .await
776            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
777        let request = if success {
778            zccache_protocol::Request::FingerprintMarkSuccess {
779                cache_file: cache_file.into(),
780            }
781        } else {
782            zccache_protocol::Request::FingerprintMarkFailure {
783                cache_file: cache_file.into(),
784            }
785        };
786        conn.send(&request)
787            .await
788            .map_err(|e| format!("failed to send to daemon: {e}"))?;
789        match conn.recv::<zccache_protocol::Response>().await {
790            Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
791            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
792            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
793            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
794            Err(e) => Err(format!("broken connection to daemon: {e}")),
795        }
796    })
797}
798
799pub fn fingerprint_invalidate(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
800    let endpoint = resolve_endpoint(endpoint);
801    let cache_file = cache_file.to_path_buf();
802    run_async(async move {
803        ensure_daemon(&endpoint).await?;
804        let mut conn = connect_client(&endpoint)
805            .await
806            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
807        conn.send(&zccache_protocol::Request::FingerprintInvalidate {
808            cache_file: cache_file.into(),
809        })
810        .await
811        .map_err(|e| format!("failed to send to daemon: {e}"))?;
812        match conn.recv::<zccache_protocol::Response>().await {
813            Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
814            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
815            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
816            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
817            Err(e) => Err(format!("broken connection to daemon: {e}")),
818        }
819    })
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825    use std::ffi::OsString;
826    use std::sync::{Mutex, MutexGuard};
827
828    static ENV_LOCK: Mutex<()> = Mutex::new(());
829
830    struct EnvGuard {
831        _lock: MutexGuard<'static, ()>,
832        previous: Option<OsString>,
833    }
834
835    impl EnvGuard {
836        fn set_cache_dir(value: &std::path::Path) -> Self {
837            let lock = ENV_LOCK.lock().unwrap();
838            let previous = std::env::var_os(zccache_core::config::CACHE_DIR_ENV);
839            std::env::set_var(zccache_core::config::CACHE_DIR_ENV, value);
840            Self {
841                _lock: lock,
842                previous,
843            }
844        }
845    }
846
847    impl Drop for EnvGuard {
848        fn drop(&mut self) {
849            match &self.previous {
850                Some(value) => std::env::set_var(zccache_core::config::CACHE_DIR_ENV, value),
851                None => std::env::remove_var(zccache_core::config::CACHE_DIR_ENV),
852            }
853        }
854    }
855
856    fn fake_status() -> zccache_protocol::DaemonStatus {
857        zccache_protocol::DaemonStatus {
858            version: zccache_core::VERSION.to_string(),
859            artifact_count: 0,
860            cache_size_bytes: 0,
861            metadata_entries: 0,
862            uptime_secs: 0,
863            cache_hits: 0,
864            cache_misses: 0,
865            total_compilations: 0,
866            non_cacheable: 0,
867            compile_errors: 0,
868            time_saved_ms: 0,
869            total_links: 0,
870            link_hits: 0,
871            link_misses: 0,
872            link_non_cacheable: 0,
873            dep_graph_contexts: 0,
874            dep_graph_files: 0,
875            sessions_total: 0,
876            sessions_active: 0,
877            cache_dir: zccache_core::config::default_cache_dir(),
878            dep_graph_version: 0,
879            dep_graph_disk_size: 0,
880        }
881    }
882
883    #[test]
884    fn infer_download_path_keeps_url_filename() {
885        let path = infer_download_archive_path(
886            &DownloadSource::Url("https://example.com/releases/toolchain.tar.gz?download=1".into()),
887            ArchiveFormat::Auto,
888        );
889        let file_name = path.file_name().unwrap().to_string_lossy();
890        assert!(file_name.ends_with("-toolchain.tar.gz"));
891    }
892
893    #[test]
894    fn infer_download_path_uses_archive_format_suffix_when_needed() {
895        let path = infer_download_archive_path(
896            &DownloadSource::Url("https://example.com/download".into()),
897            ArchiveFormat::Zip,
898        );
899        let file_name = path.file_name().unwrap().to_string_lossy();
900        assert!(file_name.ends_with(".zip"));
901    }
902
903    #[test]
904    fn build_download_request_derives_archive_path_when_missing() {
905        let request = build_download_request(DownloadParams::new("https://example.com/file.zip"));
906        let file_name = request
907            .destination_path
908            .file_name()
909            .unwrap()
910            .to_string_lossy();
911        assert!(file_name.ends_with("-file.zip"));
912    }
913
914    #[test]
915    fn infer_download_path_strips_multipart_suffix_from_first_part() {
916        let path = infer_download_archive_path(
917            &DownloadSource::MultipartUrls(vec![
918                "https://example.com/toolchain.tar.zst.part-aa".into(),
919                "https://example.com/toolchain.tar.zst.part-ab".into(),
920            ]),
921            ArchiveFormat::Auto,
922        );
923        let file_name = path.file_name().unwrap().to_string_lossy();
924        assert!(file_name.ends_with("-toolchain.tar.zst"));
925    }
926
927    #[tokio::test(flavor = "current_thread")]
928    async fn client_start_waits_for_delayed_listener() {
929        let endpoint = zccache_ipc::unique_test_endpoint();
930        let cache_root = tempfile::tempdir().unwrap();
931        let _env = EnvGuard::set_cache_dir(cache_root.path());
932        let lock_path = zccache_ipc::lock_file_path();
933
934        std::fs::write(&lock_path, std::process::id().to_string()).unwrap();
935
936        let ep = endpoint.clone();
937        let cache_dir = cache_root.path().to_path_buf();
938        let server = tokio::spawn(async move {
939            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
940
941            let mut listener = zccache_ipc::IpcListener::bind(&ep).unwrap();
942            let mut conn = listener.accept().await.unwrap();
943
944            let req: Option<zccache_protocol::Request> = conn.recv().await.unwrap();
945            assert_eq!(req, Some(zccache_protocol::Request::Status));
946
947            conn.send(&zccache_protocol::Response::Status(
948                zccache_protocol::DaemonStatus {
949                    cache_dir: cache_dir.into(),
950                    ..fake_status()
951                },
952            ))
953            .await
954            .unwrap();
955        });
956
957        let result = tokio::task::spawn_blocking(move || client_start(Some(&endpoint)))
958            .await
959            .unwrap();
960        let _ = std::fs::remove_file(&lock_path);
961        server.await.unwrap();
962
963        assert!(
964            result.is_ok(),
965            "expected delayed daemon to be accepted: {result:?}"
966        );
967    }
968}