#
cargo/core/compiler/fingerprint/
dirty_reason.rs

    1use std::fmt;
    2use std::fmt::Debug;
    3
    4use serde::Serialize;
    5
    6use super::*;
    7use crate::core::Shell;
    8
    9/// Tells a better story of why a build is considered "dirty" that leads
    10/// to a recompile. Usually constructed via [`Fingerprint::compare`].
    11///
    12/// [`Fingerprint::compare`]: super::Fingerprint::compare
    13#[derive(Clone, Debug, Serialize)]
    14#[serde(tag = "dirty_reason", rename_all = "kebab-case")]
    15pub enum DirtyReason {
    16    RustcChanged,
    17    FeaturesChanged {
    18        old: String,
    19        new: String,
    20    },
    21    DeclaredFeaturesChanged {
    22        old: String,
    23        new: String,
    24    },
    25    TargetConfigurationChanged,
    26    PathToSourceChanged,
    27    ProfileConfigurationChanged,
    28    RustflagsChanged {
    29        old: Vec<String>,
    30        new: Vec<String>,
    31    },
    32    ConfigSettingsChanged,
    33    CompileKindChanged,
    34    LocalLengthsChanged,
    35    PrecalculatedComponentsChanged {
    36        old: String,
    37        new: String,
    38    },
    39    ChecksumUseChanged {
    40        old: bool,
    41    },
    42    DepInfoOutputChanged {
    43        old: PathBuf,
    44        new: PathBuf,
    45    },
    46    RerunIfChangedOutputFileChanged {
    47        old: PathBuf,
    48        new: PathBuf,
    49    },
    50    RerunIfChangedOutputPathsChanged {
    51        old: Vec<PathBuf>,
    52        new: Vec<PathBuf>,
    53    },
    54    EnvVarsChanged {
    55        old: String,
    56        new: String,
    57    },
    58    EnvVarChanged {
    59        name: String,
    60        old_value: Option<String>,
    61        new_value: Option<String>,
    62    },
    63    LocalFingerprintTypeChanged {
    64        old: &'static str,
    65        new: &'static str,
    66    },
    67    NumberOfDependenciesChanged {
    68        old: usize,
    69        new: usize,
    70    },
    71    UnitDependencyNameChanged {
    72        old: InternedString,
    73        new: InternedString,
    74    },
    75    UnitDependencyInfoChanged {
    76        old_name: InternedString,
    77        old_fingerprint: u64,
    78
    79        new_name: InternedString,
    80        new_fingerprint: u64,
    81    },
    82    FsStatusOutdated(FsStatus),
    83    NothingObvious,
    84    Forced,
    85    /// First time to build something.
    86    FreshBuild,
    87}
    88
    89trait ShellExt {
    90    fn dirty_because(&mut self, unit: &Unit, s: impl fmt::Display) -> CargoResult<()>;
    91}
    92
    93impl ShellExt for Shell {
    94    fn dirty_because(&mut self, unit: &Unit, s: impl fmt::Display) -> CargoResult<()> {
    95        self.status("Dirty", format_args!("{}: {s}", &unit.pkg))
    96    }
    97}
    98
    99struct FileTimeDiff {
    100    old_time: FileTime,
    101    new_time: FileTime,
    102}
    103
    104impl fmt::Display for FileTimeDiff {
    105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    106        let s_diff = self.new_time.seconds() - self.old_time.seconds();
    107        if s_diff >= 1 {
    108            write!(f, "{:#}", jiff::SignedDuration::from_secs(s_diff))
    109        } else {
    110            // format nanoseconds as it is, jiff would display ms, us and ns
    111            let ns_diff = self.new_time.nanoseconds() - self.old_time.nanoseconds();
    112            write!(f, "{ns_diff}ns")
    113        }
    114    }
    115}
    116
    117#[derive(Copy, Clone)]
    118struct After {
    119    old_time: FileTime,
    120    new_time: FileTime,
    121    what: &'static str,
    122}
    123
    124impl fmt::Display for After {
    125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    126        let Self {
    127            old_time,
    128            new_time,
    129            what,
    130        } = *self;
    131        let diff = FileTimeDiff { old_time, new_time };
    132
    133        write!(f, "{new_time}, {diff} after {what} at {old_time}")
    134    }
    135}
    136
    137impl DirtyReason {
    138    /// Whether a build is dirty because it is a fresh build being kicked off.
    139    pub fn is_fresh_build(&self) -> bool {
    140        matches!(self, DirtyReason::FreshBuild)
    141    }
    142
    143    fn after(old_time: FileTime, new_time: FileTime, what: &'static str) -> After {
    144        After {
    145            old_time,
    146            new_time,
    147            what,
    148        }
    149    }
    150
    151    pub fn present_to(&self, s: &mut Shell, unit: &Unit, root: &Path) -> CargoResult<()> {
    152        match self {
    153            DirtyReason::RustcChanged => s.dirty_because(unit, "the toolchain changed"),
    154            DirtyReason::FeaturesChanged { .. } => {
    155                s.dirty_because(unit, "the list of features changed")
    156            }
    157            DirtyReason::DeclaredFeaturesChanged { .. } => {
    158                s.dirty_because(unit, "the list of declared features changed")
    159            }
    160            DirtyReason::TargetConfigurationChanged => {
    161                s.dirty_because(unit, "the target configuration changed")
    162            }
    163            DirtyReason::PathToSourceChanged => {
    164                s.dirty_because(unit, "the path to the source changed")
    165            }
    166            DirtyReason::ProfileConfigurationChanged => {
    167                s.dirty_because(unit, "the profile configuration changed")
    168            }
    169            DirtyReason::RustflagsChanged { .. } => s.dirty_because(unit, "the rustflags changed"),
    170            DirtyReason::ConfigSettingsChanged => {
    171                s.dirty_because(unit, "the config settings changed")
    172            }
    173            DirtyReason::CompileKindChanged => {
    174                s.dirty_because(unit, "the rustc compile kind changed")
    175            }
    176            DirtyReason::LocalLengthsChanged => {
    177                s.dirty_because(unit, "the local lengths changed")?;
    178                s.note(
    179                    "this could happen because of added/removed `cargo::rerun-if` instructions in the build script",
    180                )?;
    181
    182                Ok(())
    183            }
    184            DirtyReason::PrecalculatedComponentsChanged { .. } => {
    185                s.dirty_because(unit, "the precalculated components changed")
    186            }
    187            DirtyReason::ChecksumUseChanged { old } => {
    188                if *old {
    189                    s.dirty_because(
    190                        unit,
    191                        "the prior compilation used checksum freshness and this one does not",
    192                    )
    193                } else {
    194                    s.dirty_because(unit, "checksum freshness requested, prior compilation did not use checksum freshness")
    195                }
    196            }
    197            DirtyReason::DepInfoOutputChanged { .. } => {
    198                s.dirty_because(unit, "the dependency info output changed")
    199            }
    200            DirtyReason::RerunIfChangedOutputFileChanged { .. } => {
    201                s.dirty_because(unit, "rerun-if-changed output file path changed")
    202            }
    203            DirtyReason::RerunIfChangedOutputPathsChanged { .. } => {
    204                s.dirty_because(unit, "the rerun-if-changed instructions changed")
    205            }
    206            DirtyReason::EnvVarsChanged { .. } => {
    207                s.dirty_because(unit, "the environment variables changed")
    208            }
    209            DirtyReason::EnvVarChanged { name, .. } => {
    210                s.dirty_because(unit, format_args!("the env variable {name} changed"))
    211            }
    212            DirtyReason::LocalFingerprintTypeChanged { .. } => {
    213                s.dirty_because(unit, "the local fingerprint type changed")
    214            }
    215            DirtyReason::NumberOfDependenciesChanged { old, new } => s.dirty_because(
    216                unit,
    217                format_args!("number of dependencies changed ({old} => {new})",),
    218            ),
    219            DirtyReason::UnitDependencyNameChanged { old, new } => s.dirty_because(
    220                unit,
    221                format_args!("name of dependency changed ({old} => {new})"),
    222            ),
    223            DirtyReason::UnitDependencyInfoChanged { .. } => {
    224                s.dirty_because(unit, "dependency info changed")
    225            }
    226            DirtyReason::FsStatusOutdated(status) => match status {
    227                FsStatus::Stale => s.dirty_because(unit, "stale, unknown reason"),
    228                FsStatus::StaleItem(item) => match item {
    229                    StaleItem::MissingFile { path } => {
    230                        let file = path.strip_prefix(root).unwrap_or(&path);
    231                        s.dirty_because(
    232                            unit,
    233                            format_args!("the file `{}` is missing", file.display()),
    234                        )
    235                    }
    236                    StaleItem::UnableToReadFile { path } => {
    237                        let file = path.strip_prefix(root).unwrap_or(&path);
    238                        s.dirty_because(
    239                            unit,
    240                            format_args!("the file `{}` could not be read", file.display()),
    241                        )
    242                    }
    243                    StaleItem::FailedToReadMetadata { path } => {
    244                        let file = path.strip_prefix(root).unwrap_or(&path);
    245                        s.dirty_because(
    246                            unit,
    247                            format_args!("couldn't read metadata for file `{}`", file.display()),
    248                        )
    249                    }
    250                    StaleItem::ChangedFile {
    251                        stale,
    252                        stale_mtime,
    253                        reference_mtime,
    254                        ..
    255                    } => {
    256                        let file = stale.strip_prefix(root).unwrap_or(&stale);
    257                        let after = Self::after(*reference_mtime, *stale_mtime, "last build");
    258                        s.dirty_because(
    259                            unit,
    260                            format_args!("the file `{}` has changed ({after})", file.display()),
    261                        )
    262                    }
    263                    StaleItem::ChangedChecksum {
    264                        source,
    265                        stored_checksum,
    266                        new_checksum,
    267                    } => {
    268                        let file = source.strip_prefix(root).unwrap_or(&source);
    269                        s.dirty_because(
    270                            unit,
    271                            format_args!(
    272                                "the file `{}` has changed (checksum didn't match, {stored_checksum} != {new_checksum})",
    273                                file.display(),
    274                            ),
    275                        )
    276                    }
    277                    StaleItem::FileSizeChanged {
    278                        path,
    279                        old_size,
    280                        new_size,
    281                    } => {
    282                        let file = path.strip_prefix(root).unwrap_or(&path);
    283                        s.dirty_because(
    284                            unit,
    285                            format_args!(
    286                                "file size changed ({old_size} != {new_size}) for `{}`",
    287                                file.display()
    288                            ),
    289                        )
    290                    }
    291                    StaleItem::MissingChecksum { path } => {
    292                        let file = path.strip_prefix(root).unwrap_or(&path);
    293                        s.dirty_because(
    294                            unit,
    295                            format_args!("the checksum for file `{}` is missing", file.display()),
    296                        )
    297                    }
    298                    StaleItem::ChangedEnv { var, .. } => s.dirty_because(
    299                        unit,
    300                        format_args!("the environment variable {var} changed"),
    301                    ),
    302                },
    303                FsStatus::StaleDependency {
    304                    name,
    305                    dep_mtime,
    306                    max_mtime,
    307                    ..
    308                } => {
    309                    let after = Self::after(*max_mtime, *dep_mtime, "last build");
    310                    s.dirty_because(
    311                        unit,
    312                        format_args!("the dependency {name} was rebuilt ({after})"),
    313                    )
    314                }
    315                FsStatus::StaleDepFingerprint { name } => {
    316                    s.dirty_because(unit, format_args!("the dependency {name} was rebuilt"))
    317                }
    318                FsStatus::UpToDate { .. } => {
    319                    unreachable!()
    320                }
    321            },
    322            DirtyReason::NothingObvious => {
    323                // See comment in fingerprint compare method.
    324                s.dirty_because(unit, "the fingerprint comparison turned up nothing obvious")
    325            }
    326            DirtyReason::Forced => s.dirty_because(unit, "forced"),
    327            DirtyReason::FreshBuild => s.dirty_because(unit, "fresh build"),
    328        }
    329    }
    330}
    331
    332// These test the actual JSON structure that will be logged.
    333// In the future we might decouple this from the actual log message schema.
    334#[cfg(test)]
    335mod json_schema {
    336    use super::*;
    337    use snapbox::IntoData;
    338    use snapbox::assert_data_eq;
    339    use snapbox::str;
    340
    341    fn to_json<T: Serialize>(value: &T) -> String {
    342        serde_json::to_string_pretty(value).unwrap()
    343    }
    344
    345    #[test]
    346    fn rustc_changed() {
    347        let reason = DirtyReason::RustcChanged;
    348        assert_data_eq!(
    349            to_json(&reason),
    350            str![[r#"
    351{
    352  "dirty_reason": "rustc-changed"
    353}
    354"#]]
    355            .is_json()
    356        );
    357    }
    358
    359    #[test]
    360    fn fresh_build() {
    361        let reason = DirtyReason::FreshBuild;
    362        assert_data_eq!(
    363            to_json(&reason),
    364            str![[r#"
    365{
    366  "dirty_reason": "fresh-build"
    367}
    368"#]]
    369            .is_json()
    370        );
    371    }
    372
    373    #[test]
    374    fn forced() {
    375        let reason = DirtyReason::Forced;
    376        assert_data_eq!(
    377            to_json(&reason),
    378            str![[r#"
    379{
    380  "dirty_reason": "forced"
    381}
    382"#]]
    383            .is_json()
    384        );
    385    }
    386
    387    #[test]
    388    fn nothing_obvious() {
    389        let reason = DirtyReason::NothingObvious;
    390        assert_data_eq!(
    391            to_json(&reason),
    392            str![[r#"
    393{
    394  "dirty_reason": "nothing-obvious"
    395}
    396"#]]
    397            .is_json()
    398        );
    399    }
    400
    401    #[test]
    402    fn features_changed() {
    403        let reason = DirtyReason::FeaturesChanged {
    404            old: "f1".to_string(),
    405            new: "f1,f2".to_string(),
    406        };
    407        assert_data_eq!(
    408            to_json(&reason),
    409            str![[r#"
    410{
    411  "dirty_reason": "features-changed",
    412  "new": "f1,f2",
    413  "old": "f1"
    414}
    415"#]]
    416            .is_json()
    417        );
    418    }
    419
    420    #[test]
    421    fn rustflags_changed() {
    422        let reason = DirtyReason::RustflagsChanged {
    423            old: vec!["-C".into(), "opt-level=2".into()],
    424            new: vec!["--cfg".into(), "tokio_unstable".into()],
    425        };
    426        assert_data_eq!(
    427            to_json(&reason),
    428            str![[r#"
    429{
    430  "dirty_reason": "rustflags-changed",
    431  "old": [
    432    "-C",
    433    "opt-level=2"
    434  ],
    435  "new": [
    436    "--cfg",
    437    "tokio_unstable"
    438  ]
    439}
    440"#]]
    441        );
    442    }
    443
    444    #[test]
    445    fn env_var_changed_both_some() {
    446        let reason = DirtyReason::EnvVarChanged {
    447            name: "VAR".into(),
    448            old_value: Some("old".into()),
    449            new_value: Some("new".into()),
    450        };
    451        assert_data_eq!(
    452            to_json(&reason),
    453            str![[r#"
    454{
    455  "dirty_reason": "env-var-changed",
    456  "name": "VAR",
    457  "new_value": "new",
    458  "old_value": "old"
    459}
    460"#]]
    461            .is_json()
    462        );
    463    }
    464
    465    #[test]
    466    fn env_var_changed_old_none() {
    467        let reason = DirtyReason::EnvVarChanged {
    468            name: "VAR".into(),
    469            old_value: None,
    470            new_value: Some("new".into()),
    471        };
    472        assert_data_eq!(
    473            to_json(&reason),
    474            str![[r#"
    475{
    476  "dirty_reason": "env-var-changed",
    477  "name": "VAR",
    478  "new_value": "new",
    479  "old_value": null
    480}
    481"#]]
    482            .is_json()
    483        );
    484    }
    485
    486    #[test]
    487    fn dep_info_output_changed() {
    488        let reason = DirtyReason::DepInfoOutputChanged {
    489            old: "target/debug/old.d".into(),
    490            new: "target/debug/new.d".into(),
    491        };
    492        assert_data_eq!(
    493            to_json(&reason),
    494            str![[r#"
    495{
    496  "dirty_reason": "dep-info-output-changed",
    497  "old": "target/debug/old.d",
    498  "new": "target/debug/new.d"
    499}
    500"#]]
    501            .is_json()
    502        );
    503    }
    504
    505    #[test]
    506    fn number_of_dependencies_changed() {
    507        let reason = DirtyReason::NumberOfDependenciesChanged { old: 5, new: 7 };
    508        assert_data_eq!(
    509            to_json(&reason),
    510            str![[r#"
    511{
    512  "dirty_reason": "number-of-dependencies-changed",
    513  "old": 5,
    514  "new": 7
    515}
    516"#]]
    517            .is_json()
    518        );
    519    }
    520
    521    #[test]
    522    fn unit_dependency_name_changed() {
    523        let reason = DirtyReason::UnitDependencyNameChanged {
    524            old: "old_dep".into(),
    525            new: "new_dep".into(),
    526        };
    527        assert_data_eq!(
    528            to_json(&reason),
    529            str![[r#"
    530{
    531  "dirty_reason": "unit-dependency-name-changed",
    532  "old": "old_dep",
    533  "new": "new_dep"
    534}
    535"#]]
    536            .is_json()
    537        );
    538    }
    539
    540    #[test]
    541    fn unit_dependency_info_changed() {
    542        let reason = DirtyReason::UnitDependencyInfoChanged {
    543            old_name: "serde".into(),
    544            old_fingerprint: 0x1234567890abcdef,
    545            new_name: "serde".into(),
    546            new_fingerprint: 0xfedcba0987654321,
    547        };
    548        assert_data_eq!(
    549            to_json(&reason),
    550            str![[r#"
    551{
    552  "dirty_reason": "unit-dependency-info-changed",
    553  "new_fingerprint": 18364757930599072545,
    554  "new_name": "serde",
    555  "old_fingerprint": 1311768467294899695,
    556  "old_name": "serde"
    557}
    558"#]]
    559            .is_json()
    560        );
    561    }
    562
    563    #[test]
    564    fn fs_status_stale() {
    565        let reason = DirtyReason::FsStatusOutdated(FsStatus::Stale);
    566        assert_data_eq!(
    567            to_json(&reason),
    568            str![[r#"
    569{
    570  "dirty_reason": "fs-status-outdated",
    571  "fs_status": "stale"
    572}
    573"#]]
    574            .is_json()
    575        );
    576    }
    577
    578    #[test]
    579    fn fs_status_missing_file() {
    580        let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleItem(StaleItem::MissingFile {
    581            path: "src/lib.rs".into(),
    582        }));
    583        assert_data_eq!(
    584            to_json(&reason),
    585            str![[r#"
    586{
    587  "dirty_reason": "fs-status-outdated",
    588  "fs_status": "stale-item",
    589  "path": "src/lib.rs",
    590  "stale_item": "missing-file"
    591}
    592"#]]
    593            .is_json()
    594        );
    595    }
    596
    597    #[test]
    598    fn fs_status_changed_file() {
    599        let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleItem(StaleItem::ChangedFile {
    600            reference: "target/debug/deps/libfoo-abc123.rmeta".into(),
    601            reference_mtime: FileTime::from_unix_time(1730567890, 123000000),
    602            stale: "src/lib.rs".into(),
    603            stale_mtime: FileTime::from_unix_time(1730567891, 456000000),
    604        }));
    605        assert_data_eq!(
    606            to_json(&reason),
    607            str![[r#"
    608{
    609  "dirty_reason": "fs-status-outdated",
    610  "fs_status": "stale-item",
    611  "reference": "target/debug/deps/libfoo-abc123.rmeta",
    612  "reference_mtime": 1730567890123.0,
    613  "stale": "src/lib.rs",
    614  "stale_item": "changed-file",
    615  "stale_mtime": 1730567891456.0
    616}
    617"#]]
    618            .is_json()
    619        );
    620    }
    621
    622    #[test]
    623    fn fs_status_changed_checksum() {
    624        use super::dep_info::ChecksumAlgo;
    625        let reason =
    626            DirtyReason::FsStatusOutdated(FsStatus::StaleItem(StaleItem::ChangedChecksum {
    627                source: "src/main.rs".into(),
    628                stored_checksum: Checksum::new(ChecksumAlgo::Sha256, [0xaa; 32]),
    629                new_checksum: Checksum::new(ChecksumAlgo::Sha256, [0xbb; 32]),
    630            }));
    631        assert_data_eq!(
    632            to_json(&reason),
    633            str![[r#"
    634{
    635  "dirty_reason": "fs-status-outdated",
    636  "fs_status": "stale-item",
    637  "new_checksum": "sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    638  "source": "src/main.rs",
    639  "stale_item": "changed-checksum",
    640  "stored_checksum": "sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    641}
    642"#]]
    643            .is_json()
    644        );
    645    }
    646
    647    #[test]
    648    fn fs_status_stale_dependency() {
    649        let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleDependency {
    650            name: "serde".into(),
    651            dep_mtime: FileTime::from_unix_time(1730567892, 789000000),
    652            max_mtime: FileTime::from_unix_time(1730567890, 123000000),
    653        });
    654        assert_data_eq!(
    655            to_json(&reason),
    656            str![[r#"
    657{
    658  "dep_mtime": 1730567892789.0,
    659  "dirty_reason": "fs-status-outdated",
    660  "fs_status": "stale-dependency",
    661  "max_mtime": 1730567890123.0,
    662  "name": "serde"
    663}
    664"#]]
    665            .is_json()
    666        );
    667    }
    668
    669    #[test]
    670    fn fs_status_stale_dep_fingerprint() {
    671        let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleDepFingerprint {
    672            name: "tokio".into(),
    673        });
    674        assert_data_eq!(
    675            to_json(&reason),
    676            str![[r#"
    677{
    678  "dirty_reason": "fs-status-outdated",
    679  "fs_status": "stale-dep-fingerprint",
    680  "name": "tokio"
    681}
    682"#]]
    683            .is_json()
    684        );
    685    }
    686
    687    #[test]
    688    fn fs_status_unable_to_read_file() {
    689        let reason =
    690            DirtyReason::FsStatusOutdated(FsStatus::StaleItem(StaleItem::UnableToReadFile {
    691                path: "src/lib.rs".into(),
    692            }));
    693        assert_data_eq!(
    694            to_json(&reason),
    695            str![[r#"
    696{
    697  "dirty_reason": "fs-status-outdated",
    698  "fs_status": "stale-item",
    699  "stale_item": "unable-to-read-file",
    700  "path": "src/lib.rs"
    701}
    702"#]]
    703        );
    704    }
    705
    706    #[test]
    707    fn fs_status_failed_to_read_metadata() {
    708        let reason =
    709            DirtyReason::FsStatusOutdated(FsStatus::StaleItem(StaleItem::FailedToReadMetadata {
    710                path: "src/lib.rs".into(),
    711            }));
    712        assert_data_eq!(
    713            to_json(&reason),
    714            str![[r#"
    715{
    716  "dirty_reason": "fs-status-outdated",
    717  "fs_status": "stale-item",
    718  "stale_item": "failed-to-read-metadata",
    719  "path": "src/lib.rs"
    720}
    721"#]]
    722        );
    723    }
    724
    725    #[test]
    726    fn fs_status_file_size_changed() {
    727        let reason =
    728            DirtyReason::FsStatusOutdated(FsStatus::StaleItem(StaleItem::FileSizeChanged {
    729                path: "src/lib.rs".into(),
    730                old_size: 1024,
    731                new_size: 2048,
    732            }));
    733        assert_data_eq!(
    734            to_json(&reason),
    735            str![[r#"
    736{
    737  "dirty_reason": "fs-status-outdated",
    738  "fs_status": "stale-item",
    739  "stale_item": "file-size-changed",
    740  "path": "src/lib.rs",
    741  "old_size": 1024,
    742  "new_size": 2048
    743}
    744"#]]
    745        );
    746    }
    747
    748    #[test]
    749    fn fs_status_missing_checksum() {
    750        let reason =
    751            DirtyReason::FsStatusOutdated(FsStatus::StaleItem(StaleItem::MissingChecksum {
    752                path: "src/lib.rs".into(),
    753            }));
    754        assert_data_eq!(
    755            to_json(&reason),
    756            str![[r#"
    757{
    758  "dirty_reason": "fs-status-outdated",
    759  "fs_status": "stale-item",
    760  "stale_item": "missing-checksum",
    761  "path": "src/lib.rs"
    762}
    763"#]]
    764        );
    765    }
    766
    767    #[test]
    768    fn fs_status_changed_env() {
    769        let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleItem(StaleItem::ChangedEnv {
    770            var: "VAR".into(),
    771            previous: Some("old".into()),
    772            current: Some("new".into()),
    773        }));
    774        assert_data_eq!(
    775            to_json(&reason),
    776            str![[r#"
    777{
    778  "dirty_reason": "fs-status-outdated",
    779  "fs_status": "stale-item",
    780  "stale_item": "changed-env",
    781  "var": "VAR",
    782  "previous": "old",
    783  "current": "new"
    784}
    785"#]]
    786        );
    787    }
    788
    789    #[test]
    790    fn checksum_use_changed() {
    791        let reason = DirtyReason::ChecksumUseChanged { old: false };
    792        assert_data_eq!(
    793            to_json(&reason),
    794            str![[r#"
    795{
    796  "dirty_reason": "checksum-use-changed",
    797  "old": false
    798}
    799"#]]
    800            .is_json()
    801        );
    802    }
    803
    804    #[test]
    805    fn rerun_if_changed_output_paths_changed() {
    806        let reason = DirtyReason::RerunIfChangedOutputPathsChanged {
    807            old: vec!["file1.txt".into(), "file2.txt".into()],
    808            new: vec!["file1.txt".into(), "file2.txt".into(), "file3.txt".into()],
    809        };
    810        assert_data_eq!(
    811            to_json(&reason),
    812            str![[r#"
    813{
    814  "dirty_reason": "rerun-if-changed-output-paths-changed",
    815  "old": [
    816    "file1.txt",
    817    "file2.txt"
    818  ],
    819  "new": [
    820    "file1.txt",
    821    "file2.txt",
    822    "file3.txt"
    823  ]
    824}
    825"#]]
    826            .is_json()
    827        );
    828    }
    829
    830    #[test]
    831    fn local_fingerprint_type_changed() {
    832        let reason = DirtyReason::LocalFingerprintTypeChanged {
    833            old: "precalculated",
    834            new: "rerun-if-changed",
    835        };
    836        assert_data_eq!(
    837            to_json(&reason),
    838            str![[r#"
    839{
    840  "dirty_reason": "local-fingerprint-type-changed",
    841  "new": "rerun-if-changed",
    842  "old": "precalculated"
    843}
    844"#]]
    845            .is_json()
    846        );
    847    }
    848}