Skip to main content

cargo/compiler/fingerprint/
dirty_reason.rs

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