1use std::fmt;
2use std::fmt::Debug;
3
4use serde::Serialize;
5
6use super::*;
7use crate::core::Shell;
8
9#[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 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 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 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 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#[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}