use anyhow::{bail, Context, Result}; use clap::{value_parser, ArgAction, Parser}; use futures::{stream, FutureExt, StreamExt}; use lambo::config::Config; use sqlx::sqlite::SqlitePoolOptions; use std::{fs, path::PathBuf}; use stderrlog::StdErrLog; #[derive(Debug, Parser)] struct Args { /// The path to the lambo configuration file. config_path: PathBuf, /// The path to the CSV file, as exported from Canvas's gradebook. csv_path: PathBuf, /// Decreases the log level. #[clap( short, long, conflicts_with("verbose"), action = ArgAction::Count, value_parser = value_parser!(u8).range(..=2) )] quiet: u8, /// Increases the log level. #[clap( short, long, conflicts_with("quiet"), action = ArgAction::Count, value_parser = value_parser!(u8).range(..=3) )] verbose: u8, } #[tokio::main] async fn main() -> Result<()> { // Parse the arguments. let args = Args::parse(); // Set up logging. { let mut logger = StdErrLog::new(); match args.quiet { 0 => logger.verbosity(1 + args.verbose as usize), 1 => logger.verbosity(0), 2 => logger.quiet(true), // UNREACHABLE: A maximum of two occurrences of quiet are allowed. _ => unreachable!(), }; // UNWRAP: No other logger should be set up. logger.show_module_names(true).init().unwrap() } // Parse the config file. let config = Config::read_from_file(&args.config_path)?; // Connect to the database. let db = SqlitePoolOptions::new() .connect(&config.database_url) .await .with_context(|| format!("failed to connect to database at {:?}", config.database_url))?; // Run any necessary migrations. sqlx::migrate!().run(&db).await.with_context(|| { format!( "failed to run migrations on database at {:?}", config.database_url ) })?; // Read the CSV file. let csv_string = fs::read_to_string(&args.csv_path) .with_context(|| format!("failed to read {:?}", args.csv_path))?; // Skip the first two lines; Canvas puts two lines of headers in... let (i, _) = csv_string .match_indices('\n') .nth(1) .context("invalid CSV file (not enough lines)")?; let csv_string = &csv_string[i..]; // Parse the CSV file. let mut csv = csv::ReaderBuilder::new() .has_headers(false) .from_reader(csv_string.as_bytes()) .records() .collect::, _>>() .with_context(|| format!("failed to parse {:?}", args.csv_path))?; // Remove the test student. csv.retain(|record| &record[0] != "Student, Test"); // Collect all the X.500s, checking that they were of the right form. let x500s = csv .into_iter() .map(|record| { let email = &record[3]; if let Some(x500) = email.strip_suffix("@umn.edu") { Ok(x500.to_string()) } else { bail!("not a valid UMN email: {:?}", email) } }) .collect::>>()?; // Insert them all in the database. // // Looks like sqlx doesn't actually have bulk insert? WTF? // // https://github.com/launchbadge/sqlx/issues/294 let db = &db; let errors = stream::iter(x500s) .map(|x500| async move { sqlx::query!( "INSERT OR IGNORE INTO student_x500s (x500) VALUES (?)", x500 ) .execute(db) .await .context("failed to insert X.500s") }) .filter_map(|future| future.map(|r| r.err())) .collect::>() .await; if !errors.is_empty() { log::error!("encountered {} errors:", errors.len()); for error in errors { log::error!("{:?}", error); } bail!("failed to insert X.500s") } // Count the number of X.500s. let x500_count = sqlx::query!("SELECT COUNT(x500) as count from student_x500s") .fetch_one(db) .await .context("failed to get a count of X.500s")?; log::info!("We now have {} student X.500s", x500_count.count); Ok(()) }