Skip to main content

polyc_runtime/
update_copy.rs

1//! The one place an available update is worded for a person.
2//!
3//! Every surface that tells someone "an update is ready" — the CLI `status`
4//! line, the local dashboard banner, and the chat-edge approval ask — renders
5//! the text this module returns, so the same update never reads two different
6//! ways on two surfaces. [`update_copy`] is pure: it maps an update
7//! [`Compatibility`] class plus the target version to a small [`UpdateCopy`]
8//! value, and nothing about an available update is worded anywhere else.
9//!
10//! The copy is honest per class, in plain terms:
11//!
12//! - [`Compatibility::Hot`] — a config-as-data change reaches new conversations
13//!   right away, with no restart. Carries the [`APPLY_NOW`] verb.
14//! - [`Compatibility::Warm`] — a binary swap restarts the service; conversations
15//!   already underway finish first. Carries the [`APPLY_NOW`] verb.
16//! - [`Compatibility::Cold`] — a format change needs a coordinated deploy across
17//!   the whole deployment, so it points at that path and carries **no** apply
18//!   verb (there is no one-click apply for a cold change).
19//! - [`Compatibility::Incompatible`] — the release was built for a different
20//!   runtime and cannot be applied here; it names the mismatch and carries no
21//!   apply verb.
22
23use crate::compat::Compatibility;
24
25/// The exact verb a surface renders on the apply affordance for an update that
26/// can be applied in place — never "Yes" or "OK". Shared so no surface
27/// hand-writes it.
28pub const APPLY_NOW: &str = "Apply now";
29
30/// The wording for one available update: a headline, a plain-language detail
31/// sentence, and — only when the update can be applied in place — the apply
32/// verb a surface puts on its button.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct UpdateCopy {
35    /// The one-line headline naming the update and, for a cold or incompatible
36    /// change, why it is not a one-click apply.
37    pub headline: String,
38    /// A complete sentence saying what applying the update does (or, for a cold
39    /// or incompatible change, what to do instead).
40    pub detail: String,
41    /// The apply verb ([`APPLY_NOW`]) for a hot or warm update that a surface
42    /// renders on its button, or `None` for a cold or incompatible change that
43    /// routes to a coordinated deploy rather than a one-click apply.
44    pub action: Option<&'static str>,
45}
46
47/// Word an available `version` for a person, given how it relates to the running
48/// build ([`Compatibility`]). Pure — the same inputs always yield the same
49/// [`UpdateCopy`], with no I/O.
50///
51/// A hot or warm update carries [`APPLY_NOW`]; a cold or incompatible one
52/// carries no apply verb and its detail routes to a coordinated deploy instead.
53#[must_use]
54pub fn update_copy(class: &Compatibility, version: &str) -> UpdateCopy {
55    match class {
56        Compatibility::Hot => UpdateCopy {
57            headline: format!("Update {version} is ready to apply"),
58            detail: "It reaches new conversations right away, with no restart.".to_owned(),
59            action: Some(APPLY_NOW),
60        },
61        Compatibility::Warm => UpdateCopy {
62            headline: format!("Update {version} is ready to apply"),
63            detail: "Applying it restarts the service; conversations already underway finish \
64                     first."
65                .to_owned(),
66            action: Some(APPLY_NOW),
67        },
68        Compatibility::Cold => UpdateCopy {
69            headline: format!("Update {version} needs a coordinated deploy"),
70            detail: "It changes how the parts talk to each other, so the whole deployment moves \
71                     together. Reach out to whoever runs your deployment to schedule it."
72                .to_owned(),
73            action: None,
74        },
75        Compatibility::Incompatible(reason) => UpdateCopy {
76            headline: format!("Update {version} can't be applied here"),
77            detail: format!(
78                "This build was made for a different setup: {reason}. Reach out to whoever runs \
79                 your deployment."
80            ),
81            action: None,
82        },
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
89
90    use super::*;
91    use crate::compat::Incompatibility;
92
93    /// Every class, for the copy sweeps below.
94    fn all_classes() -> Vec<Compatibility> {
95        vec![
96            Compatibility::Hot,
97            Compatibility::Warm,
98            Compatibility::Cold,
99            Compatibility::Incompatible(Incompatibility::Wire),
100            Compatibility::Incompatible(Incompatibility::EventLog),
101            Compatibility::Incompatible(Incompatibility::Crd),
102        ]
103    }
104
105    #[test]
106    fn hot_and_warm_carry_the_apply_verb() {
107        assert_eq!(
108            update_copy(&Compatibility::Hot, "0.2.0").action,
109            Some(APPLY_NOW)
110        );
111        assert_eq!(
112            update_copy(&Compatibility::Warm, "0.2.0").action,
113            Some(APPLY_NOW)
114        );
115    }
116
117    #[test]
118    fn cold_and_incompatible_carry_no_apply_verb() {
119        assert_eq!(update_copy(&Compatibility::Cold, "0.2.0").action, None);
120        assert_eq!(
121            update_copy(&Compatibility::Incompatible(Incompatibility::Wire), "0.2.0").action,
122            None,
123        );
124    }
125
126    #[test]
127    fn each_class_speaks_plainly_and_distinctly() {
128        // Hot: no restart. Warm: a restart. Cold: a coordinated deploy.
129        assert!(
130            update_copy(&Compatibility::Hot, "0.2.0")
131                .detail
132                .contains("no restart"),
133            "hot copy must say it needs no restart"
134        );
135        assert!(
136            update_copy(&Compatibility::Warm, "0.2.0")
137                .detail
138                .contains("restarts the service"),
139            "warm copy must say it restarts the service"
140        );
141        let cold = update_copy(&Compatibility::Cold, "0.2.0");
142        assert!(
143            cold.detail.contains("coordinated deploy")
144                || cold.headline.contains("coordinated deploy"),
145            "cold copy must route to a coordinated deploy"
146        );
147    }
148
149    #[test]
150    fn the_version_rides_every_headline() {
151        for class in all_classes() {
152            assert!(
153                update_copy(&class, "1.4.2").headline.contains("1.4.2"),
154                "the version must appear in the headline for {class:?}"
155            );
156        }
157    }
158
159    #[test]
160    fn no_banned_jargon_and_no_apologising_anywhere() {
161        // The user-facing copy rules (CLAUDE.md): no internal jargon, no
162        // please/sorry/unfortunately — checked across every class.
163        const BANNED: &[&str] = &[
164            "operator",
165            "trifecta",
166            "rule-of-two",
167            "context budget",
168            "sub-agent",
169            "state-changing action",
170            "lethal-trifecta",
171            "please",
172            "sorry",
173            "unfortunately",
174        ];
175        for class in all_classes() {
176            let copy = update_copy(&class, "0.2.0");
177            let surface = format!(
178                "{} {} {}",
179                copy.headline,
180                copy.detail,
181                copy.action.unwrap_or_default()
182            )
183            .to_lowercase();
184            for banned in BANNED {
185                assert!(
186                    !surface.contains(banned),
187                    "class {class:?} copy contains banned term {banned:?}: {surface}"
188                );
189            }
190        }
191    }
192
193    #[test]
194    fn incompatible_names_the_mismatched_axis() {
195        let copy = update_copy(
196            &Compatibility::Incompatible(Incompatibility::EventLog),
197            "0.2.0",
198        );
199        assert!(
200            copy.detail.contains("event-log schema"),
201            "an incompatible update names the axis that differs: {}",
202            copy.detail
203        );
204    }
205}