Skip to main content

ytil_sys/
rustup.rs

1use std::fmt::Display;
2use std::fmt::Formatter;
3use std::process::Command;
4use std::process::Output;
5use std::str::FromStr;
6
7use jiff::civil::Date;
8use rootcause::prelude::ResultExt;
9use rootcause::report;
10use strum::EnumString;
11use ytil_cmd::CmdExt;
12
13/// A requested Rust toolchain.
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub enum RequestedRustToolchain {
16    /// Select the newest installed stable channel, optionally starting at a date.
17    Stable(Option<RustToolchainDate>),
18    /// Select the newest installed beta channel, optionally starting at a date.
19    Beta(Option<RustToolchainDate>),
20    /// Select the newest installed nightly channel, optionally starting at a date.
21    Nightly(Option<RustToolchainDate>),
22    /// Use an exact Rustup toolchain name after checking that it is installed.
23    Exact(RustToolchainName),
24}
25
26/// A validated date embedded in a Rustup toolchain name.
27#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
28pub struct RustToolchainDate(Date);
29
30impl RustToolchainDate {
31    /// Returns the underlying calendar date.
32    pub const fn as_date(self) -> Date {
33        self.0
34    }
35}
36
37impl TryFrom<(&str, &str, &str)> for RustToolchainDate {
38    type Error = rootcause::Report;
39
40    fn try_from((year, month, day): (&str, &str, &str)) -> Result<Self, Self::Error> {
41        if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) {
42            return Err(report!("Rust toolchain date year has an invalid format").attach(format!("year={year:?}")));
43        }
44        if month.len() != 2 || !month.chars().all(|character| character.is_ascii_digit()) {
45            return Err(report!("Rust toolchain date month has an invalid format").attach(format!("month={month:?}")));
46        }
47        if day.len() != 2 || !day.chars().all(|character| character.is_ascii_digit()) {
48            return Err(report!("Rust toolchain date day has an invalid format").attach(format!("day={day:?}")));
49        }
50
51        let year = year
52            .parse::<i16>()
53            .context("failed to parse Rust toolchain date year")
54            .attach(format!("year={year:?}"))?;
55        let month = month
56            .parse::<i8>()
57            .context("failed to parse Rust toolchain date month")
58            .attach(format!("month={month:?}"))?;
59        let day = day
60            .parse::<i8>()
61            .context("failed to parse Rust toolchain date day")
62            .attach(format!("day={day:?}"))?;
63
64        Ok(Date::new(year, month, day)
65            .context("Rust toolchain date is not a valid calendar date")
66            .attach(format!("year={year}"))
67            .attach(format!("month={month}"))
68            .attach(format!("day={day}"))
69            .map(Self)?)
70    }
71}
72
73impl From<Date> for RustToolchainDate {
74    fn from(value: Date) -> Self {
75        Self(value)
76    }
77}
78
79impl Display for RustToolchainDate {
80    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
81        self.0.fmt(formatter)
82    }
83}
84
85impl FromStr for RustToolchainDate {
86    type Err = rootcause::Report;
87
88    fn from_str(value: &str) -> Result<Self, Self::Err> {
89        let input = value;
90        let mut components = value.split('-');
91        let Some(year) = components.next() else {
92            return Err(report!("Rust toolchain date is missing a year").attach(format!("input={input:?}")));
93        };
94        if year.is_empty() {
95            return Err(report!("Rust toolchain date is missing a year").attach(format!("input={input:?}")));
96        }
97        let Some(month) = components.next() else {
98            return Err(report!("Rust toolchain date is missing a month").attach(format!("input={input:?}")));
99        };
100        if month.is_empty() {
101            return Err(report!("Rust toolchain date is missing a month").attach(format!("input={input:?}")));
102        }
103        let Some(day) = components.next() else {
104            return Err(report!("Rust toolchain date is missing a day").attach(format!("input={input:?}")));
105        };
106        if day.is_empty() {
107            return Err(report!("Rust toolchain date is missing a day").attach(format!("input={input:?}")));
108        }
109        if components.next().is_some() {
110            return Err(report!("Rust toolchain date has extra components").attach(format!("input={input:?}")));
111        }
112
113        Self::try_from((year, month, day)).attach(format!("input={input:?}"))
114    }
115}
116
117/// A validated Rust compiler commit date.
118#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
119pub struct RustcCommitDate(Date);
120
121impl RustcCommitDate {
122    /// Returns the underlying calendar date.
123    pub const fn as_date(self) -> Date {
124        self.0
125    }
126}
127
128impl From<Date> for RustcCommitDate {
129    fn from(value: Date) -> Self {
130        Self(value)
131    }
132}
133
134impl Display for RustcCommitDate {
135    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
136        self.0.fmt(formatter)
137    }
138}
139
140impl FromStr for RustcCommitDate {
141    type Err = rootcause::Report;
142
143    fn from_str(value: &str) -> Result<Self, Self::Err> {
144        let input = value;
145        let value = value.strip_prefix("commit-date: ").ok_or_else(|| {
146            report!("Rust compiler commit date is missing its prefix").attach(format!("input={input:?}"))
147        })?;
148        let date = value
149            .parse::<RustToolchainDate>()
150            .context("failed to parse Rust compiler commit date")
151            .attach(format!("input={input:?}"))?;
152        Ok(Self(date.as_date()))
153    }
154}
155
156/// A validated Rustup toolchain name.
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct RustToolchainName(String);
159
160impl Display for RustToolchainName {
161    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
162        formatter.write_str(&self.0)
163    }
164}
165
166impl TryFrom<&str> for RustToolchainName {
167    type Error = rootcause::Report;
168
169    fn try_from(value: &str) -> Result<Self, Self::Error> {
170        if value.is_empty() {
171            return Err(report!("Rust toolchain name is empty").attach(format!("input={value:?}")));
172        }
173        if let Some(character) = value
174            .chars()
175            .find(|character| !character.is_ascii_alphanumeric() && !matches!(*character, '-' | '_' | '.'))
176        {
177            return Err(report!("Rust toolchain name contains an invalid character")
178                .attach(format!("input={value:?}"))
179                .attach(format!("character={character:?}")));
180        }
181
182        Ok(Self(value.to_owned()))
183    }
184}
185
186impl FromStr for RustToolchainName {
187    type Err = rootcause::Report;
188
189    fn from_str(value: &str) -> Result<Self, Self::Err> {
190        Self::try_from(value)
191    }
192}
193
194#[derive(Clone, Copy, Debug, EnumString, Eq, PartialEq)]
195#[strum(serialize_all = "snake_case")]
196enum RustToolchainChannel {
197    Stable,
198    Beta,
199    Nightly,
200}
201
202impl RequestedRustToolchain {
203    const fn channel_request(&self) -> Option<(RustToolchainChannel, Option<RustToolchainDate>)> {
204        match self {
205            Self::Stable(date) => Some((RustToolchainChannel::Stable, *date)),
206            Self::Beta(date) => Some((RustToolchainChannel::Beta, *date)),
207            Self::Nightly(date) => Some((RustToolchainChannel::Nightly, *date)),
208            Self::Exact(_) => None,
209        }
210    }
211}
212
213#[derive(Clone, Debug, Eq, PartialEq)]
214struct InstalledChannelToolchain {
215    channel: RustToolchainChannel,
216    date: Option<RustToolchainDate>,
217    name: RustToolchainName,
218}
219
220#[derive(Clone, Debug, Eq, PartialEq)]
221enum InstalledRustToolchain {
222    Channel(InstalledChannelToolchain),
223    Exact(RustToolchainName),
224}
225
226impl FromStr for InstalledRustToolchain {
227    type Err = rootcause::Report;
228
229    fn from_str(value: &str) -> Result<Self, Self::Err> {
230        let name = value.parse::<RustToolchainName>()?;
231        let (channel_name, channel_suffix) = value.split_once('-').unwrap_or((value, ""));
232        let Ok(channel) = channel_name.parse::<RustToolchainChannel>() else {
233            return Ok(Self::Exact(name));
234        };
235
236        let mut components = channel_suffix.split('-');
237        let date = match components.next() {
238            None => None,
239            Some(year) if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) => None,
240            Some(year) => {
241                let month = components.next().ok_or_else(|| {
242                    report!("installed Rust toolchain date is missing a month")
243                        .attach(format!("toolchain_name={value:?}"))
244                        .attach(format!("year={year:?}"))
245                })?;
246                let day = components.next().ok_or_else(|| {
247                    report!("installed Rust toolchain date is missing a day")
248                        .attach(format!("toolchain_name={value:?}"))
249                        .attach(format!("year={year:?}"))
250                })?;
251                Some(
252                    RustToolchainDate::try_from((year, month, day))
253                        .context("failed to parse installed Rust toolchain date")
254                        .attach(format!("toolchain_name={value:?}"))?,
255                )
256            }
257        };
258        Ok(Self::Channel(InstalledChannelToolchain { channel, date, name }))
259    }
260}
261
262/// Find the latest installed Rust toolchain matching the request.
263///
264/// This does not update or install any Rust toolchain.
265///
266/// An exact request returns its name after checking that the toolchain is installed.
267///
268/// # Errors
269///
270/// Returns an error if Rustup cannot list the installed toolchains or if no matching toolchain is
271/// installed.
272pub fn find_latest_installed_rust_toolchain(
273    requested_rust_toolchain: &RequestedRustToolchain,
274) -> rootcause::Result<RustToolchainName> {
275    let requested_channel = match requested_rust_toolchain {
276        RequestedRustToolchain::Stable(_) => RustToolchainChannel::Stable,
277        RequestedRustToolchain::Beta(_) => RustToolchainChannel::Beta,
278        RequestedRustToolchain::Nightly(_) => RustToolchainChannel::Nightly,
279        RequestedRustToolchain::Exact(name) => {
280            let rustc_output = inspect_rustc_toolchain(name)?;
281            ytil_cmd::extract_success_output(&rustc_output)
282                .context("failed to read exact Rust toolchain information")
283                .attach(format!("toolchain_name={name:?}"))?;
284            return Ok(name.clone());
285        }
286    };
287
288    let rustup_output = list_rustup_toolchains()?;
289    let installed_rust_toolchains = parse_installed_rust_toolchains(
290        &ytil_cmd::extract_success_output(&rustup_output).context("failed to read installed Rust toolchain list")?,
291    );
292
293    let unqualified_toolchain = installed_rust_toolchains.iter().find_map(|installed| match installed {
294        InstalledRustToolchain::Channel(candidate)
295            if candidate.channel == requested_channel && candidate.date.is_none() =>
296        {
297            Some(candidate)
298        }
299        InstalledRustToolchain::Channel(_) | InstalledRustToolchain::Exact(_) => None,
300    });
301    let unqualified_rustc_commit_date = match unqualified_toolchain {
302        Some(candidate) => {
303            let rustc_output = inspect_rustc_toolchain(&candidate.name)?;
304            let output = ytil_cmd::extract_success_output(&rustc_output)
305                .context("failed to read Rust compiler toolchain information")
306                .attach(format!("toolchain_name={:?}", candidate.name))?;
307            output
308                .lines()
309                .find_map(|line| line.strip_prefix("commit-date: ").map(|_| line))
310                .map(|line| {
311                    line.parse::<RustcCommitDate>()
312                        .context("failed to parse Rust compiler commit date")
313                        .attach(format!("line={line:?}"))
314                })
315                .transpose()?
316        }
317        None => None,
318    };
319
320    let Some(name) = select_latest_installed_rust_toolchain(
321        requested_rust_toolchain,
322        &installed_rust_toolchains,
323        unqualified_rustc_commit_date,
324    ) else {
325        return Err(report!("no matching installed Rust toolchain found")
326            .attach(format!("requested_rust_toolchain={requested_rust_toolchain:?}")));
327    };
328
329    Ok(name)
330}
331
332fn list_rustup_toolchains() -> rootcause::Result<Output> {
333    let mut command = Command::new("rustup");
334    command.args(["toolchain", "list"]);
335    Ok(command.exec().context("failed to list installed Rust toolchains")?)
336}
337
338fn inspect_rustc_toolchain(toolchain: &RustToolchainName) -> rootcause::Result<Output> {
339    let mut command = Command::new("rustc");
340    command.arg(format!("+{toolchain}")).arg("-Vv");
341    Ok(command
342        .exec()
343        .context("failed to inspect Rust toolchain")
344        .attach(format!("toolchain_name={toolchain:?}"))?)
345}
346
347fn parse_installed_rust_toolchains(output: &str) -> Vec<InstalledRustToolchain> {
348    let mut installed_rust_toolchains = Vec::new();
349    for line in output.lines() {
350        let Some(name) = line.split_ascii_whitespace().next() else {
351            continue;
352        };
353        let Ok(toolchain) = name.parse::<InstalledRustToolchain>() else {
354            continue;
355        };
356        installed_rust_toolchains.push(toolchain);
357    }
358    installed_rust_toolchains
359}
360
361fn select_latest_installed_rust_toolchain(
362    requested_rust_toolchain: &RequestedRustToolchain,
363    installed_rust_toolchains: &[InstalledRustToolchain],
364    unqualified_rustc_commit_date: Option<RustcCommitDate>,
365) -> Option<RustToolchainName> {
366    let (channel, minimum_date) = requested_rust_toolchain.channel_request()?;
367    let mut unqualified_toolchain = None;
368    let mut latest_dated_toolchain = None;
369
370    for installed in installed_rust_toolchains {
371        let InstalledRustToolchain::Channel(candidate) = installed else {
372            continue;
373        };
374        if candidate.channel != channel {
375            continue;
376        }
377
378        let Some(date) = candidate.date else {
379            if unqualified_toolchain.is_none() {
380                unqualified_toolchain = Some(candidate);
381            }
382            continue;
383        };
384        if minimum_date.is_some_and(|minimum| date < minimum) {
385            continue;
386        }
387
388        let is_newer = latest_dated_toolchain.is_none_or(|(_, latest_date)| date > latest_date);
389        if is_newer {
390            latest_dated_toolchain = Some((candidate, date));
391        }
392    }
393
394    let unqualified_rustc_date = unqualified_rustc_commit_date.map(RustcCommitDate::as_date);
395    let unqualified_toolchain_is_eligible = unqualified_toolchain.is_some()
396        && minimum_date.is_none_or(|minimum| unqualified_rustc_date.is_some_and(|date| date >= minimum.as_date()));
397
398    match (unqualified_toolchain, latest_dated_toolchain) {
399        (Some(unqualified), Some((dated, dated_date))) if unqualified_toolchain_is_eligible => {
400            if unqualified_rustc_date.is_some_and(|date| date > dated_date.as_date()) {
401                Some(unqualified.name.clone())
402            } else {
403                Some(dated.name.clone())
404            }
405        }
406        (Some(unqualified), None) if unqualified_toolchain_is_eligible => Some(unqualified.name.clone()),
407        (None | Some(_), Some((dated, _))) => Some(dated.name.clone()),
408        (Some(_) | None, None) => None,
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use test_that::prelude::*;
415
416    use super::*;
417
418    #[rstest::rstest]
419    #[case("1.95.0", RustToolchainName("1.95.0".to_owned()))]
420    #[case("1.99.0-beta.1", RustToolchainName("1.99.0-beta.1".to_owned()))]
421    fn test_rust_toolchain_name_when_name_is_parsed_returns_typed_value(
422        #[case] value: &str,
423        #[case] expected: RustToolchainName,
424    ) {
425        assert_that!(value.parse::<RustToolchainName>(), ok(eq(expected)));
426    }
427
428    #[rstest::rstest]
429    #[case("1.95.0")]
430    #[case("1.99.0-beta.1")]
431    fn test_rust_toolchain_name_when_formatted_returns_original_name(#[case] value: &str) {
432        let actual = RustToolchainName(value.to_owned()).to_string();
433
434        assert_that!(actual, eq(value));
435    }
436
437    #[rstest::rstest]
438    #[case("")]
439    #[case("nightly 2026")]
440    #[case("nightly/2026")]
441    fn test_rust_toolchain_name_when_invalid_name_is_parsed_returns_error(#[case] value: &str) {
442        let actual = value.parse::<RustToolchainName>();
443
444        assert_that!(actual, err(anything()));
445    }
446
447    #[test]
448    fn test_rust_toolchain_name_when_name_contains_invalid_character_returns_static_error_with_input_context() {
449        let actual = "nightly/2026".parse::<RustToolchainName>().unwrap_err();
450
451        assert_eq!(
452            actual.format_current_context().to_string(),
453            "Rust toolchain name contains an invalid character"
454        );
455        assert!(
456            actual
457                .attachments()
458                .iter()
459                .any(|attachment| attachment.to_string() == "input=\"nightly/2026\"")
460        );
461        assert!(
462            actual
463                .attachments()
464                .iter()
465                .any(|attachment| attachment.to_string() == "character='/'")
466        );
467    }
468
469    #[test]
470    fn test_parse_installed_rust_toolchains_when_output_contains_status_marker_returns_toolchains() {
471        let output = "nightly-2026-07-12-aarch64-apple-darwin\nnightly-2026-08-30-aarch64-apple-darwin (active)\n";
472        let actual = parse_installed_rust_toolchains(output);
473        let expected = vec![
474            InstalledRustToolchain::Channel(InstalledChannelToolchain {
475                channel: RustToolchainChannel::Nightly,
476                date: Some(RustToolchainDate(jiff::civil::Date::new(2026, 7, 12).unwrap())),
477                name: RustToolchainName("nightly-2026-07-12-aarch64-apple-darwin".to_owned()),
478            }),
479            InstalledRustToolchain::Channel(InstalledChannelToolchain {
480                channel: RustToolchainChannel::Nightly,
481                date: Some(RustToolchainDate(jiff::civil::Date::new(2026, 8, 30).unwrap())),
482                name: RustToolchainName("nightly-2026-08-30-aarch64-apple-darwin".to_owned()),
483            }),
484        ];
485
486        assert_that!(actual, eq(expected));
487    }
488
489    #[rstest::rstest]
490    #[case(
491        "stable-aarch64-apple-darwin",
492        InstalledRustToolchain::Channel(InstalledChannelToolchain {
493            channel: RustToolchainChannel::Stable,
494            date: None,
495            name: RustToolchainName("stable-aarch64-apple-darwin".to_owned()),
496        })
497    )]
498    #[case(
499        "stable",
500        InstalledRustToolchain::Channel(InstalledChannelToolchain {
501            channel: RustToolchainChannel::Stable,
502            date: None,
503            name: RustToolchainName("stable".to_owned()),
504        })
505    )]
506    #[case(
507        "nightly-2026-08-30-aarch64-apple-darwin",
508        InstalledRustToolchain::Channel(InstalledChannelToolchain {
509            channel: RustToolchainChannel::Nightly,
510            date: Some(RustToolchainDate(jiff::civil::Date::new(2026, 8, 30).unwrap())),
511            name: RustToolchainName("nightly-2026-08-30-aarch64-apple-darwin".to_owned()),
512        })
513    )]
514    #[case(
515        "1.99.0-beta.1-aarch64-apple-darwin",
516        InstalledRustToolchain::Exact(RustToolchainName("1.99.0-beta.1-aarch64-apple-darwin".to_owned()))
517    )]
518    fn test_parse_installed_rust_toolchains_when_input_contains_channel_and_exact_names_classifies_toolchains(
519        #[case] name: &str,
520        #[case] expected: InstalledRustToolchain,
521    ) {
522        let actual = parse_installed_rust_toolchains(name);
523
524        assert_that!(actual, eq(vec![expected]));
525    }
526
527    #[rstest::rstest]
528    #[case(RequestedRustToolchain::Stable(None), "stable-aarch64-apple-darwin")]
529    #[case(RequestedRustToolchain::Beta(None), "beta-aarch64-apple-darwin")]
530    #[case(RequestedRustToolchain::Nightly(None), "nightly-aarch64-apple-darwin")]
531    fn test_select_latest_installed_rust_toolchain_when_only_unqualified_channel_is_installed_returns_it(
532        #[case] toolchain: RequestedRustToolchain,
533        #[case] expected: &str,
534    ) {
535        let installed = parse_installed_rust_toolchains(
536            "stable-aarch64-apple-darwin\nbeta-aarch64-apple-darwin\nnightly-aarch64-apple-darwin\n",
537        );
538        let actual = select_latest_installed_rust_toolchain(&toolchain, &installed, None);
539
540        assert_that!(actual, some(eq(RustToolchainName(expected.to_owned()))));
541    }
542
543    #[test]
544    fn test_select_latest_installed_rust_toolchain_when_multiple_dated_channels_exist_returns_newest() {
545        let installed = parse_installed_rust_toolchains(
546            "nightly-2026-07-12-aarch64-apple-darwin\nnightly-2026-08-31-aarch64-apple-darwin\n",
547        );
548        let actual = select_latest_installed_rust_toolchain(&RequestedRustToolchain::Nightly(None), &installed, None);
549
550        assert_that!(
551            actual,
552            some(eq(RustToolchainName(
553                "nightly-2026-08-31-aarch64-apple-darwin".to_owned(),
554            )))
555        );
556    }
557
558    #[rstest::rstest]
559    #[case(
560        Some(RustcCommitDate(jiff::civil::Date::new(2026, 8, 31).unwrap())),
561        "nightly-aarch64-apple-darwin"
562    )]
563    #[case(
564        Some(RustcCommitDate(jiff::civil::Date::new(2026, 8, 30).unwrap())),
565        "nightly-2026-08-30-aarch64-apple-darwin"
566    )]
567    #[case(
568        Some(RustcCommitDate(jiff::civil::Date::new(2026, 8, 29).unwrap())),
569        "nightly-2026-08-30-aarch64-apple-darwin"
570    )]
571    #[case(None, "nightly-2026-08-30-aarch64-apple-darwin")]
572    fn test_select_latest_installed_rust_toolchain_when_unqualified_and_dated_channels_exist_selects_newest_by_compiler_date(
573        #[case] rustc_commit_date: Option<RustcCommitDate>,
574        #[case] expected: &str,
575    ) {
576        let installed =
577            parse_installed_rust_toolchains("nightly-2026-08-30-aarch64-apple-darwin\nnightly-aarch64-apple-darwin\n");
578        let actual = select_latest_installed_rust_toolchain(
579            &RequestedRustToolchain::Nightly(None),
580            &installed,
581            rustc_commit_date,
582        );
583
584        assert_that!(actual, some(eq(RustToolchainName(expected.to_owned()))));
585    }
586
587    #[test]
588    fn test_select_latest_installed_rust_toolchain_when_only_unqualified_channel_exists_returns_toolchain() {
589        let installed = parse_installed_rust_toolchains("nightly-aarch64-apple-darwin\n");
590        let actual = select_latest_installed_rust_toolchain(&RequestedRustToolchain::Nightly(None), &installed, None);
591
592        assert_that!(
593            actual,
594            some(eq(RustToolchainName("nightly-aarch64-apple-darwin".to_owned())))
595        );
596    }
597
598    #[test]
599    fn test_select_latest_installed_rust_toolchain_when_requested_channel_is_not_installed_returns_none() {
600        let installed = parse_installed_rust_toolchains("stable-aarch64-apple-darwin\n");
601        let actual = select_latest_installed_rust_toolchain(&RequestedRustToolchain::Nightly(None), &installed, None);
602
603        assert_that!(actual, none());
604    }
605
606    #[rstest::rstest]
607    #[case(
608        RequestedRustToolchain::Stable(Some(RustToolchainDate(jiff::civil::Date::new(2026, 8, 30).unwrap()))),
609        "stable-2026-08-31-aarch64-apple-darwin"
610    )]
611    #[case(
612        RequestedRustToolchain::Beta(Some(RustToolchainDate(jiff::civil::Date::new(2026, 8, 30).unwrap()))),
613        "beta-2026-08-31-aarch64-apple-darwin"
614    )]
615    #[case(
616        RequestedRustToolchain::Nightly(Some(RustToolchainDate(jiff::civil::Date::new(2026, 8, 30).unwrap()))),
617        "nightly-2026-08-31-aarch64-apple-darwin"
618    )]
619    fn test_select_latest_installed_rust_toolchain_when_minimum_date_is_requested_returns_newest_matching_toolchain(
620        #[case] toolchain: RequestedRustToolchain,
621        #[case] expected: &str,
622    ) {
623        let installed = parse_installed_rust_toolchains(
624            "stable-2026-08-31-aarch64-apple-darwin\nbeta-2026-08-31-aarch64-apple-darwin\nnightly-2026-08-30-aarch64-apple-darwin\nnightly-2026-08-31-aarch64-apple-darwin\n",
625        );
626        let actual = select_latest_installed_rust_toolchain(&toolchain, &installed, None);
627
628        assert_that!(actual, some(eq(RustToolchainName(expected.to_owned()))));
629    }
630
631    #[test]
632    fn test_select_latest_installed_rust_toolchain_when_channel_is_missing_returns_none() {
633        let installed = parse_installed_rust_toolchains("stable-2026-08-30-aarch64-apple-darwin\n");
634        let toolchain =
635            RequestedRustToolchain::Nightly(Some(RustToolchainDate(jiff::civil::Date::new(2026, 8, 30).unwrap())));
636        let actual = select_latest_installed_rust_toolchain(&toolchain, &installed, None);
637
638        assert_that!(actual, none());
639    }
640
641    #[rstest::rstest]
642    #[case(
643        "commit-date: 2026-08-30",
644        RustcCommitDate(jiff::civil::Date::new(2026, 8, 30).unwrap())
645    )]
646    fn test_rustc_commit_date_when_valid_value_is_parsed_returns_date(
647        #[case] value: &str,
648        #[case] expected: RustcCommitDate,
649    ) {
650        assert_that!(value.parse::<RustcCommitDate>(), ok(eq(expected)));
651    }
652
653    #[rstest::rstest]
654    #[case("")]
655    #[case("2026-08-30")]
656    #[case("2026-8-30")]
657    #[case("commit-date: 2026-02-29")]
658    #[case("2026-08-30-extra")]
659    #[case("aarch64-apple-darwin")]
660    fn test_rustc_commit_date_when_invalid_value_is_parsed_returns_error(#[case] value: &str) {
661        let actual = value.parse::<RustcCommitDate>();
662
663        assert_that!(actual, err(anything()));
664    }
665
666    #[rstest::rstest]
667    #[case(
668        "2026-08-30",
669        RustToolchainDate(jiff::civil::Date::new(2026, 8, 30).unwrap())
670    )]
671    fn test_rust_toolchain_date_when_valid_value_is_parsed_returns_date(
672        #[case] value: &str,
673        #[case] expected: RustToolchainDate,
674    ) {
675        assert_that!(value.parse::<RustToolchainDate>(), ok(eq(expected)));
676    }
677
678    #[rstest::rstest]
679    #[case("")]
680    #[case("2026-02-29")]
681    #[case("2026-8-30")]
682    #[case("2026-08-30-extra")]
683    #[case("commit-date: 2026-08-30")]
684    fn test_rust_toolchain_date_when_invalid_value_is_parsed_returns_error(#[case] value: &str) {
685        let actual = value.parse::<RustToolchainDate>();
686
687        assert_that!(actual, err(anything()));
688    }
689
690    #[test]
691    fn test_rust_toolchain_date_when_calendar_date_is_invalid_returns_static_error_with_input_context() {
692        let actual = "2026-02-29".parse::<RustToolchainDate>().unwrap_err();
693
694        assert_eq!(
695            actual.format_current_context().to_string(),
696            "Rust toolchain date is not a valid calendar date"
697        );
698        assert!(
699            actual
700                .attachments()
701                .iter()
702                .any(|attachment| attachment.to_string() == "input=\"2026-02-29\"")
703        );
704    }
705}