Skip to main content

cp_cli/
lib.rs

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, &registration, 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;