Skip to main content

purple_ssh/
onboarding.rs

1use semver::Version;
2
3pub struct PostInitOutcome {
4    pub upgrade_toast: Option<String>,
5}
6
7pub fn evaluate(paths: Option<&crate::runtime::env::Paths>) -> PostInitOutcome {
8    let current = match Version::parse(env!("CARGO_PKG_VERSION")) {
9        Ok(v) => v,
10        Err(_) => {
11            return PostInitOutcome {
12                upgrade_toast: None,
13            };
14        }
15    };
16    let last = crate::preferences::load_last_seen_version(paths)
17        .ok()
18        .flatten()
19        .and_then(|s| Version::parse(s.as_str()).ok());
20
21    // First-ever launch has no last_seen_version. The Welcome screen already
22    // introduces purple; adding a sticky "what's new" toast on top would be
23    // noise. Leave last_seen_version unset so the Welcome handler can seed
24    // it on close, after which future launches compare normally.
25    if last.is_none() {
26        return PostInitOutcome {
27            upgrade_toast: None,
28        };
29    }
30
31    if let Some(ref seen) = last {
32        if seen >= &current {
33            return PostInitOutcome {
34                upgrade_toast: None,
35            };
36        }
37    }
38
39    let sections = crate::changelog::cached();
40    let shown = crate::changelog::versions_to_show(sections, last.as_ref(), &current, 5);
41    if shown.is_empty() {
42        // Do not silently advance last_seen_version here. Bumping it on every
43        // launch lets dev builds with a higher Cargo.toml version race ahead of
44        // the installed release, which then suppresses the upgrade toast on the
45        // next real install. last_seen_version only advances via explicit user
46        // actions (Welcome close, What's New close).
47        return PostInitOutcome {
48            upgrade_toast: None,
49        };
50    }
51
52    log::debug!(
53        "[purple] queued upgrade toast: {} sections (last_seen={:?}, current={})",
54        shown.len(),
55        last.as_ref().map(|v| v.to_string()),
56        current
57    );
58    PostInitOutcome {
59        upgrade_toast: Some(crate::messages::whats_new_toast::upgraded(
60            &current.to_string(),
61        )),
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::preferences;
69    use crate::runtime::env::Paths;
70
71    fn current() -> String {
72        env!("CARGO_PKG_VERSION").to_string()
73    }
74
75    #[test]
76    fn first_launch_returns_no_toast() {
77        let dir = tempfile::tempdir().unwrap();
78        let paths = Paths::new(dir.path());
79        let outcome = evaluate(Some(&paths));
80        assert!(
81            outcome.upgrade_toast.is_none(),
82            "first launch must not show upgrade toast"
83        );
84    }
85
86    #[test]
87    fn up_to_date_returns_no_toast() {
88        let dir = tempfile::tempdir().unwrap();
89        let paths = Paths::new(dir.path());
90        preferences::save_last_seen_version(Some(&paths), &current()).unwrap();
91        let outcome = evaluate(Some(&paths));
92        assert!(outcome.upgrade_toast.is_none());
93        // evaluate() must never rewrite last_seen_version: any write
94        // would race ahead of the installed release and suppress a
95        // legitimate upgrade toast after a brew/curl install.
96        assert_eq!(
97            preferences::load_last_seen_version(Some(&paths))
98                .unwrap()
99                .as_deref(),
100            Some(current().as_str()),
101            "evaluate() must not touch last_seen_version when up-to-date"
102        );
103    }
104
105    #[test]
106    fn downgrade_returns_no_toast() {
107        let dir = tempfile::tempdir().unwrap();
108        let paths = Paths::new(dir.path());
109        preferences::save_last_seen_version(Some(&paths), "999.0.0").unwrap();
110        let outcome = evaluate(Some(&paths));
111        assert!(outcome.upgrade_toast.is_none());
112    }
113
114    #[test]
115    fn upgrade_with_new_sections_returns_toast() {
116        let dir = tempfile::tempdir().unwrap();
117        let paths = Paths::new(dir.path());
118        preferences::save_last_seen_version(Some(&paths), "0.0.1").unwrap();
119        let outcome = evaluate(Some(&paths));
120        let fragment = crate::messages::whats_new_toast::INVITE_FRAGMENT;
121        assert!(
122            outcome
123                .upgrade_toast
124                .as_deref()
125                .is_some_and(|t| t.contains(fragment)),
126            "expected upgrade toast with invite fragment"
127        );
128    }
129
130    #[test]
131    fn evaluate_never_writes_last_seen_version() {
132        // Regression: the old `shown.is_empty()` arm silently wrote
133        // last_seen_version = current, which let dev builds (Cargo.toml
134        // version ahead of any CHANGELOG entry) race ahead of the next
135        // installed release and suppress its upgrade toast. The fix is a
136        // pure delete of that write — evaluate() now never mutates the
137        // pref on ANY code path. The `shown.is_empty()` arm itself is
138        // hard to reach without stubbing `changelog::cached()` because a
139        // shipped CHANGELOG.md always has entries in the current-version
140        // range, so this property-style test sweeps every reachable arm
141        // (first-launch, up-to-date, downgrade, upgrade-with-sections,
142        // unparseable) and asserts the pref comes out exactly as it went
143        // in. If someone re-introduces a pref-write in any arm, at least
144        // one of these scenarios will catch it.
145        let scenarios: &[(&str, Option<&str>)] = &[
146            ("first_launch", None),
147            ("same_version", Some(env!("CARGO_PKG_VERSION"))),
148            ("downgrade", Some("999.0.0")),
149            ("older_version", Some("0.0.1")),
150            ("unparseable", Some("not-a-semver")),
151        ];
152        for (label, input) in scenarios {
153            let dir = tempfile::tempdir().unwrap();
154            let paths = Paths::new(dir.path());
155            if let Some(v) = input {
156                preferences::save_last_seen_version(Some(&paths), v).unwrap();
157            }
158            let _ = evaluate(Some(&paths));
159            let after = preferences::load_last_seen_version(Some(&paths)).unwrap();
160            assert_eq!(
161                after.as_deref(),
162                *input,
163                "[{}] evaluate() must not touch last_seen_version",
164                label
165            );
166        }
167    }
168
169    #[test]
170    fn unparseable_last_seen_falls_through_to_first_launch() {
171        let dir = tempfile::tempdir().unwrap();
172        let paths = Paths::new(dir.path());
173        preferences::save_last_seen_version(Some(&paths), "not-a-semver").unwrap();
174        let outcome = evaluate(Some(&paths));
175        assert!(
176            outcome.upgrade_toast.is_none(),
177            "garbled last_seen must be treated as first launch, not surface a toast"
178        );
179    }
180}