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() -> 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()
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
70    fn current() -> String {
71        env!("CARGO_PKG_VERSION").to_string()
72    }
73
74    #[test]
75    fn first_launch_returns_no_toast() {
76        preferences::tests_helpers::with_temp_prefs("onboarding_first", |_| {
77            let outcome = evaluate();
78            assert!(
79                outcome.upgrade_toast.is_none(),
80                "first launch must not show upgrade toast"
81            );
82        });
83    }
84
85    #[test]
86    fn up_to_date_returns_no_toast() {
87        preferences::tests_helpers::with_temp_prefs("onboarding_up_to_date", |_| {
88            preferences::save_last_seen_version(&current()).unwrap();
89            let outcome = evaluate();
90            assert!(outcome.upgrade_toast.is_none());
91            // evaluate() must never rewrite last_seen_version: any write
92            // would race ahead of the installed release and suppress a
93            // legitimate upgrade toast after a brew/curl install.
94            assert_eq!(
95                preferences::load_last_seen_version().unwrap().as_deref(),
96                Some(current().as_str()),
97                "evaluate() must not touch last_seen_version when up-to-date"
98            );
99        });
100    }
101
102    #[test]
103    fn downgrade_returns_no_toast() {
104        preferences::tests_helpers::with_temp_prefs("onboarding_downgrade", |_| {
105            preferences::save_last_seen_version("999.0.0").unwrap();
106            let outcome = evaluate();
107            assert!(outcome.upgrade_toast.is_none());
108        });
109    }
110
111    #[test]
112    fn upgrade_with_new_sections_returns_toast() {
113        preferences::tests_helpers::with_temp_prefs("onboarding_upgrade_toast", |_| {
114            preferences::save_last_seen_version("0.0.1").unwrap();
115            let outcome = evaluate();
116            let fragment = crate::messages::whats_new_toast::INVITE_FRAGMENT;
117            assert!(
118                outcome
119                    .upgrade_toast
120                    .as_deref()
121                    .is_some_and(|t| t.contains(fragment)),
122                "expected upgrade toast with invite fragment"
123            );
124        });
125    }
126
127    #[test]
128    fn evaluate_never_writes_last_seen_version() {
129        // Regression: the old `shown.is_empty()` arm silently wrote
130        // last_seen_version = current, which let dev builds (Cargo.toml
131        // version ahead of any CHANGELOG entry) race ahead of the next
132        // installed release and suppress its upgrade toast. The fix is a
133        // pure delete of that write — evaluate() now never mutates the
134        // pref on ANY code path. The `shown.is_empty()` arm itself is
135        // hard to reach without stubbing `changelog::cached()` because a
136        // shipped CHANGELOG.md always has entries in the current-version
137        // range, so this property-style test sweeps every reachable arm
138        // (first-launch, up-to-date, downgrade, upgrade-with-sections,
139        // unparseable) and asserts the pref comes out exactly as it went
140        // in. If someone re-introduces a pref-write in any arm, at least
141        // one of these scenarios will catch it.
142        let scenarios: &[(&str, Option<&str>)] = &[
143            ("first_launch", None),
144            ("same_version", Some(env!("CARGO_PKG_VERSION"))),
145            ("downgrade", Some("999.0.0")),
146            ("older_version", Some("0.0.1")),
147            ("unparseable", Some("not-a-semver")),
148        ];
149        for (label, input) in scenarios {
150            preferences::tests_helpers::with_temp_prefs(
151                &format!("onboarding_no_writes_{label}"),
152                |_| {
153                    if let Some(v) = input {
154                        preferences::save_last_seen_version(v).unwrap();
155                    }
156                    let _ = evaluate();
157                    let after = preferences::load_last_seen_version().unwrap();
158                    assert_eq!(
159                        after.as_deref(),
160                        *input,
161                        "[{}] evaluate() must not touch last_seen_version",
162                        label
163                    );
164                },
165            );
166        }
167    }
168
169    #[test]
170    fn unparseable_last_seen_falls_through_to_first_launch() {
171        preferences::tests_helpers::with_temp_prefs("onboarding_unparseable", |_| {
172            preferences::save_last_seen_version("not-a-semver").unwrap();
173            let outcome = evaluate();
174            assert!(
175                outcome.upgrade_toast.is_none(),
176                "garbled last_seen must be treated as first launch, not surface a toast"
177            );
178        });
179    }
180}