1mod auth;
2mod catalog;
3mod cli;
4mod codeforces_support;
5mod community;
6mod contests;
7mod discussions;
8mod domain;
9mod error;
10mod exercism_cli;
11mod math;
12mod output;
13mod platform_auth;
14mod possum;
15mod problems;
16mod public_stats;
17mod reader;
18mod solve;
19mod stats;
20mod theme;
21
22use std::{
23 io::{IsTerminal, Write},
24 time::Duration,
25};
26
27use clap::Parser;
28use indicatif::{ProgressBar, ProgressStyle};
29
30pub use error::Error;
31
32enum Content {
33 AuthLogout(domain::AuthLogout),
34 Auth(domain::AuthStatus),
35 CatalogList(domain::CatalogProblemList),
36 CatalogProblem(domain::CatalogProblem),
37 CodeforcesContest(codeforces_support::CodeforcesContest),
38 CodeforcesContests(codeforces_support::CodeforcesContestList),
39 CodeforcesStats(codeforces_support::CodeforcesStats),
40 CommunityDiscussion(domain::CommunityDiscussion),
41 CommunityDiscussions(domain::CommunityDiscussionList),
42 Contest(domain::Contest),
43 ContestRegistration(domain::ContestRegistration),
44 Contests(domain::ContestList),
45 Discussion(domain::Discussion),
46 Discussions(domain::DiscussionList),
47 List(domain::ProblemList),
48 Problem(domain::Problem),
49 PublicStats(domain::PublicAccountStats),
50 Search(domain::ProblemSearch),
51 Solution(domain::SolutionFile),
52 Stats(domain::AccountStats),
53 Submission(domain::SubmissionResult),
54 Test(domain::TestResult),
55 ToolAction(domain::ToolAction),
56}
57
58pub async fn run() -> Result<(), Error> {
59 let cli = cli::Cli::parse();
60 let platform = cli.platform;
61 if let cli::Command::Problem { command } = &cli.command {
62 match (platform, command) {
63 (
64 cli::Platform::LeetCode,
65 cli::ProblemCommand::List { track: Some(_), .. }
66 | cli::ProblemCommand::Show { track: Some(_), .. }
67 | cli::ProblemCommand::Pick { track: Some(_), .. },
68 ) => {
69 return Err(Error::UnsupportedPlatformCommand {
70 platform: platform.label(),
71 command: "problem browsing with --track",
72 });
73 }
74 (platform, cli::ProblemCommand::List { topic: Some(_), .. })
75 if platform != cli::Platform::HackerEarth =>
76 {
77 return Err(Error::UnsupportedPlatformCommand {
78 platform: platform.label(),
79 command: "problem list with --topic",
80 });
81 }
82 (cli::Platform::LeetCode, cli::ProblemCommand::List { page: Some(_), .. }) => {
83 return Err(Error::UnsupportedPlatformCommand {
84 platform: platform.label(),
85 command: "problem list with --page",
86 });
87 }
88 (cli::Platform::LeetCode, cli::ProblemCommand::List { recent: true, .. }) => {
89 return Err(Error::UnsupportedPlatformCommand {
90 platform: platform.label(),
91 command: "problem list with --recent",
92 });
93 }
94 (
95 cli::Platform::CodeChef,
96 cli::ProblemCommand::List {
97 difficulty,
98 tag,
99 track,
100 topic,
101 page,
102 recent,
103 },
104 ) if difficulty.is_some()
105 || tag.is_some()
106 || track.is_some()
107 || topic.is_some()
108 || page.is_some()
109 || *recent =>
110 {
111 return Err(Error::UnsupportedPlatformCommand {
112 platform: platform.label(),
113 command: "problem list filters",
114 });
115 }
116 (
117 cli::Platform::HackerEarth,
118 cli::ProblemCommand::List {
119 difficulty,
120 tag,
121 track,
122 recent,
123 ..
124 },
125 ) if difficulty.is_some() || tag.is_some() || track.is_some() || *recent => {
126 return Err(Error::UnsupportedPlatformCommand {
127 platform: platform.label(),
128 command: "problem list with --difficulty, --tag, --track or --recent",
129 });
130 }
131 (
132 cli::Platform::Codeforces,
133 cli::ProblemCommand::List {
134 difficulty,
135 tag,
136 track,
137 recent,
138 ..
139 },
140 ) if difficulty.is_some() || tag.is_some() || track.is_some() || *recent => {
141 return Err(Error::UnsupportedPlatformCommand {
142 platform: platform.label(),
143 command: "problem list with --difficulty, --tag, --track or --recent",
144 });
145 }
146 (
147 cli::Platform::HackerRank,
148 cli::ProblemCommand::List {
149 difficulty,
150 tag,
151 recent,
152 ..
153 },
154 ) if difficulty.is_some() || tag.is_some() || *recent => {
155 return Err(Error::UnsupportedPlatformCommand {
156 platform: platform.label(),
157 command: "problem list with --difficulty, --tag or --recent",
158 });
159 }
160 (
161 cli::Platform::ProjectEuler,
162 cli::ProblemCommand::List {
163 difficulty,
164 tag,
165 track,
166 ..
167 },
168 ) if difficulty.is_some() || tag.is_some() || track.is_some() => {
169 return Err(Error::UnsupportedPlatformCommand {
170 platform: platform.label(),
171 command: "problem list with --difficulty, --tag or --track",
172 });
173 }
174 (
175 cli::Platform::Exercism,
176 cli::ProblemCommand::List {
177 difficulty,
178 tag,
179 page,
180 recent,
181 ..
182 },
183 ) if difficulty.is_some() || tag.is_some() || page.is_some() || *recent => {
184 return Err(Error::UnsupportedPlatformCommand {
185 platform: platform.label(),
186 command: "problem list with --difficulty, --tag or --page",
187 });
188 }
189 (
190 cli::Platform::CodeChef
191 | cli::Platform::Codeforces
192 | cli::Platform::HackerEarth
193 | cli::Platform::HackerRank
194 | cli::Platform::ProjectEuler,
195 cli::ProblemCommand::Show { track: Some(_), .. },
196 ) => {
197 return Err(Error::UnsupportedPlatformCommand {
198 platform: platform.label(),
199 command: "problem show with --track",
200 });
201 }
202 (
203 cli::Platform::CodeChef
204 | cli::Platform::Codeforces
205 | cli::Platform::HackerEarth
206 | cli::Platform::HackerRank
207 | cli::Platform::ProjectEuler,
208 cli::ProblemCommand::Pick { track: Some(_), .. },
209 ) => {
210 return Err(Error::UnsupportedPlatformCommand {
211 platform: platform.label(),
212 command: "problem pick with --track",
213 });
214 }
215 (
216 cli::Platform::Exercism,
217 cli::ProblemCommand::List { track: None, .. }
218 | cli::ProblemCommand::Show { track: None, .. }
219 | cli::ProblemCommand::Pick { track: None, .. },
220 ) => return Err(Error::ExercismTrackRequired),
221 (cli::Platform::Exercism, cli::ProblemCommand::Pick { lang: Some(_), .. }) => {
222 return Err(Error::UnsupportedPlatformCommand {
223 platform: platform.label(),
224 command: "problem pick with --lang; the Exercism track chooses the language",
225 });
226 }
227 _ => {}
228 }
229 }
230 let activity = match &cli.command {
231 cli::Command::Auth { command } => match command {
232 cli::AuthCommand::Login => format!("Configuring {} credentials", platform.label()),
233 cli::AuthCommand::Logout => format!("Removing {} credentials", platform.label()),
234 cli::AuthCommand::Status => format!("Checking {} credentials", platform.label()),
235 },
236 cli::Command::Contest { command } => match command {
237 cli::ContestCommand::Create => "Checking contest creation support".to_owned(),
238 cli::ContestCommand::Delete { contest } => {
239 format!("Checking whether {} can be deleted", contest.as_ref())
240 }
241 cli::ContestCommand::Edit { contest } => {
242 format!("Checking whether {} can be edited", contest.as_ref())
243 }
244 cli::ContestCommand::Join { contest } => {
245 format!("Checking registration for {}", contest.as_ref())
246 }
247 cli::ContestCommand::Leave { contest } => {
248 format!("Checking registration for {}", contest.as_ref())
249 }
250 cli::ContestCommand::List => "Finding upcoming contests".to_owned(),
251 cli::ContestCommand::Show { contest } => {
252 format!("Loading contest {}", contest.as_ref())
253 }
254 cli::ContestCommand::Status { contest } => {
255 format!("Checking registration for {}", contest.as_ref())
256 }
257 },
258 cli::Command::Discussion { command } => match command {
259 cli::DiscussionCommand::Create {
260 problem: Some(problem),
261 } => match problem {
262 domain::ProblemSelector::Number(number) => {
263 format!("Checking discussion support for problem {number}")
264 }
265 domain::ProblemSelector::Slug(id) => {
266 format!("Checking discussion support for {}", id.as_ref())
267 }
268 },
269 cli::DiscussionCommand::Create { problem: None } => {
270 "Checking discussion creation support".to_owned()
271 }
272 cli::DiscussionCommand::Delete { discussion } => {
273 format!(
274 "Checking whether discussion {} can be deleted",
275 discussion.get()
276 )
277 }
278 cli::DiscussionCommand::Edit { discussion } => {
279 format!(
280 "Checking whether discussion {} can be edited",
281 discussion.get()
282 )
283 }
284 cli::DiscussionCommand::List {
285 problem: Some(problem),
286 } => match problem {
287 domain::ProblemSelector::Number(number) => {
288 format!("Finding discussions for problem {number}")
289 }
290 domain::ProblemSelector::Slug(id) => {
291 format!("Finding discussions for {}", id.as_ref())
292 }
293 },
294 cli::DiscussionCommand::List { problem: None } => {
295 "Finding trending discussions".to_owned()
296 }
297 cli::DiscussionCommand::Show { discussion } => {
298 format!("Loading discussion {}", discussion.get())
299 }
300 cli::DiscussionCommand::Reply { discussion } => {
301 format!("Checking reply support for discussion {}", discussion.get())
302 }
303 },
304 cli::Command::Problem { command } => match command {
305 cli::ProblemCommand::Daily => "Loading the Daily Challenge".to_owned(),
306 cli::ProblemCommand::List { .. } => {
307 format!("Browsing {} problems", platform.label())
308 }
309 cli::ProblemCommand::Pick { .. } => "Fetching starter code".to_owned(),
310 cli::ProblemCommand::Test(_) if platform == cli::Platform::LeetCode => {
311 "Running LeetCode examples".to_owned()
312 }
313 cli::ProblemCommand::Test(_) if platform == cli::Platform::Exercism => {
314 "Running Exercism tests".to_owned()
315 }
316 cli::ProblemCommand::Test(_) => "Checking platform test support".to_owned(),
317 cli::ProblemCommand::Submit(_) if platform == cli::Platform::Exercism => {
318 "Submitting with Exercism".to_owned()
319 }
320 cli::ProblemCommand::Submit(_) => "Submitting solution".to_owned(),
321 cli::ProblemCommand::Show { problem, .. } => match problem {
322 domain::ProblemSelector::Number(number) => {
323 format!("Loading {} problem {number}", platform.label())
324 }
325 domain::ProblemSelector::Slug(id) => format!("Loading {}", id.as_ref()),
326 },
327 cli::ProblemCommand::Search { query } => format!("Searching {}", query.as_ref()),
328 },
329 cli::Command::Stats { user } => user.as_ref().map_or_else(
330 || format!("Loading {} account stats", platform.label()),
331 |user| format!("Loading stats for {}", user.as_ref()),
332 ),
333 };
334 let auth_prompt = matches!(
335 &cli.command,
336 cli::Command::Auth {
337 command: cli::AuthCommand::Login | cli::AuthCommand::Logout
338 }
339 );
340 let mut presentation = output::Presentation::detect(cli.color, cli.heading_size);
341 presentation.motion = !cli.no_animation;
342 let terminal = std::io::stdout().is_terminal()
343 && matches!(cli.format, cli::Format::Text)
344 && std::env::var("TERM").as_deref() != Ok("dumb");
345 presentation.palette = cli.theme.palette(theme::background(
346 cli.background,
347 terminal && presentation.color,
348 cli.theme,
349 ));
350 let accent = presentation.accent();
351 let frames =
352 possum::loading_frames(presentation.columns, presentation.color, !cli.no_animation);
353 let frame_refs: Vec<_> = frames.iter().map(String::as_str).collect();
354 let spinner_style = ProgressStyle::with_template(&format!(
355 "{{spinner}}\n{accent}POSSUM// LINK{accent:#} {{msg}}"
356 ))
357 .map_err(std::io::Error::other)?
358 .tick_strings(&frame_refs);
359 let bar_art = possum::render(
360 possum::Pose::Idle,
361 presentation.columns.min(30),
362 presentation.color,
363 );
364 let bar_template = match bar_art {
365 Some(art) => {
366 format!("{art}\n{accent}POSSUM// LOAD [{{bar:16}}]{accent:#} {{bytes}}/{{total_bytes}}")
367 }
368 None => format!("{accent}POSSUM// LOAD [{{bar:16}}]{accent:#} {{bytes}}/{{total_bytes}}"),
369 };
370 let bar_style = ProgressStyle::with_template(&bar_template)
371 .map_err(std::io::Error::other)?
372 .progress_chars("█▉▊▋▌▍▎▏ ");
373 let progress = if terminal && std::io::stderr().is_terminal() && !auth_prompt {
374 let progress = ProgressBar::new_spinner();
375 progress.set_style(spinner_style.clone());
376 progress.set_message(activity);
377 if !cli.no_animation {
378 progress.enable_steady_tick(Duration::from_millis(260));
379 } else {
380 progress.tick();
381 }
382 Some(progress)
383 } else {
384 None
385 };
386 let result = match (cli.platform, cli.command) {
387 (
388 cli::Platform::Codeforces,
389 cli::Command::Problem {
390 command: cli::ProblemCommand::List { page, .. },
391 },
392 ) => catalog::list(
393 domain::CatalogPlatform::Codeforces,
394 page,
395 None,
396 None,
397 |bytes, total| update_progress(&progress, &bar_style, bytes, total),
398 )
399 .await
400 .map(Content::CatalogList),
401 (
402 cli::Platform::HackerRank,
403 cli::Command::Problem {
404 command: cli::ProblemCommand::List { page, track, .. },
405 },
406 ) => catalog::list(
407 domain::CatalogPlatform::HackerRank,
408 page,
409 track,
410 None,
411 |bytes, total| update_progress(&progress, &bar_style, bytes, total),
412 )
413 .await
414 .map(Content::CatalogList),
415 (
416 cli::Platform::Codeforces,
417 cli::Command::Problem {
418 command: cli::ProblemCommand::Show { problem, .. },
419 },
420 ) => match catalog::problem_id(domain::CatalogPlatform::Codeforces, problem) {
421 Ok(id) => catalog::show(domain::CatalogPlatform::Codeforces, id, |bytes, total| {
422 update_progress(&progress, &bar_style, bytes, total)
423 })
424 .await
425 .map(Content::CatalogProblem),
426 Err(error) => Err(error),
427 },
428 (
429 cli::Platform::ProjectEuler,
430 cli::Command::Problem {
431 command: cli::ProblemCommand::List { recent: true, .. },
432 },
433 ) => catalog::project_euler_recent(|bytes, total| {
434 update_progress(&progress, &bar_style, bytes, total)
435 })
436 .await
437 .map(Content::CatalogList),
438 (
439 cli::Platform::ProjectEuler,
440 cli::Command::Problem {
441 command:
442 cli::ProblemCommand::List {
443 page,
444 recent: false,
445 ..
446 },
447 },
448 ) => catalog::list(
449 domain::CatalogPlatform::ProjectEuler,
450 page,
451 None,
452 None,
453 |bytes, total| update_progress(&progress, &bar_style, bytes, total),
454 )
455 .await
456 .map(Content::CatalogList),
457 (
458 cli::Platform::ProjectEuler,
459 cli::Command::Problem {
460 command: cli::ProblemCommand::Show { problem, .. },
461 },
462 ) => match catalog::problem_id(domain::CatalogPlatform::ProjectEuler, problem) {
463 Ok(id) => catalog::show(domain::CatalogPlatform::ProjectEuler, id, |bytes, total| {
464 update_progress(&progress, &bar_style, bytes, total)
465 })
466 .await
467 .map(Content::CatalogProblem),
468 Err(error) => Err(error),
469 },
470 (
471 cli::Platform::Exercism,
472 cli::Command::Problem {
473 command: cli::ProblemCommand::List { track, .. },
474 },
475 ) => {
476 let _ = track;
477 Err(Error::CapabilityUnavailable {
478 platform: "Exercism",
479 capability: "a terminal problem catalogue; use `problem pick --track <track>` with a known exercise slug",
480 })
481 }
482 (
483 cli::Platform::Exercism,
484 cli::Command::Problem {
485 command: cli::ProblemCommand::Show { problem, track, .. },
486 },
487 ) => {
488 let _ = (problem, track);
489 Err(Error::CapabilityUnavailable {
490 platform: "Exercism",
491 capability: "terminal problem details before an exercise is downloaded; `problem pick` downloads its README and tests",
492 })
493 }
494 (
495 cli::Platform::Exercism,
496 cli::Command::Problem {
497 command:
498 cli::ProblemCommand::Pick {
499 problem,
500 dir,
501 track: Some(track),
502 ..
503 },
504 },
505 ) => {
506 if matches!(cli.format, cli::Format::Json) {
507 Err(Error::CapabilityUnavailable {
508 platform: "Exercism",
509 capability: "JSON output for official CLI downloads",
510 })
511 } else {
512 let id = match problem {
513 domain::ProblemSelector::Slug(id) => Ok(id),
514 domain::ProblemSelector::Number(_) => Err(Error::InvalidCatalogProblemId {
515 platform: "Exercism",
516 expected: "the exercise slug from its URL",
517 }),
518 }?;
519 let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
520 let workspace = solve::choose_directory(dir, interactive)?;
521 std::fs::create_dir_all(&workspace).map_err(|source| Error::WorkspaceIo {
522 path: workspace.clone(),
523 source,
524 })?;
525 if let Some(progress) = &progress {
526 progress.finish_and_clear();
527 }
528 let exercise = exercism_cli::Client::new().download(&workspace, &track, &id)?;
529 let path = exercise
530 .solution_files
531 .first()
532 .map(|path| exercise.directory.join(path))
533 .ok_or(Error::CapabilityUnavailable {
534 platform: "Exercism",
535 capability: "a declared solution file for this exercise",
536 })?;
537 Ok(Content::Solution(domain::SolutionFile {
538 platform: "exercism".into(),
539 title: id.as_ref().into(),
540 language: track.as_ref().into(),
541 id,
542 path,
543 }))
544 }
545 }
546 (
547 cli::Platform::Exercism,
548 cli::Command::Problem {
549 command: cli::ProblemCommand::Test(target),
550 },
551 ) => {
552 if matches!(cli.format, cli::Format::Json) {
553 Err(Error::CapabilityUnavailable {
554 platform: "Exercism",
555 capability: "JSON output from the official CLI test runner",
556 })
557 } else {
558 let exercise = exercism_cli::discover_from_solution(&target.path)?;
559 if let Some(progress) = &progress {
560 progress.finish_and_clear();
561 }
562 exercism_cli::Client::new().test(&exercise)?;
563 Ok(Content::ToolAction(domain::ToolAction {
564 platform: "Exercism".into(),
565 action: "Tests complete".into(),
566 detail: exercise.directory.display().to_string().into(),
567 }))
568 }
569 }
570 (
571 cli::Platform::Exercism,
572 cli::Command::Problem {
573 command: cli::ProblemCommand::Submit(target),
574 },
575 ) => {
576 if matches!(cli.format, cli::Format::Json) {
577 Err(Error::CapabilityUnavailable {
578 platform: "Exercism",
579 capability: "JSON output from the official CLI submit command",
580 })
581 } else {
582 let exercise = exercism_cli::discover_from_solution(&target.path)?;
583 if let Some(progress) = &progress {
584 progress.finish_and_clear();
585 }
586 exercism_cli::Client::new().submit(&exercise)?;
587 Ok(Content::ToolAction(domain::ToolAction {
588 platform: "Exercism".into(),
589 action: "Submission sent".into(),
590 detail: "The official Exercism CLI accepted the solution files.".into(),
591 }))
592 }
593 }
594 (
595 cli::Platform::HackerEarth,
596 cli::Command::Problem {
597 command: cli::ProblemCommand::List { page, topic, .. },
598 },
599 ) => catalog::list(
600 domain::CatalogPlatform::HackerEarth,
601 page,
602 None,
603 topic,
604 |bytes, total| update_progress(&progress, &bar_style, bytes, total),
605 )
606 .await
607 .map(Content::CatalogList),
608 (
609 cli::Platform::HackerEarth,
610 cli::Command::Problem {
611 command: cli::ProblemCommand::Show { problem, .. },
612 },
613 ) => match catalog::problem_id(domain::CatalogPlatform::HackerEarth, problem) {
614 Ok(id) => catalog::show(domain::CatalogPlatform::HackerEarth, id, |bytes, total| {
615 update_progress(&progress, &bar_style, bytes, total)
616 })
617 .await
618 .map(Content::CatalogProblem),
619 Err(error) => Err(error),
620 },
621 (
622 cli::Platform::CodeChef,
623 cli::Command::Problem {
624 command: cli::ProblemCommand::List { .. },
625 },
626 ) => Err(Error::CapabilityUnavailable {
627 platform: "CodeChef",
628 capability: "a dependable public problem catalogue API",
629 }),
630 (
631 cli::Platform::CodeChef,
632 cli::Command::Problem {
633 command: cli::ProblemCommand::Show { problem, .. },
634 },
635 ) => {
636 let _ = problem;
637 Err(Error::CapabilityUnavailable {
638 platform: "CodeChef",
639 capability: "dependable public problem statements through a callable API",
640 })
641 }
642 (
643 cli::Platform::HackerRank,
644 cli::Command::Problem {
645 command: cli::ProblemCommand::Show { problem, .. },
646 },
647 ) => match catalog::problem_id(domain::CatalogPlatform::HackerRank, problem) {
648 Ok(id) => catalog::show(domain::CatalogPlatform::HackerRank, id, |bytes, total| {
649 update_progress(&progress, &bar_style, bytes, total)
650 })
651 .await
652 .map(Content::CatalogProblem),
653 Err(error) => Err(error),
654 },
655 (
656 cli::Platform::HackerRank,
657 cli::Command::Problem {
658 command:
659 cli::ProblemCommand::Pick {
660 problem, lang, dir, ..
661 },
662 },
663 ) => solve::pick_hackerrank(
664 problem,
665 lang,
666 dir,
667 |bytes, total| update_progress(&progress, &bar_style, bytes, total),
668 || {
669 if let Some(progress) = &progress {
670 progress.finish_and_clear();
671 }
672 },
673 )
674 .await
675 .map(Content::Solution),
676 (cli::Platform::Codeforces, cli::Command::Stats { user: Some(user) }) => {
677 codeforces_support::stats(user.as_ref(), |bytes, total| {
678 update_progress(&progress, &bar_style, bytes, total)
679 })
680 .await
681 .map(Content::CodeforcesStats)
682 }
683 (cli::Platform::Codeforces, cli::Command::Stats { user: None }) => {
684 Err(Error::AccountNameRequired {
685 platform: "Codeforces",
686 capability: "public profile stats",
687 })
688 }
689 (cli::Platform::CodeChef, cli::Command::Stats { user: Some(user) }) => {
690 public_stats::codechef(user.as_ref(), |bytes, total| {
691 update_progress(&progress, &bar_style, bytes, total)
692 })
693 .await
694 .map(Content::PublicStats)
695 }
696 (cli::Platform::CodeChef, cli::Command::Stats { user: None }) => {
697 Err(Error::AccountNameRequired {
698 platform: "CodeChef",
699 capability: "public profile stats",
700 })
701 }
702 (cli::Platform::HackerRank, cli::Command::Stats { user: Some(user) }) => {
703 public_stats::hackerrank(user.as_ref(), |bytes, total| {
704 update_progress(&progress, &bar_style, bytes, total)
705 })
706 .await
707 .map(Content::PublicStats)
708 }
709 (cli::Platform::HackerRank, cli::Command::Stats { user: None }) => {
710 Err(Error::AccountNameRequired {
711 platform: "HackerRank",
712 capability: "public profile stats",
713 })
714 }
715 (
716 cli::Platform::Codeforces,
717 cli::Command::Contest {
718 command: cli::ContestCommand::List,
719 },
720 ) => codeforces_support::contests(|bytes, total| {
721 update_progress(&progress, &bar_style, bytes, total)
722 })
723 .await
724 .map(Content::CodeforcesContests),
725 (
726 cli::Platform::Codeforces,
727 cli::Command::Contest {
728 command: cli::ContestCommand::Show { contest },
729 },
730 ) => {
731 let id = contest
732 .as_ref()
733 .parse::<u32>()
734 .ok()
735 .filter(|id| *id > 0)
736 .ok_or(Error::InvalidContestSlug)?;
737 codeforces_support::contest(id, |bytes, total| {
738 update_progress(&progress, &bar_style, bytes, total)
739 })
740 .await?
741 .map(Content::CodeforcesContest)
742 .ok_or_else(|| Error::ContestNotFound {
743 platform: "Codeforces",
744 id: id.to_string().into(),
745 })
746 }
747 (
748 cli::Platform::Codeforces,
749 cli::Command::Discussion {
750 command: cli::DiscussionCommand::List { problem: None },
751 },
752 ) => codeforces_support::discussions(|bytes, total| {
753 update_progress(&progress, &bar_style, bytes, total)
754 })
755 .await
756 .map(Content::CommunityDiscussions),
757 (
758 cli::Platform::Codeforces,
759 cli::Command::Discussion {
760 command: cli::DiscussionCommand::Show { discussion },
761 },
762 ) => codeforces_support::discussion(discussion, |bytes, total| {
763 update_progress(&progress, &bar_style, bytes, total)
764 })
765 .await
766 .map(Content::CommunityDiscussion),
767 (
768 cli::Platform::Codeforces,
769 cli::Command::Discussion {
770 command: cli::DiscussionCommand::List { problem: Some(_) },
771 },
772 ) => Err(Error::CapabilityUnavailable {
773 platform: "Codeforces",
774 capability: "problem-filtered blog discussions in its public API",
775 }),
776 (
777 cli::Platform::CodeChef,
778 cli::Command::Discussion {
779 command: cli::DiscussionCommand::List { problem: None },
780 },
781 ) => community::codechef_list(|bytes, total| {
782 update_progress(&progress, &bar_style, bytes, total)
783 })
784 .await
785 .map(Content::CommunityDiscussions),
786 (
787 cli::Platform::CodeChef,
788 cli::Command::Discussion {
789 command: cli::DiscussionCommand::Show { discussion },
790 },
791 ) => community::codechef_show(discussion, |bytes, total| {
792 update_progress(&progress, &bar_style, bytes, total)
793 })
794 .await
795 .map(Content::CommunityDiscussion),
796 (
797 cli::Platform::Codeforces,
798 cli::Command::Auth {
799 command: cli::AuthCommand::Login,
800 },
801 ) => platform_auth::codeforces_login(|bytes, total| {
802 update_progress(&progress, &bar_style, bytes, total)
803 })
804 .await
805 .map(Content::ToolAction),
806 (
807 cli::Platform::Codeforces,
808 cli::Command::Auth {
809 command: cli::AuthCommand::Logout,
810 },
811 ) => platform_auth::codeforces_logout().map(Content::ToolAction),
812 (
813 cli::Platform::Codeforces,
814 cli::Command::Auth {
815 command: cli::AuthCommand::Status,
816 },
817 ) => platform_auth::codeforces_status(|bytes, total| {
818 update_progress(&progress, &bar_style, bytes, total)
819 })
820 .await
821 .map(Content::ToolAction),
822 (
823 cli::Platform::Exercism,
824 cli::Command::Auth {
825 command: cli::AuthCommand::Login,
826 },
827 ) => platform_auth::exercism_login().map(Content::ToolAction),
828 (
829 cli::Platform::Exercism,
830 cli::Command::Auth {
831 command: cli::AuthCommand::Status,
832 },
833 ) => platform_auth::exercism_status().map(Content::ToolAction),
834 (
835 cli::Platform::Exercism,
836 cli::Command::Auth {
837 command: cli::AuthCommand::Logout,
838 },
839 ) => Err(Error::CapabilityUnavailable {
840 platform: "Exercism",
841 capability: "a documented logout operation in the official CLI",
842 }),
843 (
844 cli::Platform::LeetCode,
845 cli::Command::Auth {
846 command: cli::AuthCommand::Login,
847 },
848 ) => Err(Error::CapabilityUnavailable {
849 platform: "LeetCode",
850 capability: "a documented terminal-native authentication flow",
851 }),
852 (
853 cli::Platform::LeetCode,
854 cli::Command::Auth {
855 command: cli::AuthCommand::Logout,
856 },
857 ) => auth::logout().map(Content::AuthLogout),
858 (
859 cli::Platform::LeetCode,
860 cli::Command::Auth {
861 command: cli::AuthCommand::Status,
862 },
863 ) => auth::status().await.map(Content::Auth),
864 (
865 cli::Platform::LeetCode,
866 cli::Command::Contest {
867 command: cli::ContestCommand::Join { contest },
868 },
869 ) => {
870 let _ = contest;
871 Err(Error::CapabilityUnavailable {
872 platform: "LeetCode",
873 capability: "a verified contest registration mutation",
874 })
875 }
876 (
877 cli::Platform::LeetCode,
878 cli::Command::Contest {
879 command: cli::ContestCommand::Leave { contest },
880 },
881 ) => {
882 let _ = contest;
883 Err(Error::CapabilityUnavailable {
884 platform: "LeetCode",
885 capability: "a verified contest withdrawal mutation",
886 })
887 }
888 (
889 cli::Platform::LeetCode,
890 cli::Command::Contest {
891 command: cli::ContestCommand::List,
892 },
893 ) => contests::list(|bytes, total| update_progress(&progress, &bar_style, bytes, total))
894 .await
895 .map(Content::Contests),
896 (
897 cli::Platform::LeetCode,
898 cli::Command::Contest {
899 command: cli::ContestCommand::Show { contest },
900 },
901 ) => contests::show(contest, |bytes, total| {
902 update_progress(&progress, &bar_style, bytes, total)
903 })
904 .await
905 .map(Content::Contest),
906 (
907 cli::Platform::LeetCode,
908 cli::Command::Contest {
909 command: cli::ContestCommand::Status { contest },
910 },
911 ) => contests::status(contest, |bytes, total| {
912 update_progress(&progress, &bar_style, bytes, total)
913 })
914 .await
915 .map(Content::ContestRegistration),
916 (
917 cli::Platform::LeetCode,
918 cli::Command::Discussion {
919 command: cli::DiscussionCommand::Create { problem },
920 },
921 ) => {
922 let _ = problem;
923 Err(Error::CapabilityUnavailable {
924 platform: "LeetCode",
925 capability: "a verified discussion creation mutation",
926 })
927 }
928 (
929 cli::Platform::LeetCode,
930 cli::Command::Discussion {
931 command: cli::DiscussionCommand::Delete { discussion },
932 },
933 ) => {
934 let _ = discussion;
935 Err(Error::CapabilityUnavailable {
936 platform: "LeetCode",
937 capability: "a verified discussion deletion mutation",
938 })
939 }
940 (
941 cli::Platform::LeetCode,
942 cli::Command::Discussion {
943 command: cli::DiscussionCommand::Edit { discussion },
944 },
945 ) => {
946 let _ = discussion;
947 Err(Error::CapabilityUnavailable {
948 platform: "LeetCode",
949 capability: "a verified discussion editing mutation",
950 })
951 }
952 (
953 cli::Platform::LeetCode,
954 cli::Command::Discussion {
955 command: cli::DiscussionCommand::List { problem },
956 },
957 ) => discussions::list(problem, |bytes, total| {
958 update_progress(&progress, &bar_style, bytes, total)
959 })
960 .await
961 .map(Content::Discussions),
962 (
963 cli::Platform::LeetCode,
964 cli::Command::Discussion {
965 command: cli::DiscussionCommand::Show { discussion },
966 },
967 ) => discussions::show(discussion, |bytes, total| {
968 update_progress(&progress, &bar_style, bytes, total)
969 })
970 .await
971 .map(Content::Discussion),
972 (
973 cli::Platform::LeetCode,
974 cli::Command::Discussion {
975 command: cli::DiscussionCommand::Reply { discussion },
976 },
977 ) => {
978 let _ = discussion;
979 Err(Error::CapabilityUnavailable {
980 platform: "LeetCode",
981 capability: "a verified discussion reply mutation",
982 })
983 }
984 (
985 cli::Platform::LeetCode,
986 cli::Command::Problem {
987 command: cli::ProblemCommand::Daily,
988 },
989 ) => problems::daily(|bytes, total| update_progress(&progress, &bar_style, bytes, total))
990 .await
991 .map(Content::Problem),
992 (
993 cli::Platform::LeetCode,
994 cli::Command::Problem {
995 command:
996 cli::ProblemCommand::List {
997 difficulty, tag, ..
998 },
999 },
1000 ) => problems::list(difficulty.map(Into::into), tag, |bytes, total| {
1001 update_progress(&progress, &bar_style, bytes, total)
1002 })
1003 .await
1004 .map(Content::List),
1005 (
1006 cli::Platform::LeetCode,
1007 cli::Command::Problem {
1008 command: cli::ProblemCommand::Show { problem, .. },
1009 },
1010 ) => problems::show(problem, |bytes, total| {
1011 update_progress(&progress, &bar_style, bytes, total)
1012 })
1013 .await
1014 .map(Content::Problem),
1015 (
1016 cli::Platform::LeetCode,
1017 cli::Command::Problem {
1018 command: cli::ProblemCommand::Search { query },
1019 },
1020 ) => problems::search(query, |bytes, total| {
1021 update_progress(&progress, &bar_style, bytes, total)
1022 })
1023 .await
1024 .map(Content::Search),
1025 (
1026 cli::Platform::LeetCode,
1027 cli::Command::Problem {
1028 command:
1029 cli::ProblemCommand::Pick {
1030 problem, lang, dir, ..
1031 },
1032 },
1033 ) => solve::pick(
1034 problem,
1035 lang,
1036 dir,
1037 |bytes, total| update_progress(&progress, &bar_style, bytes, total),
1038 || {
1039 if let Some(progress) = &progress {
1040 progress.finish_and_clear();
1041 }
1042 },
1043 )
1044 .await
1045 .map(Content::Solution),
1046 (
1047 cli::Platform::LeetCode,
1048 cli::Command::Problem {
1049 command: cli::ProblemCommand::Test(target),
1050 },
1051 ) => solve::test(target.path, |bytes, total| {
1052 update_progress(&progress, &bar_style, bytes, total)
1053 })
1054 .await
1055 .map(Content::Test),
1056 (
1057 cli::Platform::LeetCode,
1058 cli::Command::Problem {
1059 command: cli::ProblemCommand::Submit(target),
1060 },
1061 ) => solve::submit(target.path).await.map(Content::Submission),
1062 (cli::Platform::LeetCode, cli::Command::Stats { user: None }) => {
1063 stats::load().await.map(Content::Stats)
1064 }
1065 (cli::Platform::LeetCode, cli::Command::Stats { user: Some(_) }) => {
1066 Err(Error::CapabilityUnavailable {
1067 platform: "LeetCode",
1068 capability: "public stats for another account",
1069 })
1070 }
1071 (
1072 cli::Platform::LeetCode,
1073 cli::Command::Contest {
1074 command:
1075 cli::ContestCommand::Create
1076 | cli::ContestCommand::Edit { .. }
1077 | cli::ContestCommand::Delete { .. },
1078 },
1079 ) => Err(Error::CapabilityUnavailable {
1080 platform: "LeetCode",
1081 capability: "self-service contest management",
1082 }),
1083 (platform, command) => Err(Error::UnsupportedPlatformCommand {
1084 platform: platform.label(),
1085 command: command.path(),
1086 }),
1087 };
1088 if let Some(progress) = &progress {
1089 progress.finish_and_clear();
1090 }
1091 let mut content = result?;
1092 let selection = if terminal
1093 && std::io::stdin().is_terminal()
1094 && presentation.interactive
1095 && reader::supported()
1096 {
1097 match &content {
1098 Content::CatalogList(list) if !list.results.is_empty() => {
1099 let (heading, detail) = output::catalog_list_header(list);
1100 Some(
1101 reader::browse_catalog(
1102 &heading,
1103 &detail,
1104 list.platform,
1105 &list.results,
1106 presentation,
1107 )?
1108 .map(|id| reader::Target::Catalog(list.platform, id)),
1109 )
1110 }
1111 Content::CodeforcesContests(list) if !list.contests.is_empty() => Some(
1112 reader::browse_codeforces_contests(
1113 &format!(
1114 "POSSUM//Contests {} Codeforces contests",
1115 list.contests.len()
1116 ),
1117 "Select a contest to inspect it",
1118 &list.contests,
1119 presentation,
1120 )?
1121 .map(reader::Target::CodeforcesContest),
1122 ),
1123 Content::CodeforcesStats(stats) if !stats.recent_submissions.is_empty() => Some(
1124 reader::browse_codeforces_submissions(
1125 &format!("POSSUM//Stats {}", stats.profile.handle),
1126 &format!(
1127 "Rating {} · {} solved in the latest {} submissions",
1128 stats
1129 .profile
1130 .rating
1131 .map_or_else(|| "unrated".to_owned(), |rating| rating.to_string()),
1132 stats.recent_solved_count,
1133 stats.recent_submissions.len()
1134 ),
1135 &stats.recent_submissions,
1136 presentation,
1137 )?
1138 .map(|id| reader::Target::Catalog(domain::CatalogPlatform::Codeforces, id)),
1139 ),
1140 Content::CommunityDiscussions(list) if !list.discussions.is_empty() => Some(
1141 reader::browse_community_discussions(
1142 &format!(
1143 "POSSUM//Discuss {} {} topics",
1144 list.discussions.len(),
1145 list.platform
1146 ),
1147 "Select a discussion to read it",
1148 &list.discussions,
1149 presentation,
1150 )?
1151 .map(|id| {
1152 let platform = if list.platform.as_ref() == "Codeforces" {
1153 cli::Platform::Codeforces
1154 } else {
1155 cli::Platform::CodeChef
1156 };
1157 reader::Target::CommunityDiscussion(platform, id)
1158 }),
1159 ),
1160 Content::List(list) if !list.results.is_empty() => {
1161 let (heading, detail) = output::list_header(list);
1162 Some(
1163 reader::browse_problems(&heading, &detail, &list.results, presentation)?
1164 .map(reader::Target::Problem),
1165 )
1166 }
1167 Content::Search(search) if !search.results.is_empty() => {
1168 let (heading, detail) = output::search_header(search);
1169 Some(
1170 reader::browse_problems(&heading, &detail, &search.results, presentation)?
1171 .map(reader::Target::Problem),
1172 )
1173 }
1174 Content::Contests(list) if !list.contests.is_empty() => {
1175 let noun = if list.contests.len() == 1 {
1176 "contest"
1177 } else {
1178 "contests"
1179 };
1180 let heading = format!("POSSUM//Contests {} upcoming {noun}", list.contests.len());
1181 Some(
1182 reader::browse_contests(
1183 &heading,
1184 "Select a contest to open its schedule",
1185 &list.contests,
1186 presentation,
1187 )?
1188 .map(reader::Target::Contest),
1189 )
1190 }
1191 Content::Discussions(list) if !list.discussions.is_empty() => {
1192 let shown = list.discussions.len();
1193 let heading = match (list.problem.as_ref(), list.total) {
1194 (Some(_), Some(total)) => {
1195 format!("POSSUM//Discuss {shown} of {total} posts")
1196 }
1197 (Some(_), None) => format!("POSSUM//Discuss {shown} posts"),
1198 (None, _) => format!("POSSUM//Discuss {shown} trending posts"),
1199 };
1200 let detail = list.problem.as_ref().map_or_else(
1201 || "Select a discussion to read it".to_owned(),
1202 |problem| format!("Problem: leetcode/{}", problem.as_ref()),
1203 );
1204 Some(
1205 reader::browse_discussions(&heading, &detail, &list.discussions, presentation)?
1206 .map(reader::Target::Discussion),
1207 )
1208 }
1209 Content::Stats(stats) if !stats.recent_submissions.is_empty() => {
1210 let heading = format!("POSSUM//Stats {}", stats.username);
1211 let detail = format!(
1212 "{} solved · {} accepted · {} attempts · recent submissions",
1213 stats.solved.all, stats.accepted_submissions.all, stats.submissions.all
1214 );
1215 Some(
1216 reader::browse_submissions(
1217 &heading,
1218 &detail,
1219 &stats.recent_submissions,
1220 presentation,
1221 )?
1222 .map(reader::Target::Problem),
1223 )
1224 }
1225 _ => None,
1226 }
1227 } else {
1228 None
1229 };
1230 match selection {
1231 Some(Some(target)) => {
1232 if let Some(progress) = &progress {
1233 progress.set_style(spinner_style.clone());
1234 progress.reset();
1235 let activity = match &target {
1236 reader::Target::Problem(id) => format!("Loading {}", id.as_ref()),
1237 reader::Target::Catalog(platform, id) => {
1238 format!("Loading {} on {}", id.as_ref(), platform.label())
1239 }
1240 reader::Target::Contest(id) => {
1241 format!("Loading contest {}", id.as_ref())
1242 }
1243 reader::Target::CodeforcesContest(id) => {
1244 format!("Loading Codeforces contest {id}")
1245 }
1246 reader::Target::Discussion(id) => {
1247 format!("Loading discussion {}", id.get())
1248 }
1249 reader::Target::CommunityDiscussion(_, id) => {
1250 format!("Loading discussion {}", id.get())
1251 }
1252 };
1253 progress.set_message(activity);
1254 if presentation.motion {
1255 progress.enable_steady_tick(Duration::from_millis(260));
1256 } else {
1257 progress.tick();
1258 }
1259 }
1260 let opened = match target {
1261 reader::Target::Problem(id) => {
1262 problems::show(domain::ProblemSelector::Slug(id), |bytes, total| {
1263 update_progress(&progress, &bar_style, bytes, total)
1264 })
1265 .await
1266 .map(Content::Problem)
1267 }
1268 reader::Target::Catalog(platform, id) => {
1269 let cached = match &content {
1270 Content::CatalogList(list)
1271 if platform == domain::CatalogPlatform::Codeforces =>
1272 {
1273 list.results
1274 .iter()
1275 .find(|problem| problem.id.as_ref() == id.as_ref())
1276 .map(|problem| catalog::metadata_problem(platform, problem))
1277 }
1278 _ => None,
1279 };
1280 if let Some(problem) = cached {
1281 Ok(Content::CatalogProblem(problem))
1282 } else {
1283 catalog::show(platform, id, |bytes, total| {
1284 update_progress(&progress, &bar_style, bytes, total)
1285 })
1286 .await
1287 .map(Content::CatalogProblem)
1288 }
1289 }
1290 reader::Target::Contest(id) => contests::show(id, |bytes, total| {
1291 update_progress(&progress, &bar_style, bytes, total)
1292 })
1293 .await
1294 .map(Content::Contest),
1295 reader::Target::CodeforcesContest(id) => {
1296 codeforces_support::contest(id, |bytes, total| {
1297 update_progress(&progress, &bar_style, bytes, total)
1298 })
1299 .await?
1300 .map(Content::CodeforcesContest)
1301 .ok_or_else(|| Error::ContestNotFound {
1302 platform: "Codeforces",
1303 id: id.to_string().into(),
1304 })
1305 }
1306 reader::Target::Discussion(id) => discussions::show(id, |bytes, total| {
1307 update_progress(&progress, &bar_style, bytes, total)
1308 })
1309 .await
1310 .map(Content::Discussion),
1311 reader::Target::CommunityDiscussion(platform, id) => match platform {
1312 cli::Platform::Codeforces => {
1313 codeforces_support::discussion(id, |bytes, total| {
1314 update_progress(&progress, &bar_style, bytes, total)
1315 })
1316 .await
1317 .map(Content::CommunityDiscussion)
1318 }
1319 cli::Platform::CodeChef => community::codechef_show(id, |bytes, total| {
1320 update_progress(&progress, &bar_style, bytes, total)
1321 })
1322 .await
1323 .map(Content::CommunityDiscussion),
1324 _ => unreachable!("community reader only supports native community clients"),
1325 },
1326 };
1327 if let Some(progress) = &progress {
1328 progress.finish_and_clear();
1329 }
1330 content = opened?;
1331 }
1332 Some(None) => return Ok(()),
1333 None => {}
1334 }
1335 let color = if presentation.color || presentation.large_heading {
1336 anstream::ColorChoice::AlwaysAnsi
1337 } else {
1338 anstream::ColorChoice::Never
1339 };
1340 let stdout = anstream::AutoStream::new(std::io::stdout(), color);
1341 let mut stdout = stdout.lock();
1342 match content {
1343 Content::AuthLogout(logout) => {
1344 output::auth_logout(&mut stdout, cli.format, &logout, presentation)
1345 }
1346 Content::Auth(status) => {
1347 output::auth_status(&mut stdout, cli.format, &status, presentation)
1348 }
1349 Content::CatalogList(list) => {
1350 output::catalog_list(&mut stdout, cli.format, &list, presentation)
1351 }
1352 Content::CatalogProblem(problem) => {
1353 output::catalog_problem(&mut stdout, cli.format, &problem, presentation)
1354 }
1355 Content::CodeforcesContest(contest) => {
1356 output::codeforces_contest(&mut stdout, cli.format, &contest, presentation)
1357 }
1358 Content::CodeforcesContests(contests) => {
1359 output::codeforces_contests(&mut stdout, cli.format, &contests, presentation)
1360 }
1361 Content::CodeforcesStats(stats) => {
1362 output::codeforces_stats(&mut stdout, cli.format, &stats, presentation)
1363 }
1364 Content::CommunityDiscussion(discussion) => {
1365 output::community_discussion(&mut stdout, cli.format, &discussion, presentation)
1366 }
1367 Content::CommunityDiscussions(discussions) => {
1368 output::community_discussions(&mut stdout, cli.format, &discussions, presentation)
1369 }
1370 Content::Contest(contest) => {
1371 output::contest(&mut stdout, cli.format, &contest, presentation)
1372 }
1373 Content::ContestRegistration(registration) => {
1374 output::contest_registration(&mut stdout, cli.format, ®istration, presentation)
1375 }
1376 Content::Contests(contests) => {
1377 output::contests(&mut stdout, cli.format, &contests, presentation)
1378 }
1379 Content::Discussion(discussion) => {
1380 output::discussion(&mut stdout, cli.format, &discussion, presentation)
1381 }
1382 Content::Discussions(discussions) => {
1383 output::discussions(&mut stdout, cli.format, &discussions, presentation)
1384 }
1385 Content::List(list) => output::list(&mut stdout, cli.format, &list, presentation),
1386 Content::Problem(problem) => {
1387 output::problem(&mut stdout, cli.format, &problem, presentation)
1388 }
1389 Content::PublicStats(stats) => {
1390 output::public_account_stats(&mut stdout, cli.format, &stats, presentation)
1391 }
1392 Content::Search(search) => output::search(&mut stdout, cli.format, &search, presentation),
1393 Content::Solution(solution) => {
1394 output::solution_file(&mut stdout, cli.format, &solution, presentation)
1395 }
1396 Content::Stats(stats) => output::stats(&mut stdout, cli.format, &stats, presentation),
1397 Content::Submission(submission) => {
1398 output::submission(&mut stdout, cli.format, &submission, presentation)
1399 }
1400 Content::Test(test) => output::test(&mut stdout, cli.format, &test, presentation),
1401 Content::ToolAction(action) => {
1402 output::tool_action(&mut stdout, cli.format, &action, presentation)
1403 }
1404 }?;
1405 if cli.sound && terminal && std::io::stderr().is_terminal() {
1406 std::io::stderr().lock().write_all(b"\x07")?;
1407 }
1408 Ok(())
1409}
1410
1411fn update_progress(
1412 progress: &Option<ProgressBar>,
1413 bar_style: &ProgressStyle,
1414 bytes: usize,
1415 total: Option<u64>,
1416) {
1417 if let Some(progress) = progress {
1418 if let Some(total) = total.filter(|total| *total > 0)
1419 && progress.length().is_none()
1420 {
1421 progress.disable_steady_tick();
1422 progress.set_style(bar_style.clone());
1423 progress.set_length(total);
1424 }
1425 progress.set_position(bytes as u64);
1426 }
1427}
1428
1429#[cfg(test)]
1430mod tests;