Skip to main content

usage/
warn.rs

1//! What a parse has to say about declarations that still work but should not be used.
2//!
3//! The reference implementation's half of what `usage-argv` reports from its static tables. Both
4//! answer the same question — which deprecated declarations did this command line use — and the
5//! rule they answer it by is written down at <https://usage.jdx.dev/spec/argv>, not in either
6//! crate. Neither can depend on the other: usage-argv has no dependencies at all, on purpose.
7//!
8//! Nothing here prints. A resolution reports, as configuration resolution does, so a CLI that
9//! queues its deprecations until its logging is up can have them as values.
10
11use std::cmp::Ordering;
12
13/// One thing a command line used that its own spec says not to use any more.
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct Warning {
16    /// What sort of declaration it was, for a caller that treats them differently.
17    pub kind: WarningKind,
18    /// What the user typed or set: `--old-flag`, `old-cmd`, `OLD_ENV`.
19    pub name: String,
20    /// The author's reason, when the declaration carries one.
21    pub message: Option<String>,
22    /// The release warnings start at. A warning that is here has already passed it.
23    pub warn_at: Option<String>,
24    /// The release the declaration goes away in, when the author has named one.
25    pub remove_at: Option<String>,
26    /// What to use instead, when the declaration implies one.
27    pub replacement: Option<String>,
28}
29
30/// The kinds of deprecation a parse can run into.
31///
32/// The wording of a message is nobody's contract; this is what a program can act on.
33#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
34#[non_exhaustive]
35pub enum WarningKind {
36    /// A flag whose declaration says not to use it any more.
37    DeprecatedFlag,
38    /// A command whose declaration says not to use it any more.
39    DeprecatedCommand,
40    /// A value that arrived through a `deprecated_env` alias rather than a current name.
41    DeprecatedEnv,
42    /// Something a CLI's own layer says, which this crate has no name for.
43    #[default]
44    Other,
45}
46
47impl Warning {
48    /// A deprecated flag that was given, named as the user names it.
49    pub fn flag(
50        name: impl Into<String>,
51        message: Option<String>,
52        warn_at: Option<String>,
53        remove_at: Option<String>,
54    ) -> Self {
55        Self {
56            kind: WarningKind::DeprecatedFlag,
57            name: name.into(),
58            message,
59            warn_at,
60            remove_at,
61            replacement: None,
62        }
63    }
64
65    /// A deprecated command that was selected.
66    pub fn command(
67        name: impl Into<String>,
68        message: Option<String>,
69        warn_at: Option<String>,
70        remove_at: Option<String>,
71    ) -> Self {
72        Self {
73            kind: WarningKind::DeprecatedCommand,
74            name: name.into(),
75            message,
76            warn_at,
77            remove_at,
78            replacement: None,
79        }
80    }
81
82    /// A value read from a deprecated environment alias, and the current name for it.
83    pub fn env(name: impl Into<String>, replacement: Option<String>) -> Self {
84        Self {
85            kind: WarningKind::DeprecatedEnv,
86            name: name.into(),
87            message: None,
88            warn_at: None,
89            remove_at: None,
90            replacement,
91        }
92    }
93
94    /// What a caller should print for this warning.
95    pub fn render(&self) -> String {
96        let subject = match self.kind {
97            WarningKind::DeprecatedCommand => format!("the {} command", self.name),
98            _ => self.name.clone(),
99        };
100        let mut out = format!("warning: {subject} is deprecated");
101        if let Some(at) = &self.remove_at {
102            out.push_str(&format!(", removed at {at}"));
103        }
104        match (&self.message, &self.replacement) {
105            (Some(message), _) => out.push_str(&format!(": {message}")),
106            (None, Some(replacement)) => out.push_str(&format!(": use {replacement}")),
107            (None, None) => {}
108        }
109        out.push('\n');
110        out
111    }
112}
113
114/// Whether a CLI at `current` has reached the release a deprecation starts warning at.
115///
116/// `deprecated_warn_at` is how an author says *not yet*. Every uncertain case warns: a missing
117/// milestone means deprecated now, and a version this cannot read is a spec or build problem that
118/// should be noisy rather than silent.
119pub fn version_reaches(current: Option<&str>, warn_at: Option<&str>) -> bool {
120    let (Some(warn_at), Some(current)) = (warn_at, current) else {
121        return true;
122    };
123    !matches!(compare(current, warn_at), Some(Ordering::Less))
124}
125
126/// Order two versions, or `None` if either is not a version this can read.
127///
128/// Dotted integers compared left to right, a missing segment reading as zero, a `-suffix` sorting
129/// before the same numbers without one, and `+build` ignored. The same rule `usage_argv::warn`
130/// implements, held to it by a parity test.
131pub fn compare(a: &str, b: &str) -> Option<Ordering> {
132    let (a_core, a_pre) = split(a);
133    let (b_core, b_pre) = split(b);
134    let mut a_segments = a_core.split('.');
135    let mut b_segments = b_core.split('.');
136    loop {
137        let (a_next, b_next) = (a_segments.next(), b_segments.next());
138        if a_next.is_none() && b_next.is_none() {
139            break;
140        }
141        match segment(a_next)?.cmp(&segment(b_next)?) {
142            Ordering::Equal => continue,
143            ordering => return Some(ordering),
144        }
145    }
146    Some(match (a_pre, b_pre) {
147        (None, None) => Ordering::Equal,
148        (Some(_), None) => Ordering::Less,
149        (None, Some(_)) => Ordering::Greater,
150        (Some(a), Some(b)) => a.cmp(b),
151    })
152}
153
154fn split(version: &str) -> (&str, Option<&str>) {
155    let version = version.split('+').next().unwrap_or(version);
156    match version.split_once('-') {
157        Some((core, pre)) => (core, Some(pre)),
158        None => (version, None),
159    }
160}
161
162fn segment(segment: Option<&str>) -> Option<u64> {
163    match segment {
164        None => Some(0),
165        Some(text) => text.parse().ok(),
166    }
167}
168
169/// Drop the warnings a CLI at `version` has not reached yet.
170pub fn retain_reached(warnings: &mut Vec<Warning>, version: Option<&str>) {
171    warnings.retain(|warning| version_reaches(version, warning.warn_at.as_deref()));
172}
173
174/// Everything, one line each, in the order they were collected.
175pub fn render(warnings: &[Warning]) -> String {
176    warnings.iter().map(Warning::render).collect()
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn the_gate_matches_the_rule() {
185        assert!(version_reaches(Some("1.0.0"), None));
186        assert!(version_reaches(None, Some("2.0.0")));
187        assert!(!version_reaches(Some("1.0.0"), Some("2.0.0")));
188        assert!(version_reaches(Some("2.0.0"), Some("2.0.0")));
189        assert!(!version_reaches(Some("2.0.0-rc.1"), Some("2.0.0")));
190        assert!(version_reaches(Some("nightly"), Some("2.0.0")));
191        assert_eq!(compare("2026.12", "2026.12.0"), Some(Ordering::Equal));
192        assert_eq!(compare("1.0.0+abc", "1.0.0+def"), Some(Ordering::Equal));
193        assert_eq!(compare("nightly", "1.0.0"), None);
194    }
195
196    #[test]
197    fn a_warning_says_what_to_do_about_it() {
198        assert_eq!(
199            Warning::flag(
200                "--old",
201                Some("use --new".into()),
202                None,
203                Some("2.0.0".into())
204            )
205            .render(),
206            "warning: --old is deprecated, removed at 2.0.0: use --new\n",
207        );
208        assert_eq!(
209            Warning::command("old", None, None, None).render(),
210            "warning: the old command is deprecated\n",
211        );
212        assert_eq!(
213            Warning::env("OLD_TOKEN", Some("APP_TOKEN".into())).render(),
214            "warning: OLD_TOKEN is deprecated: use APP_TOKEN\n",
215        );
216    }
217}