Skip to main content

fallow_engine/baseline/
install_state.rs

1//! The install state that a dead-code baseline records.
2//!
3//! Findings that need installed packages (dependency, unlisted,
4//! unresolved-import and script-binary findings) differ between a run with
5//! `node_modules` and a run without it. A baseline saved in one state and
6//! compared in the other can then report baseline findings as new. The
7//! baseline records the state at save time, and a run in the other state gets
8//! a `baseline-install-state-mismatch` diagnostic.
9//!
10//! The state stays advisory. It sits next to the semantic identity inside
11//! `analysis_identity`, but it is not part of
12//! [`SemanticAnalysisIdentity`], so a difference never stops the comparison.
13
14use std::path::Path;
15
16use fallow_types::semantic::SemanticAnalysisIdentity;
17use fallow_types::workspace::{NodeModulesState, WorkspaceDiagnostic, WorkspaceDiagnosticKind};
18
19use super::BaselineData;
20
21/// The `analysis_identity` object of a dead-code baseline file.
22///
23/// The semantic identity is flattened, so the file keeps its shape and a
24/// legacy file without `node_modules` still loads. An older fallow that reads
25/// a new file ignores the extra key.
26#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
27pub(super) struct BaselineAnalysisIdentity {
28    #[serde(flatten)]
29    pub(super) semantic: SemanticAnalysisIdentity,
30    /// The install state when the baseline was saved. `None` for a file that
31    /// predates this key.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub(super) node_modules: Option<NodeModulesState>,
34}
35
36impl From<SemanticAnalysisIdentity> for BaselineAnalysisIdentity {
37    fn from(semantic: SemanticAnalysisIdentity) -> Self {
38        Self {
39            semantic,
40            node_modules: None,
41        }
42    }
43}
44
45impl BaselineData {
46    /// Record the install state of the saving run.
47    #[must_use]
48    pub const fn with_node_modules(mut self, state: NodeModulesState) -> Self {
49        self.analysis_identity.node_modules = Some(state);
50        self
51    }
52}
53
54/// The install state a baseline file recorded when it was saved.
55///
56/// `None` for a legacy file, a file of another format, or a file that is not
57/// JSON. Reads the raw file, so it also answers for a file that the dead-code
58/// format does not load.
59#[must_use]
60pub fn saved_node_modules_state(json: &str) -> Option<NodeModulesState> {
61    let value = serde_json::from_str::<serde_json::Value>(json).ok()?;
62    let state = value.get("analysis_identity")?.get("node_modules")?;
63    serde_json::from_value(state.clone()).ok()
64}
65
66/// The `baseline-install-state-mismatch` diagnostic for a loaded baseline, or
67/// `None` when the saved state is unknown or equals `run`.
68///
69/// The baseline path is canonicalized, so it shows root-relative when `root`
70/// is a canonical path and the caller passed another spelling of it.
71#[must_use]
72pub fn install_state_diagnostic(
73    root: &Path,
74    baseline_path: &Path,
75    json: &str,
76    run: NodeModulesState,
77) -> Option<WorkspaceDiagnostic> {
78    let saved = saved_node_modules_state(json)?;
79    (saved != run).then(|| {
80        WorkspaceDiagnostic::new(
81            root,
82            dunce::canonicalize(baseline_path).unwrap_or_else(|_| baseline_path.to_path_buf()),
83            WorkspaceDiagnosticKind::BaselineInstallStateMismatch {
84                baseline_node_modules: saved,
85                run_node_modules: run,
86            },
87        )
88    })
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::results::AnalysisResults;
95
96    fn saved(state: Option<NodeModulesState>) -> String {
97        let data = BaselineData::from_results(&AnalysisResults::default(), Path::new("/p"));
98        let data = match state {
99            Some(state) => data.with_node_modules(state),
100            None => data,
101        };
102        serde_json::to_string(&data).expect("serializes")
103    }
104
105    #[test]
106    fn a_saved_baseline_records_the_install_state_inside_the_analysis_identity() {
107        let json = saved(Some(NodeModulesState::Present));
108        let value: serde_json::Value = serde_json::from_str(&json).expect("json");
109        assert_eq!(value["analysis_identity"]["node_modules"], "present");
110        assert_eq!(value["analysis_identity"]["mode"], "syntactic");
111        assert_eq!(
112            saved_node_modules_state(&json),
113            Some(NodeModulesState::Present)
114        );
115        let loaded: BaselineData = serde_json::from_str(&json).expect("loads");
116        assert_eq!(
117            loaded.analysis_identity(),
118            &SemanticAnalysisIdentity::syntactic()
119        );
120    }
121
122    #[test]
123    fn a_legacy_baseline_has_no_install_state_and_still_loads() {
124        let json = saved(None);
125        assert!(!json.contains("node_modules"));
126        assert_eq!(saved_node_modules_state(&json), None);
127        assert!(serde_json::from_str::<BaselineData>(&json).is_ok());
128        let without_identity = r#"{"unused_files":[],"unused_exports":[],"unused_types":[],
129            "unused_dependencies":[],"unused_dev_dependencies":[]}"#;
130        assert!(serde_json::from_str::<BaselineData>(without_identity).is_ok());
131        assert_eq!(saved_node_modules_state(without_identity), None);
132        assert_eq!(saved_node_modules_state("not json"), None);
133    }
134
135    /// An older fallow reads `analysis_identity` straight into the semantic
136    /// identity, so the extra key must not break that read.
137    #[test]
138    fn the_semantic_identity_ignores_the_install_state_key() {
139        let json = saved(Some(NodeModulesState::Missing));
140        let value: serde_json::Value = serde_json::from_str(&json).expect("json");
141        let identity: SemanticAnalysisIdentity =
142            serde_json::from_value(value["analysis_identity"].clone()).expect("older read");
143        assert_eq!(identity, SemanticAnalysisIdentity::syntactic());
144    }
145
146    #[test]
147    fn only_a_known_and_different_state_gives_a_diagnostic() {
148        let root = Path::new("/p");
149        let path = root.join("fallow-baseline.json");
150        let present = saved(Some(NodeModulesState::Present));
151        assert!(
152            install_state_diagnostic(root, &path, &present, NodeModulesState::Present).is_none()
153        );
154        assert!(
155            install_state_diagnostic(root, &path, &saved(None), NodeModulesState::Missing)
156                .is_none()
157        );
158        let diagnostic = install_state_diagnostic(root, &path, &present, NodeModulesState::Missing)
159            .expect("mismatch");
160        assert_eq!(
161            diagnostic.kind,
162            WorkspaceDiagnosticKind::BaselineInstallStateMismatch {
163                baseline_node_modules: NodeModulesState::Present,
164                run_node_modules: NodeModulesState::Missing,
165            }
166        );
167        assert!(diagnostic.degrades_analysis);
168    }
169}