Skip to main content

zccache_compiler/
lib.rs

1//! Compiler detection and argument parsing for zccache.
2//!
3//! Handles identifying compilers, parsing their command-line arguments
4//! to determine cacheability, and extracting cache-relevant information.
5
6#![allow(clippy::missing_errors_doc)]
7
8pub mod arduino;
9pub mod parse_archiver;
10pub mod parse_linker;
11pub mod parse_rustfmt;
12pub mod response_file;
13
14use std::sync::Arc;
15use zccache_core::NormalizedPath;
16
17/// Supported compiler families.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CompilerFamily {
20    /// GCC (gcc, g++)
21    Gcc,
22    /// Clang (clang, clang++)
23    Clang,
24    /// MSVC (cl.exe)
25    Msvc,
26    /// Rust compiler (rustc)
27    Rustc,
28    /// Rust formatter (rustfmt) — not a compiler, but cacheable as a tool.
29    Rustfmt,
30}
31
32impl CompilerFamily {
33    /// Whether this compiler supports `-MD -MF` for depfile generation.
34    /// MSVC uses `/showIncludes` instead. Rustc uses `--emit=dep-info`.
35    #[must_use]
36    pub fn supports_depfile(&self) -> bool {
37        matches!(self, CompilerFamily::Gcc | CompilerFamily::Clang)
38    }
39
40    /// Default PCH output extension (without dot) for this compiler family.
41    /// Returns `None` for MSVC (uses /Yc + /Fp mechanism instead), Rustc, and Rustfmt.
42    #[must_use]
43    pub fn pch_extension(&self) -> Option<&'static str> {
44        match self {
45            CompilerFamily::Gcc => Some("gch"),
46            CompilerFamily::Clang => Some("pch"),
47            CompilerFamily::Msvc | CompilerFamily::Rustc | CompilerFamily::Rustfmt => None,
48        }
49    }
50
51    /// Whether this is a formatter tool (not a compiler).
52    #[must_use]
53    pub fn is_formatter(&self) -> bool {
54        matches!(self, CompilerFamily::Rustfmt)
55    }
56}
57
58/// The result of parsing a compiler invocation.
59#[derive(Debug, Clone)]
60pub enum ParsedInvocation {
61    /// A cacheable compilation (single source to single object).
62    Cacheable(CacheableCompilation),
63    /// Multiple source files with `-c` — each is independently cacheable.
64    MultiFile {
65        /// One entry per source file, each with its own output path.
66        compilations: Vec<CacheableCompilation>,
67        /// The original full argument list (for batched compiler invocation of misses).
68        original_args: Arc<[String]>,
69        /// Indices of source files in `original_args`, so the daemon can filter
70        /// out cache-hit sources without reconstructing args.
71        source_indices: Vec<usize>,
72    },
73    /// A non-cacheable invocation (linking, preprocessing, etc.).
74    NonCacheable {
75        /// Reason why this invocation is not cacheable.
76        reason: String,
77    },
78}
79
80/// A cacheable compilation invocation.
81#[derive(Debug, Clone)]
82pub struct CacheableCompilation {
83    /// The compiler executable path.
84    pub compiler: NormalizedPath,
85    /// The detected compiler family.
86    pub family: CompilerFamily,
87    /// The source file being compiled.
88    pub source_file: NormalizedPath,
89    /// The output file path.
90    pub output_file: NormalizedPath,
91    /// The full original argument list — always passed to the compiler as-is.
92    pub original_args: Arc<[String]>,
93    /// Flags not recognized by the parser but still part of the invocation.
94    /// Preserved for completeness and consistency with the linker/archiver/
95    /// depgraph parsers which all track unknown flags.
96    pub unknown_flags: Vec<String>,
97}
98
99/// The language mode for a source file, as determined by `-x <lang>` or file extension.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub(crate) enum SourceMode {
102    /// Normal C/C++ source (`.c`, `.cpp`, etc.) — compiles to `.o`.
103    Normal,
104    /// PCH header (`-x c-header` / `-x c++-header`) — compiles to `.pch`/`.gch`.
105    Header,
106    /// Header unit (`-x c-header-unit` / `-x c++-header-unit`) — compiles to `.pcm`.
107    HeaderUnit,
108    /// Module interface (`-x c++-module` or `.cppm`/`.ixx`) — `.pcm` with `--precompile`, `.o` with `-c`.
109    Module,
110}
111
112impl SourceMode {
113    /// Whether this mode implies compilation without an explicit `-c` or `--precompile` flag.
114    /// Header and header-unit modes imply compilation (like PCH generation).
115    /// Module mode does NOT — it requires `-c` or `--precompile`.
116    pub(crate) fn implies_compilation(self) -> bool {
117        matches!(self, SourceMode::Header | SourceMode::HeaderUnit)
118    }
119}
120
121/// Map a `-x <lang>` value to the corresponding source mode.
122/// Returns `None` for unrecognized language values (no special mode).
123fn source_mode_from_language(lang: &str) -> Option<SourceMode> {
124    match lang {
125        "c-header" | "c++-header" => Some(SourceMode::Header),
126        "c-header-unit" | "c++-header-unit" => Some(SourceMode::HeaderUnit),
127        "c++-module" => Some(SourceMode::Module),
128        _ => None,
129    }
130}
131
132/// Source file extensions we recognize as C/C++.
133const SOURCE_EXTENSIONS: &[&str] = &[
134    "c", "cc", "cpp", "cxx", "c++", "C", "m", "mm", "i", "ii", "cppm", "ixx",
135];
136
137/// File extensions that imply module-interface mode even without `-x c++-module`.
138const MODULE_EXTENSIONS: &[&str] = &["cppm", "ixx"];
139
140/// Determine the source mode implied by a file extension.
141fn source_mode_from_extension(path: &str) -> SourceMode {
142    if let Some(ext) = std::path::Path::new(path)
143        .extension()
144        .and_then(|e| e.to_str())
145    {
146        if MODULE_EXTENSIONS.contains(&ext) {
147            return SourceMode::Module;
148        }
149    }
150    SourceMode::Normal
151}
152
153/// Detect the compiler family from the compiler path.
154#[must_use]
155pub fn detect_family(compiler: &str) -> CompilerFamily {
156    // Split on both `/` and `\` so Windows-style paths work on all platforms.
157    let basename = compiler.rsplit(['/', '\\']).next().unwrap_or(compiler);
158    let name = match basename.rsplit_once('.') {
159        Some((stem, _)) => stem,
160        None => basename,
161    };
162    if name == "rustfmt" || name.starts_with("rustfmt-") {
163        CompilerFamily::Rustfmt
164    } else if name == "rustc"
165        || name.starts_with("rustc-")
166        || name == "clippy-driver"
167        || name.starts_with("clippy-driver-")
168    {
169        CompilerFamily::Rustc
170    } else if name.contains("clang") || name == "emcc" || name == "em++" {
171        CompilerFamily::Clang
172    } else if name.eq_ignore_ascii_case("cl") {
173        CompilerFamily::Msvc
174    } else {
175        CompilerFamily::Gcc
176    }
177}
178
179/// Check if a path looks like a C/C++ source file.
180fn is_source_file(path: &str) -> bool {
181    if let Some(ext) = std::path::Path::new(path)
182        .extension()
183        .and_then(|e| e.to_str())
184    {
185        SOURCE_EXTENSIONS.contains(&ext)
186    } else {
187        false
188    }
189}
190
191/// Compute the default output path when `-o` is absent.
192///
193/// For both normal compilation and PCH generation, the output is placed in
194/// the current working directory using just the filename — never preserving
195/// directory components from the source path.
196///
197/// Normal compilation: `src/foo.cpp` → `foo.o`
198/// PCH generation:     `src/pch.h`   → `pch.h.pch`  (clang)
199///                     `src/pch.h`   → `pch.h.gch`  (gcc)
200///
201/// Note: real compilers place PCH output next to the source file
202/// (`src/pch.h` → `src/pch.h.pch`), but zccache intentionally uses only
203/// the filename. This prevents spurious `.pch` files from being written
204/// into the source tree when a compilation falls back to `default_output`
205/// (e.g., during cache restoration without an explicit `-o` flag).
206fn default_output(
207    source: &str,
208    family: CompilerFamily,
209    mode: SourceMode,
210    has_precompile: bool,
211) -> String {
212    match mode {
213        SourceMode::Header => {
214            // PCH: filename.pch (Clang) or filename.gch (GCC)
215            if let Some(ext) = family.pch_extension() {
216                let filename = std::path::Path::new(source)
217                    .file_name()
218                    .and_then(|f| f.to_str())
219                    .unwrap_or(source);
220                return format!("{filename}.{ext}");
221            }
222        }
223        SourceMode::HeaderUnit => {
224            // Header unit: filename.pcm
225            let filename = std::path::Path::new(source)
226                .file_name()
227                .and_then(|f| f.to_str())
228                .unwrap_or(source);
229            return format!("{filename}.pcm");
230        }
231        SourceMode::Module | SourceMode::Normal => {
232            if has_precompile {
233                // --precompile: stem.pcm
234                let stem = std::path::Path::new(source)
235                    .file_stem()
236                    .and_then(|s| s.to_str())
237                    .unwrap_or("a");
238                return format!("{stem}.pcm");
239            }
240        }
241    }
242    // Default: stem.o
243    let stem = std::path::Path::new(source)
244        .file_stem()
245        .and_then(|s| s.to_str())
246        .unwrap_or("a");
247    format!("{stem}.o")
248}
249
250/// Flags that take a following argument (value in next argv element).
251const FLAGS_WITH_VALUE: &[&str] = &[
252    "-o",
253    "-D",
254    "-U",
255    "-I",
256    "-isystem",
257    "-iquote",
258    "-idirafter",
259    "-include",
260    "-include-pch",
261    "-isysroot",
262    "-target",
263    "--target",
264    "-MF",
265    "-MQ",
266    "-MT",
267    "-std",
268    "-x",
269    "-arch",
270    "-Xclang",
271    "-mllvm",
272    "--serialize-diagnostics",
273];
274
275/// Parse a compiler invocation's arguments to determine cacheability.
276///
277/// Returns a `ParsedInvocation` indicating whether the invocation is
278/// cacheable, and if so, extracts the relevant information.
279///
280/// Arg parsing is read-only analysis — it never modifies what goes to
281/// the compiler. The compiler always receives the exact original args.
282#[must_use]
283pub fn parse_invocation(compiler: &str, args: &[String]) -> ParsedInvocation {
284    let family = detect_family(compiler);
285    // Rustfmt is not a compiler — reject here, CLI handles it separately.
286    if family == CompilerFamily::Rustfmt {
287        return ParsedInvocation::NonCacheable {
288            reason: "rustfmt is handled via the format cache path, not compile cache".to_string(),
289        };
290    }
291    // Rustc has a completely different invocation model — dispatch early.
292    if family == CompilerFamily::Rustc {
293        return parse_rustc_invocation(compiler, args);
294    }
295
296    let mut has_c_flag = false;
297    let mut has_precompile_flag = false;
298    let mut source_files: Vec<(String, usize, SourceMode)> = Vec::new();
299    let mut output_file: Option<String> = None;
300    let mut current_mode = SourceMode::Normal;
301    let mut unknown_flags: Vec<String> = Vec::new();
302
303    let mut i = 0;
304    while i < args.len() {
305        let arg = &args[i];
306
307        // Check for non-cacheable flags
308        if arg == "-E" || arg == "-M" || arg == "-MM" {
309            return ParsedInvocation::NonCacheable {
310                reason: format!("preprocessing-only flag: {arg}"),
311            };
312        }
313
314        if arg == "-" {
315            return ParsedInvocation::NonCacheable {
316                reason: "stdin source not cacheable".to_string(),
317            };
318        }
319
320        if arg == "-c" {
321            has_c_flag = true;
322            i += 1;
323            continue;
324        }
325
326        // --precompile (Clang): compile module interface to BMI (.pcm).
327        // Acts like -c but for module output.
328        if arg == "--precompile" {
329            has_precompile_flag = true;
330            i += 1;
331            continue;
332        }
333
334        // -o takes the next arg as output file, or -o<path> (concatenated)
335        if arg == "-o" {
336            if let Some(next) = args.get(i + 1) {
337                output_file = Some(next.clone());
338                i += 2;
339            } else {
340                i += 1;
341            }
342            continue;
343        } else if let Some(path) = arg.strip_prefix("-o") {
344            output_file = Some(path.to_string());
345            i += 1;
346            continue;
347        }
348
349        // Flags that take a value in the next arg — skip both flag and value
350        if let Some(&flag) = FLAGS_WITH_VALUE.iter().find(|&&f| f == arg.as_str()) {
351            if flag == "-x" && i + 1 < args.len() {
352                current_mode =
353                    source_mode_from_language(&args[i + 1]).unwrap_or(SourceMode::Normal);
354            }
355            i += 2;
356            continue;
357        }
358
359        // Any flag starting with - (including unknown flags) — preserve
360        if arg.starts_with('-') {
361            unknown_flags.push(arg.clone());
362            i += 1;
363            continue;
364        }
365
366        // Positional arg — source file candidate.
367        // In a special mode (Header/HeaderUnit/Module), any positional arg is a source.
368        // Otherwise, check by file extension. Module extensions (.cppm/.ixx) also set
369        // the effective mode to Module for correct default output.
370        let effective_mode = if current_mode != SourceMode::Normal {
371            current_mode
372        } else {
373            source_mode_from_extension(arg)
374        };
375        if is_source_file(arg) || current_mode != SourceMode::Normal {
376            source_files.push((arg.clone(), i, effective_mode));
377        }
378
379        i += 1;
380    }
381
382    // Header and header-unit modes imply compilation (no -c needed).
383    // Module mode does NOT imply compilation alone — requires -c or --precompile.
384    // --precompile also implies compilation (like -c but for BMI output).
385    if !has_c_flag && !has_precompile_flag && !current_mode.implies_compilation() {
386        return ParsedInvocation::NonCacheable {
387            reason: "no -c flag (likely a link invocation)".to_string(),
388        };
389    }
390
391    if source_files.is_empty() {
392        return ParsedInvocation::NonCacheable {
393            reason: "no source file found".to_string(),
394        };
395    }
396
397    let family = detect_family(compiler);
398
399    // Multi-file: `-o` is invalid with `-c` and multiple sources (compiler rejects it),
400    // so each source gets its default output name (stem.o).
401    if source_files.len() > 1 {
402        let source_indices: Vec<usize> = source_files.iter().map(|(_, idx, _)| *idx).collect();
403        let shared_args: Arc<[String]> = Arc::from(args.to_vec());
404        let compilations = source_files
405            .iter()
406            .map(|(src, _, mode)| CacheableCompilation {
407                compiler: NormalizedPath::new(compiler),
408                family,
409                source_file: NormalizedPath::new(src),
410                output_file: NormalizedPath::new(default_output(
411                    src,
412                    family,
413                    *mode,
414                    has_precompile_flag,
415                )),
416                original_args: Arc::clone(&shared_args),
417                unknown_flags: unknown_flags.clone(),
418            })
419            .collect();
420        return ParsedInvocation::MultiFile {
421            compilations,
422            original_args: shared_args,
423            source_indices,
424        };
425    }
426
427    // Single source file
428    let (source, _, mode) = source_files.into_iter().next().unwrap();
429    let output =
430        output_file.unwrap_or_else(|| default_output(&source, family, mode, has_precompile_flag));
431
432    ParsedInvocation::Cacheable(CacheableCompilation {
433        compiler: NormalizedPath::new(compiler),
434        family,
435        source_file: NormalizedPath::new(source),
436        output_file: NormalizedPath::new(output),
437        original_args: Arc::from(args.to_vec()),
438        unknown_flags,
439    })
440}
441
442/// Cacheable rustc crate types: these don't invoke the system linker.
443const RUSTC_CACHEABLE_CRATE_TYPES: &[&str] = &["lib", "rlib", "staticlib"];
444
445/// Rustc flags that take a following argument (value in next argv element).
446const RUSTC_FLAGS_WITH_VALUE: &[&str] = &[
447    "--edition",
448    "--crate-type",
449    "--crate-name",
450    "--emit",
451    "--out-dir",
452    "--target",
453    "--cap-lints",
454    "--extern",
455    "--error-format",
456    "--json",
457    "--color",
458    "--diagnostic-width",
459    "--sysroot",
460    "--cfg",
461    "--check-cfg",
462    "-o",
463    "-L",
464    "-C",
465    "-A",
466    "-W",
467    "-D",
468    "-F",
469    "--codegen",
470    "--remap-path-prefix",
471    "--env-set",
472];
473
474/// Parse a rustc invocation to determine cacheability.
475///
476/// Cacheable: `--crate-type` is `lib`, `rlib`, or `staticlib` (no system linker).
477/// Non-cacheable: `bin`, `dylib`, `cdylib`, `proc-macro`, or `-C incremental`.
478fn parse_rustc_invocation(compiler: &str, args: &[String]) -> ParsedInvocation {
479    let mut crate_types: Vec<String> = Vec::new();
480    let mut source_file: Option<String> = None;
481    let mut output_file: Option<String> = None;
482    let mut out_dir: Option<String> = None;
483    let mut crate_name: Option<String> = None;
484    let mut extra_filename: Option<String> = None;
485    let mut emit_types: Vec<String> = Vec::new();
486    let mut unknown_flags: Vec<String> = Vec::new();
487
488    let mut i = 0;
489    while i < args.len() {
490        let arg = &args[i];
491
492        // --crate-type <type> or --crate-type=<type>
493        // Rustc accepts comma-separated types: --crate-type lib,rlib
494        if arg == "--crate-type" {
495            if let Some(next) = args.get(i + 1) {
496                crate_types.extend(next.split(',').map(|s| s.to_string()));
497                i += 2;
498                continue;
499            }
500        } else if let Some(val) = arg.strip_prefix("--crate-type=") {
501            crate_types.extend(val.split(',').map(|s| s.to_string()));
502            i += 1;
503            continue;
504        }
505
506        // --crate-name <name> or --crate-name=<name>
507        if arg == "--crate-name" {
508            if let Some(next) = args.get(i + 1) {
509                crate_name = Some(next.clone());
510                i += 2;
511                continue;
512            }
513        } else if let Some(val) = arg.strip_prefix("--crate-name=") {
514            crate_name = Some(val.to_string());
515            i += 1;
516            continue;
517        }
518
519        // --emit <types> or --emit=<types>
520        if arg == "--emit" {
521            if let Some(next) = args.get(i + 1) {
522                emit_types.extend(next.split(',').map(|s| {
523                    // Handle --emit=dep-info=path form
524                    s.split('=').next().unwrap_or(s).to_string()
525                }));
526                i += 2;
527                continue;
528            }
529        } else if let Some(val) = arg.strip_prefix("--emit=") {
530            emit_types.extend(
531                val.split(',')
532                    .map(|s| s.split('=').next().unwrap_or(s).to_string()),
533            );
534            i += 1;
535            continue;
536        }
537
538        // --out-dir <path> or --out-dir=<path>
539        if arg == "--out-dir" {
540            if let Some(next) = args.get(i + 1) {
541                out_dir = Some(next.clone());
542                i += 2;
543                continue;
544            }
545        } else if let Some(val) = arg.strip_prefix("--out-dir=") {
546            out_dir = Some(val.to_string());
547            i += 1;
548            continue;
549        }
550
551        // -o <path>
552        if arg == "-o" {
553            if let Some(next) = args.get(i + 1) {
554                output_file = Some(next.clone());
555                i += 2;
556                continue;
557            }
558        }
559
560        // -C <option> or -C<option> or --codegen <option>
561        if arg == "-C" || arg == "--codegen" {
562            if let Some(next) = args.get(i + 1) {
563                if let Some(val) = next.strip_prefix("extra-filename=") {
564                    extra_filename = Some(val.to_string());
565                }
566                i += 2;
567                continue;
568            }
569        } else if let Some(rest) = arg.strip_prefix("-C") {
570            if !rest.is_empty() {
571                if let Some(val) = rest.strip_prefix("extra-filename=") {
572                    extra_filename = Some(val.to_string());
573                }
574                i += 1;
575                continue;
576            }
577        }
578
579        // Known flags that take a value — skip both
580        if let Some(&_flag) = RUSTC_FLAGS_WITH_VALUE.iter().find(|&&f| f == arg.as_str()) {
581            i += 2;
582            continue;
583        }
584
585        // Flags with = form (e.g., --edition=2021, --cfg=feature)
586        if arg.starts_with("--") && arg.contains('=') {
587            i += 1;
588            continue;
589        }
590
591        // Any flag starting with -
592        if arg.starts_with('-') {
593            unknown_flags.push(arg.clone());
594            i += 1;
595            continue;
596        }
597
598        // Positional arg — source file candidate (.rs)
599        if arg.ends_with(".rs") {
600            source_file = Some(arg.clone());
601        }
602
603        i += 1;
604    }
605
606    // No source file → non-cacheable (e.g., `rustc --version`)
607    let source = match source_file {
608        Some(s) => s,
609        None => {
610            return ParsedInvocation::NonCacheable {
611                reason: "no .rs source file found".to_string(),
612            };
613        }
614    };
615
616    // Note: -C incremental is ignored for caching purposes.
617    // The incremental dir is excluded from the cache key, and we let rustc
618    // use it on a miss (doesn't affect output determinism for rlib/rmeta).
619    // sccache also allows incremental — cargo always passes it.
620
621    // Default crate type is bin if not specified
622    if crate_types.is_empty() {
623        crate_types.push("bin".to_string());
624    }
625
626    // Check all crate types are cacheable
627    for ct in &crate_types {
628        if !RUSTC_CACHEABLE_CRATE_TYPES.contains(&ct.as_str()) {
629            return ParsedInvocation::NonCacheable {
630                reason: format!("non-cacheable crate type: {ct}"),
631            };
632        }
633    }
634
635    // Determine primary output extension based on --emit and --crate-type.
636    // If --emit includes "link", the primary is rlib/staticlib.
637    // If --emit is metadata-only (no link), the primary is rmeta.
638    let has_link_emit = emit_types.iter().any(|t| t == "link");
639    let primary_ext = if !has_link_emit && emit_types.iter().any(|t| t == "metadata") {
640        "rmeta"
641    } else {
642        match crate_types.first().map(|s| s.as_str()) {
643            Some("staticlib") => "a",
644            _ => "rlib",
645        }
646    };
647
648    // Derive output path
649    let output = if let Some(o) = output_file {
650        o
651    } else if let Some(ref dir) = out_dir {
652        let name = crate_name.as_deref().unwrap_or("unknown");
653        let suffix = extra_filename.as_deref().unwrap_or("");
654        // Use NormalizedPath::join to handle platform path separators correctly
655        NormalizedPath::new(dir)
656            .join(format!("lib{name}{suffix}.{primary_ext}"))
657            .to_string_lossy()
658            .into_owned()
659    } else {
660        let name = crate_name.as_deref().unwrap_or_else(|| {
661            std::path::Path::new(&source)
662                .file_stem()
663                .and_then(|s| s.to_str())
664                .unwrap_or("unknown")
665        });
666        format!("lib{name}.{primary_ext}")
667    };
668
669    ParsedInvocation::Cacheable(CacheableCompilation {
670        compiler: NormalizedPath::new(compiler),
671        family: CompilerFamily::Rustc,
672        source_file: NormalizedPath::new(source),
673        output_file: NormalizedPath::new(output),
674        original_args: Arc::from(args.to_vec()),
675        unknown_flags,
676    })
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    fn args(s: &[&str]) -> Vec<String> {
684        s.iter().map(|x| x.to_string()).collect()
685    }
686
687    #[test]
688    fn basic_cacheable_compilation() {
689        let result = parse_invocation("clang++", &args(&["-c", "hello.cpp", "-o", "hello.o"]));
690        match result {
691            ParsedInvocation::Cacheable(c) => {
692                assert_eq!(c.source_file, NormalizedPath::new("hello.cpp"));
693                assert_eq!(c.output_file, NormalizedPath::new("hello.o"));
694                assert_eq!(c.family, CompilerFamily::Clang);
695            }
696            other => panic!("expected cacheable, got: {other:?}"),
697        }
698    }
699
700    #[test]
701    fn no_c_flag_is_non_cacheable() {
702        let result = parse_invocation("gcc", &args(&["hello.cpp", "-o", "hello"]));
703        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
704    }
705
706    #[test]
707    fn preprocessing_only_non_cacheable() {
708        let result = parse_invocation("gcc", &args(&["-E", "hello.cpp"]));
709        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
710    }
711
712    #[test]
713    fn multi_file_split() {
714        let result = parse_invocation("gcc", &args(&["-c", "a.cpp", "b.cpp"]));
715        match result {
716            ParsedInvocation::MultiFile {
717                compilations,
718                source_indices,
719                ..
720            } => {
721                assert_eq!(compilations.len(), 2);
722                assert_eq!(compilations[0].source_file, NormalizedPath::new("a.cpp"));
723                assert_eq!(compilations[0].output_file, NormalizedPath::new("a.o"));
724                assert_eq!(compilations[1].source_file, NormalizedPath::new("b.cpp"));
725                assert_eq!(compilations[1].output_file, NormalizedPath::new("b.o"));
726                assert_eq!(source_indices, vec![1, 2]);
727            }
728            other => panic!("expected MultiFile, got: {other:?}"),
729        }
730    }
731
732    #[test]
733    fn multi_file_with_flags() {
734        let result = parse_invocation(
735            "g++",
736            &args(&["-c", "-O2", "main.cpp", "-Wall", "util.cpp"]),
737        );
738        match result {
739            ParsedInvocation::MultiFile {
740                compilations,
741                original_args,
742                source_indices,
743            } => {
744                assert_eq!(compilations.len(), 2);
745                assert_eq!(compilations[0].source_file, NormalizedPath::new("main.cpp"));
746                assert_eq!(compilations[1].source_file, NormalizedPath::new("util.cpp"));
747                // Flags are in original_args, not per-compilation
748                assert!(original_args.contains(&"-O2".to_string()));
749                assert!(original_args.contains(&"-Wall".to_string()));
750                assert_eq!(source_indices, vec![2, 4]);
751            }
752            other => panic!("expected MultiFile, got: {other:?}"),
753        }
754    }
755
756    #[test]
757    fn multi_file_mixed_extensions() {
758        let result = parse_invocation("gcc", &args(&["-c", "file1.c", "file2.cpp"]));
759        match result {
760            ParsedInvocation::MultiFile { compilations, .. } => {
761                assert_eq!(compilations.len(), 2);
762                assert_eq!(compilations[0].source_file, NormalizedPath::new("file1.c"));
763                assert_eq!(
764                    compilations[1].source_file,
765                    NormalizedPath::new("file2.cpp")
766                );
767            }
768            other => panic!("expected MultiFile, got: {other:?}"),
769        }
770    }
771
772    #[test]
773    fn stdin_non_cacheable() {
774        let result = parse_invocation("gcc", &args(&["-c", "-"]));
775        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
776    }
777
778    #[test]
779    fn default_output_name() {
780        let result = parse_invocation("gcc", &args(&["-c", "foo.cpp"]));
781        match result {
782            ParsedInvocation::Cacheable(c) => {
783                assert_eq!(c.output_file, NormalizedPath::new("foo.o"));
784            }
785            _ => panic!("expected cacheable"),
786        }
787    }
788
789    #[test]
790    fn original_args_preserved() {
791        let input = args(&["-c", "hello.cpp", "-O2", "-std=c++17", "-DNDEBUG", "-Wall"]);
792        let result = parse_invocation("clang++", &input);
793        match result {
794            ParsedInvocation::Cacheable(c) => {
795                assert_eq!(*c.original_args, *input);
796            }
797            _ => panic!("expected cacheable"),
798        }
799    }
800
801    #[test]
802    fn unknown_flags_preserved_in_original_args() {
803        let input = args(&[
804            "-c",
805            "hello.cpp",
806            "--deploy-dependencies",
807            "--custom-flag=value",
808            "-o",
809            "hello.o",
810        ]);
811        let result = parse_invocation("clang++", &input);
812        match result {
813            ParsedInvocation::Cacheable(c) => {
814                assert_eq!(*c.original_args, *input);
815                assert_eq!(c.source_file, NormalizedPath::new("hello.cpp"));
816                assert_eq!(c.output_file, NormalizedPath::new("hello.o"));
817            }
818            other => panic!("expected cacheable, got: {other:?}"),
819        }
820    }
821
822    #[test]
823    fn include_pch_flag_with_value() {
824        let result = parse_invocation(
825            "clang++",
826            &args(&["-c", "foo.cpp", "-include-pch", "pch.h.pch", "-o", "foo.o"]),
827        );
828        match result {
829            ParsedInvocation::Cacheable(c) => {
830                // PCH path is NOT treated as a source file
831                assert_eq!(c.source_file, NormalizedPath::new("foo.cpp"));
832                // Original args preserved
833                assert!(c.original_args.contains(&"-include-pch".to_string()));
834                assert!(c.original_args.contains(&"pch.h.pch".to_string()));
835            }
836            other => panic!("expected cacheable, got: {other:?}"),
837        }
838    }
839
840    #[test]
841    fn pch_generation_cpp_header_is_cacheable() {
842        // `clang -x c++-header -c pch.h -o pch.h.pch` should be cacheable
843        let result = parse_invocation(
844            "clang++",
845            &args(&["-x", "c++-header", "-c", "pch.h", "-o", "pch.h.pch"]),
846        );
847        match result {
848            ParsedInvocation::Cacheable(c) => {
849                assert_eq!(c.source_file, NormalizedPath::new("pch.h"));
850                assert_eq!(c.output_file, NormalizedPath::new("pch.h.pch"));
851            }
852            other => panic!("expected cacheable, got: {other:?}"),
853        }
854    }
855
856    #[test]
857    fn pch_generation_c_header_is_cacheable() {
858        // `gcc -x c-header -c stdafx.h -o stdafx.h.gch` should be cacheable
859        let result = parse_invocation(
860            "gcc",
861            &args(&["-x", "c-header", "-c", "stdafx.h", "-o", "stdafx.h.gch"]),
862        );
863        match result {
864            ParsedInvocation::Cacheable(c) => {
865                assert_eq!(c.source_file, NormalizedPath::new("stdafx.h"));
866                assert_eq!(c.output_file, NormalizedPath::new("stdafx.h.gch"));
867            }
868            other => panic!("expected cacheable, got: {other:?}"),
869        }
870    }
871
872    #[test]
873    fn pch_generation_without_c_flag_is_cacheable() {
874        // Meson invokes PCH generation without -c:
875        // `clang++ -x c++-header header.h -o header.pch`
876        // `-x c++-header` implies compilation, so -c is redundant.
877        let result = parse_invocation(
878            "clang++",
879            &args(&["-x", "c++-header", "FastLED.h", "-o", "FastLED.h.pch"]),
880        );
881        match result {
882            ParsedInvocation::Cacheable(c) => {
883                assert_eq!(c.source_file, NormalizedPath::new("FastLED.h"));
884                assert_eq!(c.output_file, NormalizedPath::new("FastLED.h.pch"));
885            }
886            other => panic!("expected cacheable, got: {other:?}"),
887        }
888    }
889
890    #[test]
891    fn pch_generation_c_header_without_c_flag_is_cacheable() {
892        let result = parse_invocation(
893            "gcc",
894            &args(&["-x", "c-header", "stdafx.h", "-o", "stdafx.h.gch"]),
895        );
896        match result {
897            ParsedInvocation::Cacheable(c) => {
898                assert_eq!(c.source_file, NormalizedPath::new("stdafx.h"));
899                assert_eq!(c.output_file, NormalizedPath::new("stdafx.h.gch"));
900            }
901            other => panic!("expected cacheable, got: {other:?}"),
902        }
903    }
904
905    #[test]
906    fn pch_generation_with_meson_flags_is_cacheable() {
907        // Full Meson-style PCH invocation with extra flags
908        let result = parse_invocation(
909            "ctc-clang++",
910            &args(&[
911                "-x",
912                "c++-header",
913                "FastLED.h",
914                "-o",
915                "FastLED.h.pch",
916                "-MD",
917                "-MF",
918                "FastLED.h.pch.d",
919                "-fPIC",
920                "-Iinclude",
921                "-Werror=invalid-pch",
922            ]),
923        );
924        match result {
925            ParsedInvocation::Cacheable(c) => {
926                assert_eq!(c.source_file, NormalizedPath::new("FastLED.h"));
927                assert_eq!(c.output_file, NormalizedPath::new("FastLED.h.pch"));
928            }
929            other => panic!("expected cacheable, got: {other:?}"),
930        }
931    }
932
933    #[test]
934    fn header_without_x_flag_is_not_source() {
935        // Without `-x c++-header`, a .h file should NOT be recognized as a source
936        let result = parse_invocation("clang++", &args(&["-c", "pch.h"]));
937        assert!(
938            matches!(result, ParsedInvocation::NonCacheable { .. }),
939            "bare .h without -x header mode should be non-cacheable"
940        );
941    }
942
943    #[test]
944    fn x_flag_reset_disables_header_mode() {
945        // `-x c++-header pch.h -x c++ main.cpp -c -o main.o`
946        // After `-x c++`, header_mode resets — main.cpp is a normal source,
947        // pch.h was collected as header-mode source → multi-file.
948        let result = parse_invocation(
949            "clang++",
950            &args(&[
951                "-x",
952                "c++-header",
953                "pch.h",
954                "-x",
955                "c++",
956                "main.cpp",
957                "-c",
958                "-o",
959                "main.o",
960            ]),
961        );
962        match result {
963            ParsedInvocation::MultiFile { compilations, .. } => {
964                assert_eq!(compilations.len(), 2);
965                assert_eq!(compilations[0].source_file, NormalizedPath::new("pch.h"));
966                assert_eq!(compilations[1].source_file, NormalizedPath::new("main.cpp"));
967            }
968            other => panic!("expected MultiFile, got: {other:?}"),
969        }
970    }
971
972    #[test]
973    fn x_cpp_after_header_is_normal_compilation() {
974        // `-x c++-header -x c++ main.cpp -c -o main.o`
975        // No header file between the two -x flags. The second -x c++ resets
976        // header_mode, so main.cpp is a normal compilation.
977        let result = parse_invocation(
978            "clang++",
979            &args(&[
980                "-x",
981                "c++-header",
982                "-x",
983                "c++",
984                "main.cpp",
985                "-c",
986                "-o",
987                "main.o",
988            ]),
989        );
990        match result {
991            ParsedInvocation::Cacheable(c) => {
992                assert_eq!(c.source_file, NormalizedPath::new("main.cpp"));
993                assert_eq!(c.output_file, NormalizedPath::new("main.o"));
994            }
995            other => panic!("expected Cacheable, got: {other:?}"),
996        }
997    }
998
999    // ─── Regression tests: sticky header_mode bug ─────────────────────
1000
1001    #[test]
1002    fn sticky_header_mode_cpp_not_spuriously_pch() {
1003        // BUG: old code set header_mode=true on `-x c++-header` but never
1004        // reset it on `-x c++`, so main.cpp was treated as a header file
1005        // needing PCH generation. After the fix, `-x c++` resets header_mode,
1006        // and main.cpp is a normal source — not a PCH candidate.
1007        let result = parse_invocation(
1008            "clang++",
1009            &args(&[
1010                "-x",
1011                "c++-header",
1012                "pch.h",
1013                "-o",
1014                "pch.h.pch",
1015                "-x",
1016                "c++",
1017                "-c",
1018                "main.cpp",
1019                "-o",
1020                "main.o",
1021            ]),
1022        );
1023        // main.cpp must be recognized as a normal source via its extension,
1024        // NOT via header_mode. With the old bug, header_mode stayed true and
1025        // both pch.h and main.cpp were header-mode sources.
1026        match &result {
1027            ParsedInvocation::MultiFile { compilations, .. } => {
1028                assert_eq!(compilations.len(), 2);
1029                // pch.h picked up in header_mode
1030                assert_eq!(compilations[0].source_file, NormalizedPath::new("pch.h"));
1031                // main.cpp picked up by extension after reset
1032                assert_eq!(compilations[1].source_file, NormalizedPath::new("main.cpp"));
1033            }
1034            other => panic!("expected MultiFile, got: {other:?}"),
1035        }
1036    }
1037
1038    #[test]
1039    fn sticky_header_mode_non_source_not_captured_after_reset() {
1040        // BUG: with sticky header_mode, a positional arg like "README.txt"
1041        // after `-x c++` would be treated as a source file because
1042        // `is_source_file(arg) || header_mode` was true. After fix,
1043        // header_mode is false after `-x c++`, so non-source extensions
1044        // are correctly ignored.
1045        let result = parse_invocation(
1046            "clang++",
1047            &args(&["-x", "c++-header", "pch.h", "-x", "c++", "-c", "main.cpp"]),
1048        );
1049        match &result {
1050            ParsedInvocation::MultiFile { compilations, .. } => {
1051                assert_eq!(compilations.len(), 2);
1052                assert_eq!(compilations[0].source_file, NormalizedPath::new("pch.h"));
1053                assert_eq!(compilations[1].source_file, NormalizedPath::new("main.cpp"));
1054            }
1055            other => panic!("expected MultiFile, got: {other:?}"),
1056        }
1057    }
1058
1059    #[test]
1060    fn sticky_header_mode_no_c_flag_after_reset_is_non_cacheable() {
1061        // BUG: with sticky header_mode, the `!has_c_flag && !header_mode`
1062        // check at the end would pass (header_mode was still true),
1063        // making a link invocation appear cacheable. After fix, `-x c++`
1064        // resets header_mode so without -c it's correctly non-cacheable.
1065        let result = parse_invocation(
1066            "clang++",
1067            &args(&["-x", "c++-header", "-x", "c++", "main.cpp", "-o", "main"]),
1068        );
1069        assert!(
1070            matches!(result, ParsedInvocation::NonCacheable { .. }),
1071            "after -x c++ reset, no -c should be non-cacheable, got: {result:?}"
1072        );
1073    }
1074
1075    #[test]
1076    fn header_unit_c_is_cacheable() {
1077        // `-x c-header-unit` activates header-unit mode (C++20 module support).
1078        // Header-unit mode implies compilation, producing .pcm output.
1079        let result = parse_invocation(
1080            "clang++",
1081            &args(&["-x", "c-header-unit", "foo.h", "-o", "foo.pcm"]),
1082        );
1083        match result {
1084            ParsedInvocation::Cacheable(c) => {
1085                assert_eq!(c.source_file, NormalizedPath::new("foo.h"));
1086                assert_eq!(c.output_file, NormalizedPath::new("foo.pcm"));
1087            }
1088            other => panic!("expected cacheable, got: {other:?}"),
1089        }
1090    }
1091
1092    #[test]
1093    fn header_unit_cpp_is_cacheable() {
1094        // `-x c++-header-unit` activates header-unit mode (C++20 module support).
1095        let result = parse_invocation(
1096            "clang++",
1097            &args(&["-x", "c++-header-unit", "foo.h", "-o", "foo.pcm"]),
1098        );
1099        match result {
1100            ParsedInvocation::Cacheable(c) => {
1101                assert_eq!(c.source_file, NormalizedPath::new("foo.h"));
1102                assert_eq!(c.output_file, NormalizedPath::new("foo.pcm"));
1103            }
1104            other => panic!("expected cacheable, got: {other:?}"),
1105        }
1106    }
1107
1108    #[test]
1109    fn detect_clang_family() {
1110        assert_eq!(detect_family("clang++"), CompilerFamily::Clang);
1111        assert_eq!(detect_family("/usr/bin/clang"), CompilerFamily::Clang);
1112        assert_eq!(detect_family("gcc"), CompilerFamily::Gcc);
1113        assert_eq!(detect_family("g++"), CompilerFamily::Gcc);
1114    }
1115
1116    #[test]
1117    fn detect_emcc_family() {
1118        assert_eq!(detect_family("emcc"), CompilerFamily::Clang);
1119        assert_eq!(detect_family("em++"), CompilerFamily::Clang);
1120        assert_eq!(detect_family("/usr/bin/emcc"), CompilerFamily::Clang);
1121        assert_eq!(detect_family("emcc.exe"), CompilerFamily::Clang);
1122        // emcc supports -MD -MF (same as clang)
1123        assert!(CompilerFamily::Clang.supports_depfile());
1124    }
1125
1126    #[test]
1127    fn detect_msvc_family() {
1128        assert_eq!(detect_family("cl"), CompilerFamily::Msvc);
1129        assert_eq!(detect_family("C:\\MSVC\\cl"), CompilerFamily::Msvc);
1130    }
1131
1132    #[test]
1133    fn detect_msvc_case_insensitive() {
1134        // MSVC cl.exe is commonly invoked in uppercase on Windows.
1135        // Bug: detect_family used case-sensitive `name == "cl"`, so
1136        // CL.EXE was misclassified as Gcc.
1137        assert_eq!(detect_family("CL"), CompilerFamily::Msvc);
1138        assert_eq!(detect_family("CL.EXE"), CompilerFamily::Msvc);
1139        assert_eq!(detect_family("Cl.exe"), CompilerFamily::Msvc);
1140        assert_eq!(detect_family("C:\\MSVC\\CL.EXE"), CompilerFamily::Msvc);
1141        assert_eq!(
1142            detect_family("C:\\Program Files\\MSVC\\cl.EXE"),
1143            CompilerFamily::Msvc
1144        );
1145    }
1146
1147    #[test]
1148    fn gcc_supports_depfile() {
1149        assert!(CompilerFamily::Gcc.supports_depfile());
1150    }
1151
1152    #[test]
1153    fn clang_supports_depfile() {
1154        assert!(CompilerFamily::Clang.supports_depfile());
1155    }
1156
1157    #[test]
1158    fn msvc_no_depfile() {
1159        assert!(!CompilerFamily::Msvc.supports_depfile());
1160    }
1161
1162    // ─── PCH default output tests ────────────────────────────────────
1163
1164    #[test]
1165    fn pch_default_output_clang() {
1166        // `clang++ -x c++-header src/pch.h` → output `pch.h.pch` (filename only, no dir)
1167        let result = parse_invocation("clang++", &args(&["-x", "c++-header", "src/pch.h"]));
1168        match result {
1169            ParsedInvocation::Cacheable(c) => {
1170                assert_eq!(c.source_file, NormalizedPath::new("src/pch.h"));
1171                assert_eq!(c.output_file, NormalizedPath::new("pch.h.pch"));
1172            }
1173            other => panic!("expected cacheable, got: {other:?}"),
1174        }
1175    }
1176
1177    #[test]
1178    fn pch_default_output_gcc() {
1179        // `gcc -x c-header src/pch.h` → output `pch.h.gch` (filename only, no dir)
1180        let result = parse_invocation("gcc", &args(&["-x", "c-header", "src/pch.h"]));
1181        match result {
1182            ParsedInvocation::Cacheable(c) => {
1183                assert_eq!(c.source_file, NormalizedPath::new("src/pch.h"));
1184                assert_eq!(c.output_file, NormalizedPath::new("pch.h.gch"));
1185            }
1186            other => panic!("expected cacheable, got: {other:?}"),
1187        }
1188    }
1189
1190    #[test]
1191    fn pch_default_output_strips_directory() {
1192        // `clang++ -x c++-header src/fl/audio/fft/fft.h` → output uses filename only.
1193        // Regression: old code produced `src/fl/audio/fft/fft.h.pch`, causing spurious
1194        // PCH files to be written into the source tree during cache restoration.
1195        let result = parse_invocation(
1196            "clang++",
1197            &args(&["-x", "c++-header", "src/fl/audio/fft/fft.h"]),
1198        );
1199        match result {
1200            ParsedInvocation::Cacheable(c) => {
1201                assert_eq!(c.source_file, NormalizedPath::new("src/fl/audio/fft/fft.h"));
1202                assert_eq!(c.output_file, NormalizedPath::new("fft.h.pch"));
1203            }
1204            other => panic!("expected cacheable, got: {other:?}"),
1205        }
1206    }
1207
1208    #[test]
1209    fn pch_default_output_absolute_path_strips_to_filename() {
1210        // Absolute source path must also produce filename-only output.
1211        // Regression: old code produced `/abs/path/src/pch.h.pch` which
1212        // the daemon resolved as an absolute write into the source tree.
1213        let result = parse_invocation(
1214            "clang++",
1215            &args(&["-x", "c++-header", "/abs/path/src/pch.h"]),
1216        );
1217        match result {
1218            ParsedInvocation::Cacheable(c) => {
1219                assert_eq!(c.source_file, NormalizedPath::new("/abs/path/src/pch.h"));
1220                assert_eq!(c.output_file, NormalizedPath::new("pch.h.pch"));
1221            }
1222            other => panic!("expected cacheable, got: {other:?}"),
1223        }
1224    }
1225
1226    #[test]
1227    fn pch_default_output_explicit_o_unchanged() {
1228        // Explicit `-o` still honored — no change in behavior
1229        let result = parse_invocation(
1230            "clang++",
1231            &args(&["-x", "c++-header", "pch.h", "-o", "build/pch.h.pch"]),
1232        );
1233        match result {
1234            ParsedInvocation::Cacheable(c) => {
1235                assert_eq!(c.source_file, NormalizedPath::new("pch.h"));
1236                assert_eq!(c.output_file, NormalizedPath::new("build/pch.h.pch"));
1237            }
1238            other => panic!("expected cacheable, got: {other:?}"),
1239        }
1240    }
1241
1242    #[test]
1243    fn normal_compile_default_output_unchanged() {
1244        // Regression guard: normal compilation still defaults to stem.o
1245        let result = parse_invocation("gcc", &args(&["-c", "foo.cpp"]));
1246        match result {
1247            ParsedInvocation::Cacheable(c) => {
1248                assert_eq!(c.output_file, NormalizedPath::new("foo.o"));
1249            }
1250            other => panic!("expected cacheable, got: {other:?}"),
1251        }
1252    }
1253
1254    // ─── Concatenated -o flag tests ──────────────────────────────────
1255
1256    #[test]
1257    fn concatenated_o_flag_parsed() {
1258        // `-obuild/foo.o` (no space) is valid for clang/gcc and must be recognized.
1259        let result = parse_invocation("clang", &args(&["-c", "foo.cpp", "-obuild/foo.o"]));
1260        match result {
1261            ParsedInvocation::Cacheable(c) => {
1262                assert_eq!(c.output_file, NormalizedPath::new("build/foo.o"));
1263            }
1264            other => panic!("expected cacheable, got: {other:?}"),
1265        }
1266    }
1267
1268    #[test]
1269    fn concatenated_o_flag_pch() {
1270        // PCH compilation with concatenated -o must preserve the build directory path.
1271        // This is the root cause of BUG_LINKER.md: `-opath` was silently dropped
1272        // as an unknown flag, causing default_output() to be used instead.
1273        let result = parse_invocation(
1274            "clang++",
1275            &args(&["-x", "c++-header", "pch.h", "-obuild/pch.h.pch"]),
1276        );
1277        match result {
1278            ParsedInvocation::Cacheable(c) => {
1279                assert_eq!(c.output_file, NormalizedPath::new("build/pch.h.pch"));
1280            }
1281            other => panic!("expected cacheable, got: {other:?}"),
1282        }
1283    }
1284
1285    // ─── Unknown flags preservation tests ────────────────────────────
1286
1287    #[test]
1288    fn all_flags_preserved() {
1289        // Every arg must be accounted for: either recognized by the parser
1290        // (source, output, known flag) or captured in unknown_flags.
1291        // Nothing is silently dropped.
1292        let input = args(&[
1293            "-c",
1294            "foo.cpp",
1295            "-o",
1296            "foo.o",
1297            "-Wall",
1298            "-Wextra",
1299            "-O2",
1300            "-Xclang",
1301            "-fno-spell-checking",
1302            "-std=c++17",
1303            "-DFOO=bar",
1304            "-I/usr/include",
1305            "-isystem",
1306            "/usr/local/include",
1307            "-unknown-future-flag",
1308        ]);
1309        let result = parse_invocation("clang++", &input);
1310        match result {
1311            ParsedInvocation::Cacheable(c) => {
1312                assert_eq!(c.source_file, NormalizedPath::new("foo.cpp"));
1313                assert_eq!(c.output_file, NormalizedPath::new("foo.o"));
1314                // Unknown flags are preserved, not dropped
1315                assert!(c.unknown_flags.contains(&"-Wall".to_string()));
1316                assert!(c.unknown_flags.contains(&"-Wextra".to_string()));
1317                assert!(c.unknown_flags.contains(&"-O2".to_string()));
1318                assert!(c
1319                    .unknown_flags
1320                    .contains(&"-unknown-future-flag".to_string()));
1321                // Concatenated known flags end up in unknown_flags
1322                // (parser only extracts -o value; -D/-I/-std with joined
1323                // values are not in FLAGS_WITH_VALUE so they go here)
1324                assert!(c.unknown_flags.contains(&"-std=c++17".to_string()));
1325                assert!(c.unknown_flags.contains(&"-DFOO=bar".to_string()));
1326                assert!(c.unknown_flags.contains(&"-I/usr/include".to_string()));
1327            }
1328            other => panic!("expected cacheable, got: {other:?}"),
1329        }
1330    }
1331
1332    #[test]
1333    fn xclang_value_not_misidentified_as_source() {
1334        // -Xclang takes the next arg as a pass-through value.
1335        // Without FLAGS_WITH_VALUE coverage, the value could be
1336        // misidentified as a source file.
1337        let result = parse_invocation(
1338            "clang++",
1339            &args(&[
1340                "-c",
1341                "foo.cpp",
1342                "-Xclang",
1343                "-fno-spell-checking",
1344                "-o",
1345                "foo.o",
1346            ]),
1347        );
1348        match result {
1349            ParsedInvocation::Cacheable(c) => {
1350                assert_eq!(c.source_file, NormalizedPath::new("foo.cpp"));
1351                // Only one source file — -fno-spell-checking must NOT be treated as source
1352            }
1353            other => panic!("expected cacheable, got: {other:?}"),
1354        }
1355    }
1356
1357    #[test]
1358    fn mllvm_value_not_misidentified_as_source() {
1359        let result = parse_invocation(
1360            "clang++",
1361            &args(&["-c", "foo.cpp", "-mllvm", "-some-llvm-opt", "-o", "foo.o"]),
1362        );
1363        match result {
1364            ParsedInvocation::Cacheable(c) => {
1365                assert_eq!(c.source_file, NormalizedPath::new("foo.cpp"));
1366            }
1367            other => panic!("expected cacheable, got: {other:?}"),
1368        }
1369    }
1370
1371    // ─── PCH output path mismatch repro (BUG_LINKER.md) ─────────────
1372
1373    #[test]
1374    fn pch_output_path_mismatch_repro() {
1375        // Repro for BUG_LINKER.md: when no -o is provided for a nested
1376        // source header, default_output() returns filename-only which
1377        // doesn't match where clang actually writes (next to source).
1378        // The caller (build system) should always provide -o; the parser
1379        // must recognize all forms of -o (space-separated and concatenated).
1380        let result = parse_invocation(
1381            "clang++",
1382            &args(&["-x", "c++-header", "src/fl/fx/2d/flowfield_q31.h"]),
1383        );
1384        match result {
1385            ParsedInvocation::Cacheable(c) => {
1386                assert_eq!(c.output_file, NormalizedPath::new("flowfield_q31.h.pch"));
1387                assert_eq!(
1388                    c.source_file,
1389                    NormalizedPath::new("src/fl/fx/2d/flowfield_q31.h")
1390                );
1391            }
1392            other => panic!("expected cacheable, got: {other:?}"),
1393        }
1394    }
1395
1396    // ─── Rustc detection tests ──────────────────────────────────────
1397
1398    #[test]
1399    fn detect_rustc_family() {
1400        assert_eq!(detect_family("rustc"), CompilerFamily::Rustc);
1401        assert_eq!(detect_family("/usr/bin/rustc"), CompilerFamily::Rustc);
1402        assert_eq!(detect_family("rustc.exe"), CompilerFamily::Rustc);
1403        assert_eq!(
1404            detect_family("C:\\rustup\\rustc.exe"),
1405            CompilerFamily::Rustc
1406        );
1407    }
1408
1409    #[test]
1410    fn rustc_no_depfile_support() {
1411        // Rustc uses --emit=dep-info, not -MD -MF
1412        assert!(!CompilerFamily::Rustc.supports_depfile());
1413    }
1414
1415    #[test]
1416    fn rustc_no_pch_extension() {
1417        assert_eq!(CompilerFamily::Rustc.pch_extension(), None);
1418    }
1419
1420    // ─── Rustc cacheability tests ───────────────────────────────────
1421
1422    #[test]
1423    fn rustc_lib_crate_is_cacheable() {
1424        let result = parse_invocation(
1425            "rustc",
1426            &args(&[
1427                "--edition",
1428                "2021",
1429                "--crate-type",
1430                "lib",
1431                "--emit=dep-info,metadata,link",
1432                "-C",
1433                "opt-level=2",
1434                "src/lib.rs",
1435            ]),
1436        );
1437        match result {
1438            ParsedInvocation::Cacheable(c) => {
1439                assert_eq!(c.family, CompilerFamily::Rustc);
1440                assert_eq!(c.source_file, NormalizedPath::new("src/lib.rs"));
1441            }
1442            other => panic!("expected cacheable, got: {other:?}"),
1443        }
1444    }
1445
1446    #[test]
1447    fn rustc_rlib_crate_is_cacheable() {
1448        let result = parse_invocation("rustc", &args(&["--crate-type", "rlib", "src/lib.rs"]));
1449        assert!(matches!(result, ParsedInvocation::Cacheable(_)));
1450    }
1451
1452    #[test]
1453    fn rustc_staticlib_crate_is_cacheable() {
1454        let result = parse_invocation("rustc", &args(&["--crate-type", "staticlib", "src/lib.rs"]));
1455        assert!(matches!(result, ParsedInvocation::Cacheable(_)));
1456    }
1457
1458    #[test]
1459    fn rustc_bin_crate_is_non_cacheable() {
1460        let result = parse_invocation("rustc", &args(&["--crate-type", "bin", "src/main.rs"]));
1461        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
1462    }
1463
1464    #[test]
1465    fn rustc_dylib_is_non_cacheable() {
1466        let result = parse_invocation("rustc", &args(&["--crate-type", "dylib", "src/lib.rs"]));
1467        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
1468    }
1469
1470    #[test]
1471    fn rustc_proc_macro_is_non_cacheable() {
1472        let result = parse_invocation(
1473            "rustc",
1474            &args(&["--crate-type", "proc-macro", "src/lib.rs"]),
1475        );
1476        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
1477    }
1478
1479    #[test]
1480    fn rustc_cdylib_is_non_cacheable() {
1481        let result = parse_invocation("rustc", &args(&["--crate-type", "cdylib", "src/lib.rs"]));
1482        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
1483    }
1484
1485    #[test]
1486    fn rustc_no_crate_type_defaults_to_bin_non_cacheable() {
1487        // Without --crate-type, rustc defaults to bin
1488        let result = parse_invocation("rustc", &args(&["src/main.rs"]));
1489        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
1490    }
1491
1492    #[test]
1493    fn rustc_incremental_is_cacheable() {
1494        // Cargo always passes -C incremental. We allow it (ignored for cache key).
1495        let result = parse_invocation(
1496            "rustc",
1497            &args(&[
1498                "--crate-type",
1499                "lib",
1500                "-C",
1501                "incremental=/tmp/incr",
1502                "src/lib.rs",
1503            ]),
1504        );
1505        assert!(matches!(result, ParsedInvocation::Cacheable(_)));
1506    }
1507
1508    #[test]
1509    fn rustc_no_source_is_non_cacheable() {
1510        let result = parse_invocation("rustc", &args(&["--version"]));
1511        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
1512    }
1513
1514    #[test]
1515    fn rustc_emit_metadata_is_cacheable() {
1516        // cargo check uses --emit=metadata
1517        let result = parse_invocation(
1518            "rustc",
1519            &args(&["--crate-type", "lib", "--emit=metadata", "src/lib.rs"]),
1520        );
1521        assert!(matches!(result, ParsedInvocation::Cacheable(_)));
1522    }
1523
1524    #[test]
1525    fn rustc_output_with_explicit_o() {
1526        let result = parse_invocation(
1527            "rustc",
1528            &args(&["--crate-type", "lib", "src/lib.rs", "-o", "libfoo.rlib"]),
1529        );
1530        match result {
1531            ParsedInvocation::Cacheable(c) => {
1532                assert_eq!(c.output_file, NormalizedPath::new("libfoo.rlib"));
1533            }
1534            other => panic!("expected cacheable, got: {other:?}"),
1535        }
1536    }
1537
1538    #[test]
1539    fn rustc_metadata_only_output_is_rmeta() {
1540        // cargo check: --emit=dep-info,metadata (no link) → primary output is .rmeta
1541        let result = parse_invocation(
1542            "rustc",
1543            &args(&[
1544                "--crate-type",
1545                "lib",
1546                "--crate-name",
1547                "mylib",
1548                "--emit=dep-info,metadata",
1549                "--out-dir",
1550                "/target/debug/deps",
1551                "-C",
1552                "extra-filename=-abc123",
1553                "src/lib.rs",
1554            ]),
1555        );
1556        match result {
1557            ParsedInvocation::Cacheable(c) => {
1558                assert_eq!(
1559                    c.output_file,
1560                    NormalizedPath::new("/target/debug/deps/libmylib-abc123.rmeta")
1561                );
1562            }
1563            other => panic!("expected cacheable, got: {other:?}"),
1564        }
1565    }
1566
1567    #[test]
1568    fn rustc_output_from_out_dir() {
1569        let result = parse_invocation(
1570            "rustc",
1571            &args(&[
1572                "--crate-type",
1573                "lib",
1574                "--crate-name",
1575                "mylib",
1576                "--out-dir",
1577                "/target/debug/deps",
1578                "-C",
1579                "extra-filename=-abc123",
1580                "src/lib.rs",
1581            ]),
1582        );
1583        match result {
1584            ParsedInvocation::Cacheable(c) => {
1585                assert_eq!(
1586                    c.output_file,
1587                    NormalizedPath::new("/target/debug/deps/libmylib-abc123.rlib")
1588                );
1589            }
1590            other => panic!("expected cacheable, got: {other:?}"),
1591        }
1592    }
1593
1594    #[test]
1595    fn rustc_full_cargo_invocation_cacheable() {
1596        // Realistic cargo-generated rustc command
1597        let result = parse_invocation(
1598            "rustc",
1599            &args(&[
1600                "--edition",
1601                "2021",
1602                "--crate-type",
1603                "lib",
1604                "--crate-name",
1605                "serde",
1606                "--emit=dep-info,metadata,link",
1607                "-C",
1608                "opt-level=2",
1609                "-C",
1610                "metadata=abc123def",
1611                "-C",
1612                "extra-filename=-abc123def",
1613                "--out-dir",
1614                "/target/release/deps",
1615                "-L",
1616                "dependency=/target/release/deps",
1617                "--extern",
1618                "serde_derive=/target/release/deps/libserde_derive-xyz.so",
1619                "--cap-lints",
1620                "allow",
1621                "--cfg",
1622                "feature=\"derive\"",
1623                "--cfg",
1624                "feature=\"std\"",
1625                "src/lib.rs",
1626            ]),
1627        );
1628        match result {
1629            ParsedInvocation::Cacheable(c) => {
1630                assert_eq!(c.family, CompilerFamily::Rustc);
1631                assert_eq!(c.source_file, NormalizedPath::new("src/lib.rs"));
1632                assert_eq!(
1633                    c.output_file,
1634                    NormalizedPath::new("/target/release/deps/libserde-abc123def.rlib")
1635                );
1636            }
1637            other => panic!("expected cacheable, got: {other:?}"),
1638        }
1639    }
1640
1641    #[test]
1642    fn rustc_original_args_preserved() {
1643        let input = args(&["--edition", "2021", "--crate-type", "lib", "src/lib.rs"]);
1644        let result = parse_invocation("rustc", &input);
1645        match result {
1646            ParsedInvocation::Cacheable(c) => {
1647                assert_eq!(*c.original_args, *input);
1648            }
1649            other => panic!("expected cacheable, got: {other:?}"),
1650        }
1651    }
1652
1653    #[test]
1654    fn rustc_equal_form_crate_type() {
1655        let result = parse_invocation("rustc", &args(&["--crate-type=lib", "src/lib.rs"]));
1656        assert!(matches!(result, ParsedInvocation::Cacheable(_)));
1657    }
1658
1659    #[test]
1660    fn rustc_concatenated_c_incremental_is_cacheable() {
1661        // -Cincremental= form (no space after -C) — still cacheable
1662        let result = parse_invocation(
1663            "rustc",
1664            &args(&["--crate-type", "lib", "-Cincremental=/tmp", "src/lib.rs"]),
1665        );
1666        assert!(matches!(result, ParsedInvocation::Cacheable(_)));
1667    }
1668
1669    #[test]
1670    fn rustc_comma_separated_crate_type_all_cacheable() {
1671        let result = parse_invocation("rustc", &args(&["--crate-type", "lib,rlib", "src/lib.rs"]));
1672        assert!(matches!(result, ParsedInvocation::Cacheable(_)));
1673    }
1674
1675    #[test]
1676    fn rustc_comma_separated_crate_type_mixed_non_cacheable() {
1677        // lib is cacheable but dylib is not
1678        let result = parse_invocation("rustc", &args(&["--crate-type", "lib,dylib", "src/lib.rs"]));
1679        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
1680    }
1681
1682    #[test]
1683    fn rustc_comma_separated_crate_type_equals_form() {
1684        let result = parse_invocation(
1685            "rustc",
1686            &args(&["--crate-type=lib,staticlib", "src/lib.rs"]),
1687        );
1688        assert!(matches!(result, ParsedInvocation::Cacheable(_)));
1689    }
1690
1691    #[test]
1692    fn rustc_test_flag_makes_non_cacheable() {
1693        // --test compiles a test harness (implicitly bin, not cacheable)
1694        let result = parse_invocation(
1695            "rustc",
1696            &args(&["--crate-type", "lib", "--test", "src/lib.rs"]),
1697        );
1698        // --test gets captured as unknown_flag. Since --crate-type lib is specified
1699        // the compilation IS cacheable. The --test flag is in unknown_flags which
1700        // is part of the cache key, so different --test values produce different keys.
1701        // This is correct: `--test` with `--crate-type lib` is a valid cacheable invocation.
1702        assert!(matches!(result, ParsedInvocation::Cacheable(_)));
1703    }
1704
1705    // ─── clippy-driver detection and caching tests ──────────────────
1706
1707    #[test]
1708    fn detect_clippy_driver_family() {
1709        assert_eq!(detect_family("clippy-driver"), CompilerFamily::Rustc);
1710        assert_eq!(
1711            detect_family(
1712                "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/clippy-driver"
1713            ),
1714            CompilerFamily::Rustc
1715        );
1716        assert_eq!(
1717            detect_family("C:\\Users\\user\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\\bin\\clippy-driver.exe"),
1718            CompilerFamily::Rustc
1719        );
1720    }
1721
1722    #[test]
1723    fn detect_clippy_driver_versioned() {
1724        // Versioned clippy-driver (e.g., from rustup with custom toolchains)
1725        assert_eq!(detect_family("clippy-driver-1.78"), CompilerFamily::Rustc);
1726    }
1727
1728    #[test]
1729    fn clippy_driver_cacheable_lib() {
1730        // cargo clippy invokes: clippy-driver --crate-type lib --crate-name foo src/lib.rs ...
1731        let result = parse_invocation(
1732            "clippy-driver",
1733            &args(&[
1734                "--crate-name",
1735                "mycrate",
1736                "--crate-type",
1737                "lib",
1738                "--emit=metadata,dep-info",
1739                "--out-dir",
1740                "target/debug/deps",
1741                "-C",
1742                "extra-filename=-abc123",
1743                "src/lib.rs",
1744            ]),
1745        );
1746        match result {
1747            ParsedInvocation::Cacheable(c) => {
1748                assert_eq!(c.family, CompilerFamily::Rustc);
1749                assert_eq!(c.source_file, NormalizedPath::new("src/lib.rs"));
1750                // metadata-only emit → .rmeta extension
1751                assert!(c.output_file.to_str().unwrap().ends_with(".rmeta"));
1752            }
1753            other => panic!("expected cacheable, got: {other:?}"),
1754        }
1755    }
1756
1757    #[test]
1758    fn clippy_driver_non_cacheable_bin() {
1759        // Binary crate type is not cacheable (same as rustc)
1760        let result = parse_invocation(
1761            "clippy-driver",
1762            &args(&[
1763                "--crate-name",
1764                "mybin",
1765                "--crate-type",
1766                "bin",
1767                "src/main.rs",
1768            ]),
1769        );
1770        assert!(matches!(result, ParsedInvocation::NonCacheable { .. }));
1771    }
1772
1773    #[test]
1774    fn clippy_driver_with_lint_flags() {
1775        // clippy-specific lint flags are standard rustc -W/-A/-D flags
1776        let result = parse_invocation(
1777            "clippy-driver",
1778            &args(&[
1779                "--crate-name",
1780                "mycrate",
1781                "--crate-type",
1782                "lib",
1783                "-W",
1784                "clippy::all",
1785                "-D",
1786                "clippy::unwrap_used",
1787                "-A",
1788                "clippy::too_many_arguments",
1789                "src/lib.rs",
1790            ]),
1791        );
1792        assert!(matches!(result, ParsedInvocation::Cacheable(_)));
1793    }
1794
1795    // ─── C++20 Module support tests ─────────────────────────────────
1796
1797    // Group A: Source extension recognition (.cppm, .ixx)
1798
1799    #[test]
1800    fn cppm_extension_is_cacheable() {
1801        let result = parse_invocation("clang++", &args(&["-c", "module.cppm", "-o", "module.pcm"]));
1802        match result {
1803            ParsedInvocation::Cacheable(c) => {
1804                assert_eq!(c.source_file, NormalizedPath::new("module.cppm"));
1805                assert_eq!(c.output_file, NormalizedPath::new("module.pcm"));
1806            }
1807            other => panic!("expected cacheable, got: {other:?}"),
1808        }
1809    }
1810
1811    #[test]
1812    fn ixx_extension_is_cacheable() {
1813        let result = parse_invocation("g++", &args(&["-c", "module.ixx", "-o", "module.o"]));
1814        match result {
1815            ParsedInvocation::Cacheable(c) => {
1816                assert_eq!(c.source_file, NormalizedPath::new("module.ixx"));
1817                assert_eq!(c.output_file, NormalizedPath::new("module.o"));
1818            }
1819            other => panic!("expected cacheable, got: {other:?}"),
1820        }
1821    }
1822
1823    #[test]
1824    fn cppm_default_output_with_precompile_is_pcm() {
1825        // --precompile without -o should produce stem.pcm
1826        let result = parse_invocation("clang++", &args(&["--precompile", "module.cppm"]));
1827        match result {
1828            ParsedInvocation::Cacheable(c) => {
1829                assert_eq!(c.source_file, NormalizedPath::new("module.cppm"));
1830                assert_eq!(c.output_file, NormalizedPath::new("module.pcm"));
1831            }
1832            other => panic!("expected cacheable, got: {other:?}"),
1833        }
1834    }
1835
1836    #[test]
1837    fn cppm_default_output_with_c_flag_is_object() {
1838        // -c on a .cppm without -o should produce stem.o (normal object)
1839        let result = parse_invocation("clang++", &args(&["-c", "module.cppm"]));
1840        match result {
1841            ParsedInvocation::Cacheable(c) => {
1842                assert_eq!(c.source_file, NormalizedPath::new("module.cppm"));
1843                assert_eq!(c.output_file, NormalizedPath::new("module.o"));
1844            }
1845            other => panic!("expected cacheable, got: {other:?}"),
1846        }
1847    }
1848
1849    #[test]
1850    fn cppm_multi_file() {
1851        let result = parse_invocation("clang++", &args(&["-c", "a.cppm", "b.cppm"]));
1852        match result {
1853            ParsedInvocation::MultiFile { compilations, .. } => {
1854                assert_eq!(compilations.len(), 2);
1855                assert_eq!(compilations[0].source_file, NormalizedPath::new("a.cppm"));
1856                assert_eq!(compilations[1].source_file, NormalizedPath::new("b.cppm"));
1857            }
1858            other => panic!("expected MultiFile, got: {other:?}"),
1859        }
1860    }
1861
1862    // Group B: -x c++-module language mode
1863
1864    #[test]
1865    fn x_cpp_module_with_precompile_is_cacheable() {
1866        let result = parse_invocation(
1867            "clang++",
1868            &args(&[
1869                "-x",
1870                "c++-module",
1871                "--precompile",
1872                "interface.cpp",
1873                "-o",
1874                "interface.pcm",
1875            ]),
1876        );
1877        match result {
1878            ParsedInvocation::Cacheable(c) => {
1879                assert_eq!(c.source_file, NormalizedPath::new("interface.cpp"));
1880                assert_eq!(c.output_file, NormalizedPath::new("interface.pcm"));
1881            }
1882            other => panic!("expected cacheable, got: {other:?}"),
1883        }
1884    }
1885
1886    #[test]
1887    fn x_cpp_module_with_c_flag_is_cacheable() {
1888        let result = parse_invocation(
1889            "clang++",
1890            &args(&[
1891                "-x",
1892                "c++-module",
1893                "-c",
1894                "interface.cpp",
1895                "-o",
1896                "interface.o",
1897            ]),
1898        );
1899        match result {
1900            ParsedInvocation::Cacheable(c) => {
1901                assert_eq!(c.source_file, NormalizedPath::new("interface.cpp"));
1902                assert_eq!(c.output_file, NormalizedPath::new("interface.o"));
1903            }
1904            other => panic!("expected cacheable, got: {other:?}"),
1905        }
1906    }
1907
1908    #[test]
1909    fn x_cpp_module_without_c_or_precompile_is_non_cacheable() {
1910        // Module mode alone does NOT imply compilation (unlike header mode).
1911        // Without -c or --precompile, this is a link invocation.
1912        let result = parse_invocation(
1913            "clang++",
1914            &args(&["-x", "c++-module", "interface.cpp", "-o", "interface"]),
1915        );
1916        assert!(
1917            matches!(result, ParsedInvocation::NonCacheable { .. }),
1918            "-x c++-module without -c or --precompile should be non-cacheable, got: {result:?}"
1919        );
1920    }
1921
1922    #[test]
1923    fn x_cpp_module_accepts_non_source_extension() {
1924        // -x c++-module should allow any positional arg as a source file
1925        // (same behavior as -x c++-header with non-standard extensions).
1926        let result = parse_invocation(
1927            "clang++",
1928            &args(&["-x", "c++-module", "--precompile", "interface.mpp"]),
1929        );
1930        match result {
1931            ParsedInvocation::Cacheable(c) => {
1932                assert_eq!(c.source_file, NormalizedPath::new("interface.mpp"));
1933            }
1934            other => panic!("expected cacheable, got: {other:?}"),
1935        }
1936    }
1937
1938    #[test]
1939    fn x_cpp_module_default_output_precompile() {
1940        // --precompile without -o → stem.pcm
1941        let result = parse_invocation(
1942            "clang++",
1943            &args(&["-x", "c++-module", "--precompile", "interface.cpp"]),
1944        );
1945        match result {
1946            ParsedInvocation::Cacheable(c) => {
1947                assert_eq!(c.output_file, NormalizedPath::new("interface.pcm"));
1948            }
1949            other => panic!("expected cacheable, got: {other:?}"),
1950        }
1951    }
1952
1953    #[test]
1954    fn x_cpp_module_default_output_c_flag() {
1955        // -c without -o → stem.o (even in module mode)
1956        let result = parse_invocation(
1957            "clang++",
1958            &args(&["-x", "c++-module", "-c", "interface.cpp"]),
1959        );
1960        match result {
1961            ParsedInvocation::Cacheable(c) => {
1962                assert_eq!(c.output_file, NormalizedPath::new("interface.o"));
1963            }
1964            other => panic!("expected cacheable, got: {other:?}"),
1965        }
1966    }
1967
1968    #[test]
1969    fn x_cpp_module_reset_by_x_cpp() {
1970        // -x c++ resets module mode, just like it resets header mode.
1971        let result = parse_invocation(
1972            "clang++",
1973            &args(&[
1974                "-x",
1975                "c++-module",
1976                "--precompile",
1977                "interface.mpp",
1978                "-x",
1979                "c++",
1980                "-c",
1981                "main.cpp",
1982            ]),
1983        );
1984        match result {
1985            ParsedInvocation::MultiFile { compilations, .. } => {
1986                assert_eq!(compilations.len(), 2);
1987                assert_eq!(
1988                    compilations[0].source_file,
1989                    NormalizedPath::new("interface.mpp")
1990                );
1991                assert_eq!(compilations[1].source_file, NormalizedPath::new("main.cpp"));
1992            }
1993            other => panic!("expected MultiFile, got: {other:?}"),
1994        }
1995    }
1996
1997    #[test]
1998    fn x_cpp_module_implies_compilation_with_precompile() {
1999        // --precompile without -c is still a cacheable compilation.
2000        let result = parse_invocation(
2001            "clang++",
2002            &args(&[
2003                "-x",
2004                "c++-module",
2005                "--precompile",
2006                "interface.cpp",
2007                "-o",
2008                "interface.pcm",
2009            ]),
2010        );
2011        match result {
2012            ParsedInvocation::Cacheable(c) => {
2013                assert_eq!(c.source_file, NormalizedPath::new("interface.cpp"));
2014                assert_eq!(c.output_file, NormalizedPath::new("interface.pcm"));
2015            }
2016            other => panic!("expected cacheable, got: {other:?}"),
2017        }
2018    }
2019
2020    // Group C: Header units (-x c++-header-unit / -x c-header-unit)
2021
2022    #[test]
2023    fn x_cpp_header_unit_with_precompile_is_cacheable() {
2024        let result = parse_invocation(
2025            "clang++",
2026            &args(&[
2027                "-x",
2028                "c++-header-unit",
2029                "--precompile",
2030                "foo.h",
2031                "-o",
2032                "foo.pcm",
2033            ]),
2034        );
2035        match result {
2036            ParsedInvocation::Cacheable(c) => {
2037                assert_eq!(c.source_file, NormalizedPath::new("foo.h"));
2038                assert_eq!(c.output_file, NormalizedPath::new("foo.pcm"));
2039            }
2040            other => panic!("expected cacheable, got: {other:?}"),
2041        }
2042    }
2043
2044    #[test]
2045    fn x_c_header_unit_with_c_flag_is_cacheable() {
2046        let result = parse_invocation(
2047            "gcc",
2048            &args(&["-x", "c-header-unit", "-c", "foo.h", "-o", "foo.pcm"]),
2049        );
2050        match result {
2051            ParsedInvocation::Cacheable(c) => {
2052                assert_eq!(c.source_file, NormalizedPath::new("foo.h"));
2053                assert_eq!(c.output_file, NormalizedPath::new("foo.pcm"));
2054            }
2055            other => panic!("expected cacheable, got: {other:?}"),
2056        }
2057    }
2058
2059    #[test]
2060    fn x_cpp_header_unit_default_output_is_pcm() {
2061        // Header unit without -o → filename.pcm
2062        let result = parse_invocation(
2063            "clang++",
2064            &args(&["-x", "c++-header-unit", "--precompile", "foo.h"]),
2065        );
2066        match result {
2067            ParsedInvocation::Cacheable(c) => {
2068                assert_eq!(c.output_file, NormalizedPath::new("foo.h.pcm"));
2069            }
2070            other => panic!("expected cacheable, got: {other:?}"),
2071        }
2072    }
2073
2074    #[test]
2075    fn x_cpp_header_unit_implies_compilation() {
2076        // Header-unit mode implies compilation (no -c needed), like header mode.
2077        let result = parse_invocation(
2078            "clang++",
2079            &args(&["-x", "c++-header-unit", "foo.h", "-o", "foo.pcm"]),
2080        );
2081        match result {
2082            ParsedInvocation::Cacheable(c) => {
2083                assert_eq!(c.source_file, NormalizedPath::new("foo.h"));
2084                assert_eq!(c.output_file, NormalizedPath::new("foo.pcm"));
2085            }
2086            other => panic!("expected cacheable, got: {other:?}"),
2087        }
2088    }
2089
2090    // Group D: --precompile flag handling
2091
2092    #[test]
2093    fn precompile_on_normal_cpp_is_cacheable() {
2094        // --precompile on a .cpp (with export module inside) is valid.
2095        let result = parse_invocation("clang++", &args(&["--precompile", "foo.cpp"]));
2096        match result {
2097            ParsedInvocation::Cacheable(c) => {
2098                assert_eq!(c.source_file, NormalizedPath::new("foo.cpp"));
2099                assert_eq!(c.output_file, NormalizedPath::new("foo.pcm"));
2100            }
2101            other => panic!("expected cacheable, got: {other:?}"),
2102        }
2103    }
2104
2105    #[test]
2106    fn precompile_without_source_is_non_cacheable() {
2107        let result = parse_invocation("clang++", &args(&["--precompile", "-O2"]));
2108        assert!(
2109            matches!(result, ParsedInvocation::NonCacheable { .. }),
2110            "--precompile without source should be non-cacheable, got: {result:?}"
2111        );
2112    }
2113
2114    #[test]
2115    fn precompile_and_c_flag_together() {
2116        // Both --precompile and -c can coexist. --precompile takes precedence
2117        // for default output (produces .pcm, not .o).
2118        let result = parse_invocation(
2119            "clang++",
2120            &args(&["--precompile", "-c", "module.cppm", "-o", "module.pcm"]),
2121        );
2122        match result {
2123            ParsedInvocation::Cacheable(c) => {
2124                assert_eq!(c.source_file, NormalizedPath::new("module.cppm"));
2125                assert_eq!(c.output_file, NormalizedPath::new("module.pcm"));
2126            }
2127            other => panic!("expected cacheable, got: {other:?}"),
2128        }
2129    }
2130
2131    // Group E: GCC -fmodules-ts interaction
2132
2133    #[test]
2134    fn gcc_fmodules_ts_with_cppm_is_cacheable() {
2135        // -fmodules-ts falls through to unknown_flags, which is fine.
2136        let result = parse_invocation(
2137            "g++",
2138            &args(&["-fmodules-ts", "-c", "module.cppm", "-o", "module.o"]),
2139        );
2140        match result {
2141            ParsedInvocation::Cacheable(c) => {
2142                assert_eq!(c.source_file, NormalizedPath::new("module.cppm"));
2143                assert_eq!(c.output_file, NormalizedPath::new("module.o"));
2144                assert!(c.unknown_flags.contains(&"-fmodules-ts".to_string()));
2145            }
2146            other => panic!("expected cacheable, got: {other:?}"),
2147        }
2148    }
2149
2150    #[test]
2151    fn gcc_fmodules_ts_with_x_module_precompile() {
2152        let result = parse_invocation(
2153            "g++",
2154            &args(&[
2155                "-fmodules-ts",
2156                "-x",
2157                "c++-module",
2158                "--precompile",
2159                "interface.cpp",
2160            ]),
2161        );
2162        match result {
2163            ParsedInvocation::Cacheable(c) => {
2164                assert_eq!(c.source_file, NormalizedPath::new("interface.cpp"));
2165                assert_eq!(c.output_file, NormalizedPath::new("interface.pcm"));
2166            }
2167            other => panic!("expected cacheable, got: {other:?}"),
2168        }
2169    }
2170}