canvas_grading/
lib.rs

1use std::str::FromStr;
2
3use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use clap_complete::Shell;
6use reqwest::Client;
7use serde::{Deserialize, Serialize};
8use tracing::info;
9
10mod config;
11mod file;
12mod submission;
13
14pub use config::Config;
15pub use file::FileSubmission;
16pub use submission::Submission;
17
18/// A struct representing an access token for Canvas. Hides its value from Debug.
19#[derive(Serialize, Deserialize, Clone)]
20pub struct AccessToken(String);
21
22impl std::fmt::Debug for AccessToken {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        write!(f, "AccessToken")
25    }
26}
27
28impl AccessToken {
29    pub fn secret(&self) -> &str {
30        &self.0
31    }
32}
33
34#[derive(Parser, Clone, Debug)]
35#[command(version, about, long_about = None)]
36pub struct CLI {
37    /// Override the Canvas access token from config.
38    /// Either this or the option in config MUST BE SET
39    #[arg(long)]
40    pub access_token: Option<String>,
41
42    /// Override the course id from config.
43    /// Either this or the option in config MUST BE SET
44    #[arg(long, short)]
45    pub course_id: Option<u64>,
46
47    /// Override the base URL for Canvas from config.
48    /// Either this or the option in config MUST BE SET
49    #[arg(long, short)]
50    pub base_url: Option<String>,
51
52    /// Generate shell completion
53    #[arg(long)]
54    generate: Option<Shell>,
55
56    #[command(subcommand)]
57    pub command: Command,
58
59    /// Assignment ID in Canvas
60    pub assignment_id: u64,
61}
62
63#[derive(Subcommand, Clone, Debug)]
64pub enum Command {
65    /// Read in a results file, parse it and output the result
66    Debug,
67    /// Download submissions meeting a predicate and print the paths to standard output
68    #[command(subcommand)]
69    Submissions(SubmissionState),
70    /// Upload grades and comments from file
71    Grade,
72    /// Count the number of submissions meeting a predicate
73    #[command(subcommand)]
74    Count(SubmissionState),
75}
76
77#[derive(Subcommand, Clone, Debug)]
78pub enum SubmissionState {
79    Unsubmitted,
80    Submitted,
81    Ungraded,
82    Graded,
83    GradeNot100,
84}
85
86impl SubmissionState {
87    pub fn predicate(&self) -> fn(&Submission) -> bool {
88        match self {
89            SubmissionState::Unsubmitted => Submission::unsubmitted,
90            SubmissionState::Submitted => Submission::submitted,
91            SubmissionState::Ungraded => Submission::ungraded,
92            SubmissionState::Graded => Submission::graded,
93            SubmissionState::GradeNot100 => Submission::grade_not_100,
94        }
95    }
96}
97
98impl Default for SubmissionState {
99    fn default() -> Self {
100        Self::Ungraded
101    }
102}
103
104#[derive(Debug)]
105pub struct Grade {
106    pub user_id: u64,
107    pub grade: f32,
108}
109
110impl FromStr for Grade {
111    type Err = anyhow::Error;
112
113    fn from_str(s: &str) -> Result<Self, Self::Err> {
114        let mut parts = s.split(": ");
115        let user_id = parts
116            .next()
117            .context("Unable to parse user id from stdin.")?;
118        let grade = parts.next().context("Unable to parse grade from stdin.")?;
119
120        Ok(Self {
121            user_id: user_id.parse().context("Unable to parse user id to u64")?,
122            grade: grade.parse().context("Unable to parse grade to f32")?,
123        })
124    }
125}
126
127#[derive(Debug)]
128pub struct Comment {
129    pub user_id: u64,
130    pub comment: String,
131}
132
133impl FromStr for Comment {
134    type Err = anyhow::Error;
135
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        let (user_id, comment) = s
138            .split_once(": ")
139            .context("Unable to parse comment line.")?;
140
141        Ok(Self {
142            user_id: user_id.parse().context("Unable to parse user id to u64")?,
143            comment: comment.to_string(),
144        })
145    }
146}
147
148pub fn create_client(auth_token: AccessToken) -> Result<Client> {
149    info!("Building application reqwest client...");
150    info!("Setting auth header...");
151    let mut auth_bearer: reqwest::header::HeaderValue = ("Bearer ".to_owned()
152        + auth_token.secret())
153    .try_into()
154    .unwrap();
155    auth_bearer.set_sensitive(true);
156    info!("Auth header set!");
157
158    let mut headers = reqwest::header::HeaderMap::new();
159    headers.insert(reqwest::header::AUTHORIZATION, auth_bearer);
160    headers.insert("per_page", 100.into());
161
162    Ok(reqwest::ClientBuilder::new()
163        .default_headers(headers)
164        .build()?)
165}