Rust · 31081 bytes Raw Blame History
1 use std::{
2 ffi::{OsStr, OsString},
3 fmt,
4 io::{self, IsTerminal, Write},
5 path::PathBuf,
6 time::Duration,
7 };
8
9 use crossterm::{
10 event::{self, DisableMouseCapture, EnableMouseCapture, Event},
11 execute,
12 terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
13 };
14 use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect};
15 use time::{Date, OffsetDateTime, format_description};
16
17 use crate::{
18 agenda::{ConfiguredAgendaSource, HolidayProvider, LocalEventStoreError, default_events_file},
19 app::{
20 AppState, CreateEventInputResult, EventDeleteInputResult, EventDeleteSubmission,
21 EventFormMode, HelpInputResult, KeyboardInput, MouseInput, RecurrenceChoiceInputResult,
22 },
23 calendar::CalendarDate,
24 tui::{
25 AppView, DEFAULT_RENDER_HEIGHT, DEFAULT_RENDER_WIDTH, hit_test_app_date,
26 render_app_to_string_with_agenda_source,
27 },
28 };
29
30 const HELP: &str = concat!(
31 "rcal ",
32 env!("CARGO_PKG_VERSION"),
33 "\n\n",
34 "Usage:\n",
35 " rcal [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]\n\n",
36 "Options:\n",
37 " --date YYYY-MM-DD Open with the given date selected.\n",
38 " --events-file PATH Read and write local user events at PATH.\n",
39 " --holiday-source off|us-federal|nager\n",
40 " Choose holiday data. Default: us-federal.\n",
41 " --holiday-country CC Country code for --holiday-source nager. Default: US.\n",
42 " -h, --help Show this help.\n",
43 " -V, --version Show version.\n\n",
44 "Keys:\n",
45 " Arrow keys move selection; Enter opens day view; Esc returns to month; q exits.\n",
46 " ? opens contextual help.\n",
47 " + opens the Create event modal.\n",
48 " In day view, d opens the Delete confirmation for the selected local event.\n",
49 " In day view, Left/Right move to the previous or next day.\n",
50 " Digits jump immediately; a quick second digit refines the selected day.\n",
51 " Weekday initials jump within the selected week.\n\n",
52 "Mouse:\n",
53 " Left click selects a visible date; double-click a visible date to open day view.\n\n",
54 "Notes:\n",
55 " Real calendar-account integration and reminder notifications are not in this milestone.\n",
56 );
57
58 const VERSION: &str = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION"), "\n");
59 const DIGIT_JUMP_TIMEOUT: Duration = Duration::from_millis(900);
60
61 #[derive(Debug, Clone, PartialEq, Eq)]
62 pub struct AppConfig {
63 pub start_date: CalendarDate,
64 pub events_file: PathBuf,
65 pub holiday_source: HolidaySourceConfig,
66 pub holiday_country: String,
67 }
68
69 impl AppConfig {
70 pub fn new(start_date: CalendarDate) -> Self {
71 Self {
72 start_date,
73 events_file: default_events_file(),
74 holiday_source: HolidaySourceConfig::UsFederal,
75 holiday_country: "US".to_string(),
76 }
77 }
78 }
79
80 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
81 pub enum HolidaySourceConfig {
82 Off,
83 UsFederal,
84 Nager,
85 }
86
87 #[derive(Debug, Clone, PartialEq, Eq)]
88 pub enum CliAction {
89 Run(AppConfig),
90 Help,
91 Version,
92 }
93
94 #[derive(Debug, Clone, PartialEq, Eq)]
95 pub enum CliError {
96 DuplicateDate,
97 MissingDateValue,
98 DuplicateEventsFile,
99 MissingEventsFileValue,
100 DuplicateHolidaySource,
101 MissingHolidaySourceValue,
102 InvalidHolidaySource(String),
103 DuplicateHolidayCountry,
104 MissingHolidayCountryValue,
105 InvalidHolidayCountry(String),
106 HolidayCountryRequiresNager,
107 UnknownArgument(String),
108 InvalidDate { input: String, reason: String },
109 }
110
111 impl fmt::Display for CliError {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 match self {
114 Self::DuplicateDate => write!(f, "--date may only be provided once"),
115 Self::MissingDateValue => write!(f, "--date requires a value in YYYY-MM-DD format"),
116 Self::DuplicateEventsFile => write!(f, "--events-file may only be provided once"),
117 Self::MissingEventsFileValue => write!(f, "--events-file requires a path"),
118 Self::DuplicateHolidaySource => write!(f, "--holiday-source may only be provided once"),
119 Self::MissingHolidaySourceValue => write!(
120 f,
121 "--holiday-source requires one of: off, us-federal, nager"
122 ),
123 Self::InvalidHolidaySource(value) => write!(
124 f,
125 "invalid --holiday-source value '{value}'; expected off, us-federal, or nager"
126 ),
127 Self::DuplicateHolidayCountry => {
128 write!(f, "--holiday-country may only be provided once")
129 }
130 Self::MissingHolidayCountryValue => {
131 write!(f, "--holiday-country requires a two-letter country code")
132 }
133 Self::InvalidHolidayCountry(value) => {
134 write!(
135 f,
136 "invalid --holiday-country value '{value}'; expected two ASCII letters"
137 )
138 }
139 Self::HolidayCountryRequiresNager => {
140 write!(
141 f,
142 "--holiday-country may only be used with --holiday-source nager"
143 )
144 }
145 Self::UnknownArgument(arg) => write!(f, "unknown argument: {arg}"),
146 Self::InvalidDate { input, reason } => {
147 write!(f, "invalid --date value '{input}': {reason}")
148 }
149 }
150 }
151 }
152
153 impl std::error::Error for CliError {}
154
155 pub fn run_terminal<I>(args: I) -> std::process::ExitCode
156 where
157 I: IntoIterator<Item = OsString>,
158 {
159 let stdout = io::stdout();
160 let stderr = io::stderr();
161
162 if stdout.is_terminal() {
163 run_styled_terminal(args, stdout, stderr)
164 } else {
165 run(args, stdout, stderr)
166 }
167 }
168
169 pub fn run<I, W, E>(args: I, mut stdout: W, mut stderr: E) -> std::process::ExitCode
170 where
171 I: IntoIterator<Item = OsString>,
172 W: Write,
173 E: Write,
174 {
175 match parse_args(args, default_start_date()) {
176 Ok(CliAction::Run(config)) => {
177 let app = AppState::new(config.start_date);
178 let agenda_source = match agenda_source(&config) {
179 Ok(source) => source,
180 Err(err) => return local_event_error_exit(&mut stderr, err),
181 };
182 let (width, height) = terminal_size();
183 let rendered =
184 render_app_to_string_with_agenda_source(&app, width, height, &agenda_source);
185 match write!(stdout, "{rendered}") {
186 Ok(()) => std::process::ExitCode::SUCCESS,
187 Err(err) => io_error_exit(&mut stderr, err),
188 }
189 }
190 Ok(CliAction::Help) => match write!(stdout, "{HELP}") {
191 Ok(()) => std::process::ExitCode::SUCCESS,
192 Err(err) => io_error_exit(&mut stderr, err),
193 },
194 Ok(CliAction::Version) => match write!(stdout, "{VERSION}") {
195 Ok(()) => std::process::ExitCode::SUCCESS,
196 Err(err) => io_error_exit(&mut stderr, err),
197 },
198 Err(err) => {
199 let _ = writeln!(stderr, "error: {err}\n\n{HELP}");
200 std::process::ExitCode::from(2)
201 }
202 }
203 }
204
205 fn run_styled_terminal<I, W, E>(args: I, mut stdout: W, mut stderr: E) -> std::process::ExitCode
206 where
207 I: IntoIterator<Item = OsString>,
208 W: Write,
209 E: Write,
210 {
211 match parse_args(args, default_start_date()) {
212 Ok(CliAction::Run(config)) => {
213 let app = AppState::new(config.start_date);
214 let agenda_source = match agenda_source(&config) {
215 Ok(source) => source,
216 Err(err) => return local_event_error_exit(&mut stderr, err),
217 };
218 match run_interactive_terminal(stdout, app, agenda_source) {
219 Ok(()) => std::process::ExitCode::SUCCESS,
220 Err(err) => io_error_exit(&mut stderr, err),
221 }
222 }
223 Ok(CliAction::Help) => match write!(stdout, "{HELP}") {
224 Ok(()) => std::process::ExitCode::SUCCESS,
225 Err(err) => io_error_exit(&mut stderr, err),
226 },
227 Ok(CliAction::Version) => match write!(stdout, "{VERSION}") {
228 Ok(()) => std::process::ExitCode::SUCCESS,
229 Err(err) => io_error_exit(&mut stderr, err),
230 },
231 Err(err) => {
232 let _ = writeln!(stderr, "error: {err}\n\n{HELP}");
233 std::process::ExitCode::from(2)
234 }
235 }
236 }
237
238 pub fn parse_args<I>(args: I, today: Date) -> Result<CliAction, CliError>
239 where
240 I: IntoIterator<Item = OsString>,
241 {
242 let mut start_date = None;
243 let mut events_file = None;
244 let mut holiday_source = None;
245 let mut holiday_country = None;
246 let mut args = args.into_iter();
247
248 while let Some(arg) = args.next() {
249 if arg == "--help" || arg == "-h" {
250 return Ok(CliAction::Help);
251 }
252
253 if arg == "--version" || arg == "-V" {
254 return Ok(CliAction::Version);
255 }
256
257 if arg == "--date" {
258 if start_date.is_some() {
259 return Err(CliError::DuplicateDate);
260 }
261
262 let value = args.next().ok_or(CliError::MissingDateValue)?;
263 start_date = Some(parse_date_arg(&value)?);
264 continue;
265 }
266
267 if let Some(value) = arg.to_str().and_then(|value| value.strip_prefix("--date=")) {
268 if start_date.is_some() {
269 return Err(CliError::DuplicateDate);
270 }
271
272 start_date = Some(parse_date_str(value)?);
273 continue;
274 }
275
276 if arg == "--events-file" {
277 if events_file.is_some() {
278 return Err(CliError::DuplicateEventsFile);
279 }
280
281 let value = args.next().ok_or(CliError::MissingEventsFileValue)?;
282 events_file = Some(PathBuf::from(value));
283 continue;
284 }
285
286 if let Some(value) = arg
287 .to_str()
288 .and_then(|value| value.strip_prefix("--events-file="))
289 {
290 if events_file.is_some() {
291 return Err(CliError::DuplicateEventsFile);
292 }
293
294 events_file = Some(PathBuf::from(value));
295 continue;
296 }
297
298 if arg == "--holiday-source" {
299 if holiday_source.is_some() {
300 return Err(CliError::DuplicateHolidaySource);
301 }
302
303 let value = args.next().ok_or(CliError::MissingHolidaySourceValue)?;
304 holiday_source = Some(parse_holiday_source_arg(&value)?);
305 continue;
306 }
307
308 if let Some(value) = arg
309 .to_str()
310 .and_then(|value| value.strip_prefix("--holiday-source="))
311 {
312 if holiday_source.is_some() {
313 return Err(CliError::DuplicateHolidaySource);
314 }
315
316 holiday_source = Some(parse_holiday_source_str(value)?);
317 continue;
318 }
319
320 if arg == "--holiday-country" {
321 if holiday_country.is_some() {
322 return Err(CliError::DuplicateHolidayCountry);
323 }
324
325 let value = args.next().ok_or(CliError::MissingHolidayCountryValue)?;
326 holiday_country = Some(parse_holiday_country_arg(&value)?);
327 continue;
328 }
329
330 if let Some(value) = arg
331 .to_str()
332 .and_then(|value| value.strip_prefix("--holiday-country="))
333 {
334 if holiday_country.is_some() {
335 return Err(CliError::DuplicateHolidayCountry);
336 }
337
338 holiday_country = Some(parse_holiday_country_str(value)?);
339 continue;
340 }
341
342 return Err(CliError::UnknownArgument(display_arg(&arg)));
343 }
344
345 let holiday_country_was_provided = holiday_country.is_some();
346 let mut config = AppConfig::new(CalendarDate::from(start_date.unwrap_or(today)));
347 if let Some(events_file) = events_file {
348 config.events_file = events_file;
349 }
350 if let Some(holiday_source) = holiday_source {
351 config.holiday_source = holiday_source;
352 }
353 if let Some(holiday_country) = holiday_country {
354 config.holiday_country = holiday_country;
355 }
356 if holiday_country_was_provided && config.holiday_source != HolidaySourceConfig::Nager {
357 return Err(CliError::HolidayCountryRequiresNager);
358 }
359
360 Ok(CliAction::Run(config))
361 }
362
363 fn default_start_date() -> Date {
364 OffsetDateTime::now_local()
365 .unwrap_or_else(|_| OffsetDateTime::now_utc())
366 .date()
367 }
368
369 fn terminal_size() -> (u16, u16) {
370 terminal::size()
371 .ok()
372 .filter(|(width, height)| *width > 0 && *height > 0)
373 .unwrap_or((DEFAULT_RENDER_WIDTH, DEFAULT_RENDER_HEIGHT))
374 }
375
376 fn agenda_source(config: &AppConfig) -> Result<ConfiguredAgendaSource, LocalEventStoreError> {
377 let holidays = match config.holiday_source {
378 HolidaySourceConfig::Off => HolidayProvider::off(),
379 HolidaySourceConfig::UsFederal => HolidayProvider::us_federal(),
380 HolidaySourceConfig::Nager => HolidayProvider::nager(config.holiday_country.clone()),
381 };
382
383 ConfiguredAgendaSource::from_events_file(config.events_file.clone(), holidays)
384 }
385
386 fn run_interactive_terminal<W>(
387 stdout: W,
388 app: AppState,
389 agenda_source: ConfiguredAgendaSource,
390 ) -> io::Result<()>
391 where
392 W: Write,
393 {
394 terminal::enable_raw_mode()?;
395 let backend = CrosstermBackend::new(stdout);
396 let mut terminal = match Terminal::new(backend) {
397 Ok(terminal) => terminal,
398 Err(err) => {
399 let _ = terminal::disable_raw_mode();
400 return Err(err);
401 }
402 };
403
404 if let Err(err) = execute!(
405 terminal.backend_mut(),
406 EnterAlternateScreen,
407 EnableMouseCapture
408 ) {
409 let _ = execute!(
410 terminal.backend_mut(),
411 DisableMouseCapture,
412 LeaveAlternateScreen
413 );
414 let _ = terminal::disable_raw_mode();
415 return Err(err);
416 }
417
418 let result = run_event_loop(&mut terminal, app, agenda_source);
419 let cleanup_result = restore_terminal(&mut terminal);
420
421 result.and(cleanup_result)
422 }
423
424 fn run_event_loop<W>(
425 terminal: &mut Terminal<CrosstermBackend<W>>,
426 mut app: AppState,
427 mut agenda_source: ConfiguredAgendaSource,
428 ) -> io::Result<()>
429 where
430 W: Write,
431 {
432 let mut keyboard = KeyboardInput::default();
433 let mut mouse = MouseInput::default();
434
435 loop {
436 terminal.draw(|frame| {
437 frame.render_widget(
438 AppView::with_agenda_source(&app, &agenda_source),
439 frame.area(),
440 );
441 })?;
442
443 if app.should_quit() {
444 return Ok(());
445 }
446
447 let event = if !app.is_creating_event()
448 && !app.is_choosing_recurring_edit()
449 && !app.is_confirming_delete()
450 && !app.is_showing_help()
451 && keyboard.is_waiting_for_digit()
452 {
453 if event::poll(DIGIT_JUMP_TIMEOUT)? {
454 event::read()?
455 } else {
456 keyboard.clear_digit();
457 continue;
458 }
459 } else {
460 event::read()?
461 };
462
463 match event {
464 Event::Key(key) => {
465 mouse.clear();
466 if app.is_showing_help() {
467 match app.handle_help_key(key) {
468 HelpInputResult::Continue | HelpInputResult::Close => {}
469 }
470 } else if app.is_confirming_delete() {
471 match app.handle_delete_choice_key(key) {
472 EventDeleteInputResult::Continue => {}
473 EventDeleteInputResult::Cancel => app.close_delete_choice(),
474 EventDeleteInputResult::Submit(submission) => match submission {
475 EventDeleteSubmission::Event { event_id }
476 | EventDeleteSubmission::Series {
477 series_id: event_id,
478 } => match agenda_source.delete_event(&event_id) {
479 Ok(_) => {
480 app.close_delete_choice();
481 app.reconcile_day_event_selection(&agenda_source);
482 }
483 Err(err) => app.set_delete_error(err.to_string()),
484 },
485 EventDeleteSubmission::Occurrence { series_id, anchor } => {
486 match agenda_source.delete_occurrence(&series_id, anchor) {
487 Ok(()) => {
488 app.close_delete_choice();
489 app.reconcile_day_event_selection(&agenda_source);
490 }
491 Err(err) => app.set_delete_error(err.to_string()),
492 }
493 }
494 },
495 }
496 } else if app.is_choosing_recurring_edit() {
497 match app.handle_recurrence_choice_key(key, &agenda_source) {
498 RecurrenceChoiceInputResult::Continue => {}
499 RecurrenceChoiceInputResult::Cancel => app.close_recurrence_choice(),
500 }
501 } else if app.is_creating_event() {
502 match app.handle_create_key(key) {
503 CreateEventInputResult::Continue => {}
504 CreateEventInputResult::Cancel => app.close_create_form(),
505 CreateEventInputResult::Submit(submission) => {
506 let submission = *submission;
507 match submission.mode {
508 EventFormMode::Create => {
509 match agenda_source.create_event(submission.draft) {
510 Ok(_) => {
511 app.close_create_form();
512 app.reconcile_day_event_selection(&agenda_source);
513 }
514 Err(err) => app.set_create_form_error(err.to_string()),
515 }
516 }
517 EventFormMode::Edit { event_id } => {
518 match agenda_source.update_event(&event_id, submission.draft) {
519 Ok(_) => {
520 app.close_create_form();
521 app.reconcile_day_event_selection(&agenda_source);
522 }
523 Err(err) => app.set_create_form_error(err.to_string()),
524 }
525 }
526 EventFormMode::EditOccurrence { series_id, anchor } => {
527 match agenda_source.update_occurrence(
528 &series_id,
529 anchor,
530 submission.draft,
531 ) {
532 Ok(_) => {
533 app.close_create_form();
534 app.reconcile_day_event_selection(&agenda_source);
535 }
536 Err(err) => app.set_create_form_error(err.to_string()),
537 }
538 }
539 }
540 }
541 }
542 } else {
543 let action = keyboard.translate(key);
544 app.apply_with_agenda_source(action, &agenda_source);
545 }
546 }
547 Event::Mouse(mouse_event) => {
548 if app.is_creating_event()
549 || app.is_choosing_recurring_edit()
550 || app.is_confirming_delete()
551 || app.is_showing_help()
552 {
553 continue;
554 }
555 keyboard.clear();
556 let size = terminal.size()?;
557 let area = Rect::new(0, 0, size.width, size.height);
558 let target_date =
559 hit_test_app_date(&app, area, mouse_event.column, mouse_event.row);
560 let action = mouse.translate(mouse_event, target_date, app.selected_date());
561 app.apply_with_agenda_source(action, &agenda_source);
562 }
563 Event::Resize(_, _) => {
564 keyboard.clear();
565 mouse.clear();
566 }
567 _ => {}
568 }
569 }
570 }
571
572 fn restore_terminal<W>(terminal: &mut Terminal<CrosstermBackend<W>>) -> io::Result<()>
573 where
574 W: Write,
575 {
576 let raw_result = terminal::disable_raw_mode();
577 let screen_result = execute!(
578 terminal.backend_mut(),
579 DisableMouseCapture,
580 LeaveAlternateScreen
581 );
582 let cursor_result = terminal.show_cursor();
583
584 raw_result?;
585 screen_result?;
586 cursor_result?;
587 Ok(())
588 }
589
590 fn parse_date_arg(value: &OsStr) -> Result<Date, CliError> {
591 let value = value.to_str().ok_or_else(|| CliError::InvalidDate {
592 input: display_arg(value),
593 reason: "date must be valid UTF-8".to_string(),
594 })?;
595
596 parse_date_str(value)
597 }
598
599 fn parse_holiday_source_arg(value: &OsStr) -> Result<HolidaySourceConfig, CliError> {
600 let value = value
601 .to_str()
602 .ok_or_else(|| CliError::InvalidHolidaySource("value must be valid UTF-8".to_string()))?;
603
604 parse_holiday_source_str(value)
605 }
606
607 fn parse_holiday_source_str(value: &str) -> Result<HolidaySourceConfig, CliError> {
608 match value {
609 "off" => Ok(HolidaySourceConfig::Off),
610 "us-federal" => Ok(HolidaySourceConfig::UsFederal),
611 "nager" => Ok(HolidaySourceConfig::Nager),
612 _ => Err(CliError::InvalidHolidaySource(value.to_string())),
613 }
614 }
615
616 fn parse_holiday_country_arg(value: &OsStr) -> Result<String, CliError> {
617 let value = value
618 .to_str()
619 .ok_or_else(|| CliError::InvalidHolidayCountry("value must be valid UTF-8".to_string()))?;
620
621 parse_holiday_country_str(value)
622 }
623
624 fn parse_holiday_country_str(value: &str) -> Result<String, CliError> {
625 if value.len() == 2 && value.bytes().all(|value| value.is_ascii_alphabetic()) {
626 Ok(value.to_ascii_uppercase())
627 } else {
628 Err(CliError::InvalidHolidayCountry(value.to_string()))
629 }
630 }
631
632 fn parse_date_str(value: &str) -> Result<Date, CliError> {
633 let format =
634 format_description::parse("[year]-[month]-[day]").map_err(|err| CliError::InvalidDate {
635 input: value.to_string(),
636 reason: err.to_string(),
637 })?;
638
639 Date::parse(value, &format).map_err(|err| CliError::InvalidDate {
640 input: value.to_string(),
641 reason: err.to_string(),
642 })
643 }
644
645 fn display_arg(value: &OsStr) -> String {
646 value.to_string_lossy().into_owned()
647 }
648
649 fn io_error_exit(stderr: &mut impl Write, err: io::Error) -> std::process::ExitCode {
650 let _ = writeln!(stderr, "error: failed to write output: {err}");
651 std::process::ExitCode::FAILURE
652 }
653
654 fn local_event_error_exit(
655 stderr: &mut impl Write,
656 err: LocalEventStoreError,
657 ) -> std::process::ExitCode {
658 let _ = writeln!(stderr, "error: failed to load local events: {err}");
659 std::process::ExitCode::from(2)
660 }
661
662 #[cfg(test)]
663 mod tests {
664 use super::*;
665 use crate::calendar::CalendarMonth;
666 use time::Month;
667
668 fn date(year: i32, month: Month, day: u8) -> CalendarDate {
669 CalendarDate::from_ymd(year, month, day).expect("valid test date")
670 }
671
672 fn arg(value: &str) -> OsString {
673 OsString::from(value)
674 }
675
676 #[test]
677 fn no_args_uses_provided_today() {
678 let today = date(2026, Month::April, 23);
679
680 let action = parse_args([], today.into()).expect("parse succeeds");
681
682 assert_eq!(action, CliAction::Run(AppConfig::new(today)));
683 }
684
685 #[test]
686 fn date_flag_sets_start_date() {
687 let today = date(2026, Month::April, 23);
688
689 let action =
690 parse_args([arg("--date"), arg("2027-01-02")], today.into()).expect("parse succeeds");
691
692 assert_eq!(
693 action,
694 CliAction::Run(AppConfig::new(date(2027, Month::January, 2)))
695 );
696 }
697
698 #[test]
699 fn date_equals_form_sets_start_date() {
700 let today = date(2026, Month::April, 23);
701
702 let action = parse_args([arg("--date=2027-01-02")], today.into()).expect("parse succeeds");
703
704 assert_eq!(
705 action,
706 CliAction::Run(AppConfig::new(date(2027, Month::January, 2)))
707 );
708 }
709
710 #[test]
711 fn holiday_source_flag_sets_provider() {
712 let today = date(2026, Month::April, 23);
713
714 let action = parse_args([arg("--holiday-source"), arg("off")], today.into())
715 .expect("parse succeeds");
716
717 assert_eq!(
718 action,
719 CliAction::Run(AppConfig {
720 start_date: today,
721 holiday_source: HolidaySourceConfig::Off,
722 holiday_country: "US".to_string(),
723 ..AppConfig::new(today)
724 })
725 );
726 }
727
728 #[test]
729 fn nager_holiday_source_accepts_country_code() {
730 let today = date(2026, Month::April, 23);
731
732 let action = parse_args(
733 [
734 arg("--holiday-source=nager"),
735 arg("--holiday-country"),
736 arg("gb"),
737 ],
738 today.into(),
739 )
740 .expect("parse succeeds");
741
742 assert_eq!(
743 action,
744 CliAction::Run(AppConfig {
745 start_date: today,
746 holiday_source: HolidaySourceConfig::Nager,
747 holiday_country: "GB".to_string(),
748 ..AppConfig::new(today)
749 })
750 );
751 }
752
753 #[test]
754 fn nager_holiday_country_can_precede_source() {
755 let today = date(2026, Month::April, 23);
756
757 let action = parse_args(
758 [
759 arg("--holiday-country=ca"),
760 arg("--holiday-source"),
761 arg("nager"),
762 ],
763 today.into(),
764 )
765 .expect("parse succeeds");
766
767 assert_eq!(
768 action,
769 CliAction::Run(AppConfig {
770 start_date: today,
771 holiday_source: HolidaySourceConfig::Nager,
772 holiday_country: "CA".to_string(),
773 ..AppConfig::new(today)
774 })
775 );
776 }
777
778 #[test]
779 fn events_file_flag_sets_path() {
780 let today = date(2026, Month::April, 23);
781 let path = PathBuf::from("/tmp/rcal-test-events.json");
782
783 let action = parse_args(
784 [arg("--events-file"), arg("/tmp/rcal-test-events.json")],
785 today.into(),
786 )
787 .expect("parse succeeds");
788
789 assert_eq!(
790 action,
791 CliAction::Run(AppConfig {
792 start_date: today,
793 events_file: path,
794 ..AppConfig::new(today)
795 })
796 );
797 }
798
799 #[test]
800 fn events_file_options_are_rejected_when_invalid() {
801 let today = date(2026, Month::April, 23);
802
803 assert_eq!(
804 parse_args([arg("--events-file")], today.into()).expect_err("missing path fails"),
805 CliError::MissingEventsFileValue
806 );
807 assert_eq!(
808 parse_args(
809 [
810 arg("--events-file"),
811 arg("/tmp/one.json"),
812 arg("--events-file=/tmp/two.json"),
813 ],
814 today.into(),
815 )
816 .expect_err("duplicate path fails"),
817 CliError::DuplicateEventsFile
818 );
819 }
820
821 #[test]
822 fn invalid_holiday_options_are_rejected() {
823 let today = date(2026, Month::April, 23);
824
825 assert_eq!(
826 parse_args([arg("--holiday-source"), arg("network")], today.into())
827 .expect_err("invalid source fails"),
828 CliError::InvalidHolidaySource("network".to_string())
829 );
830 assert_eq!(
831 parse_args([arg("--holiday-country"), arg("USA")], today.into())
832 .expect_err("invalid country fails"),
833 CliError::InvalidHolidayCountry("USA".to_string())
834 );
835 }
836
837 #[test]
838 fn holiday_country_requires_nager_source() {
839 let today = date(2026, Month::April, 23);
840
841 assert_eq!(
842 parse_args([arg("--holiday-country"), arg("GB")], today.into())
843 .expect_err("country without Nager fails"),
844 CliError::HolidayCountryRequiresNager
845 );
846 assert_eq!(
847 parse_args(
848 [
849 arg("--holiday-source"),
850 arg("off"),
851 arg("--holiday-country"),
852 arg("GB"),
853 ],
854 today.into(),
855 )
856 .expect_err("country with non-Nager source fails"),
857 CliError::HolidayCountryRequiresNager
858 );
859 }
860
861 #[test]
862 fn invalid_date_is_rejected() {
863 let today = date(2026, Month::April, 23);
864
865 let err = parse_args([arg("--date"), arg("2026-02-30")], today.into())
866 .expect_err("invalid dates fail");
867
868 assert!(matches!(err, CliError::InvalidDate { .. }));
869 }
870
871 #[test]
872 fn missing_date_value_is_rejected() {
873 let today = date(2026, Month::April, 23);
874
875 let err = parse_args([arg("--date")], today.into()).expect_err("missing values fail");
876
877 assert_eq!(err, CliError::MissingDateValue);
878 }
879
880 #[test]
881 fn duplicate_date_is_rejected() {
882 let today = date(2026, Month::April, 23);
883
884 let err = parse_args(
885 [
886 arg("--date"),
887 arg("2026-04-23"),
888 arg("--date"),
889 arg("2026-04-24"),
890 ],
891 today.into(),
892 )
893 .expect_err("duplicate dates fail");
894
895 assert_eq!(err, CliError::DuplicateDate);
896 }
897
898 #[test]
899 fn date_flag_seeds_calendar_month_selection() {
900 let today = date(2026, Month::April, 23);
901
902 let action =
903 parse_args([arg("--date"), arg("2027-01-02")], today.into()).expect("parse succeeds");
904 let CliAction::Run(config) = action else {
905 panic!("date flag should produce run config");
906 };
907
908 let month = CalendarMonth::for_launch_date(config.start_date);
909
910 assert_eq!(month.current.year, 2027);
911 assert_eq!(month.current.month, Month::January);
912 assert_eq!(
913 month.selected_cell().map(|cell| cell.date),
914 Some(date(2027, Month::January, 2))
915 );
916 }
917 }
918