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#[derive(Clone, Debug, Eq, PartialEq)]
15pub enum RequestedRustToolchain {
16 Stable(Option<RustToolchainDate>),
18 Beta(Option<RustToolchainDate>),
20 Nightly(Option<RustToolchainDate>),
22 Exact(RustToolchainName),
24}
25
26#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
28pub struct RustToolchainDate(Date);
29
30impl RustToolchainDate {
31 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#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
119pub struct RustcCommitDate(Date);
120
121impl RustcCommitDate {
122 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#[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
262pub 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}