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