diff --git a/tmc-langs-cli/Cargo.toml b/tmc-langs-cli/Cargo.toml index b5118ec9769..ce6ca4b5713 100644 --- a/tmc-langs-cli/Cargo.toml +++ b/tmc-langs-cli/Cargo.toml @@ -22,6 +22,7 @@ schemars = "0.8" serde = "1" serde_json = "1" smol = "1" +structopt = "0.3" tempfile = "3" thiserror = "1" toml = "0.5" diff --git a/tmc-langs-cli/src/app.rs b/tmc-langs-cli/src/app.rs index f6556844e7f..46d522ac49d 100644 --- a/tmc-langs-cli/src/app.rs +++ b/tmc-langs-cli/src/app.rs @@ -1,717 +1,586 @@ //! Create clap app use crate::output::UpdatedExercise; -use clap::{App, AppSettings, Arg, SubCommand}; +use anyhow::Context; +use clap::AppSettings; use schemars::JsonSchema; -use std::path::PathBuf; +use serde_json::Value as Json; +use std::{path::PathBuf, str::FromStr}; +use structopt::StructOpt; use tmc_langs::{ CombinedCourseData, CourseData, CourseDetails, CourseExercise, DownloadOrUpdateCourseExercisesResult, ExerciseDesc, ExerciseDetails, - ExercisePackagingConfiguration, LocalExercise, NewSubmission, Organization, Review, RunResult, - StyleValidationResult, Submission, SubmissionFeedbackResponse, SubmissionFinished, - UpdateResult, + ExercisePackagingConfiguration, Language, LocalExercise, NewSubmission, Organization, + OutputFormat, Review, RunResult, StyleValidationResult, Submission, SubmissionFeedbackResponse, + SubmissionFinished, UpdateResult, }; +use url::Url; // use tmc_langs_util::task_executor::RefreshData; -/// Constructs the CLI root. -pub fn create_app() -> App<'static, 'static> { - // subcommand definitions are alphabetically ordered - App::new(env!("CARGO_PKG_NAME")) - .version(env!("CARGO_PKG_VERSION")) - .author(env!("CARGO_PKG_AUTHORS")) - .about(env!("CARGO_PKG_DESCRIPTION")) - .setting(AppSettings::SubcommandRequiredElseHelp) - .arg(Arg::with_name("pretty") - .help("Pretty-prints all output") - .long("pretty")) - - .subcommand(SubCommand::with_name("checkstyle") - .about("Checks the code style for the given exercise") - .long_about(schema_leaked::>()) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the project resides.") - .long("exercise-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("locale") - .help("Locale as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'.") - .long("locale") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("output-path") - .help("If defined, the check results will be written to this path. Overwritten if it already exists.") - .long("output-path") - .takes_value(true))) - - .subcommand(SubCommand::with_name("clean") - .about("Cleans the target exercise using the appropriate language plugin") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the exercise resides.") - .long("exercise-path") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("compress-project") - .about("Compresses the target exercise into a ZIP. Only includes student files using the student file policy of the exercise's plugin") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the exercise resides.") - .long("exercise-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("output-path") - .help("Path to the output ZIP archive. Overwritten if it already exists.") - .long("output-path") - .required(true) - .takes_value(true))) - - .subcommand(create_core_app()) // "core" - - /* - .subcommand( - SubCommand::with_name("disk-space") - .about("Returns the amount of free disk space in megabytes left on the partition that contains the given path") - .arg(Arg::with_name("path") - .help("A path in the partition that should be inspected.") - .long("path") - .required(true) - .takes_value(true)) - ) - */ - - .subcommand(SubCommand::with_name("extract-project") - .about("Extracts an exercise from a ZIP archive. If the output-path is a project root, the plugin's student file policy will be used to avoid overwriting student files") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("archive-path") - .help("Path to the ZIP archive.") - .long("archive-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("output-path") - .help("Path to the directory where the archive will be extracted.") - .long("output-path") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("fast-available-points") - .about("Parses @Points notations from an exercise's exercise files and returns the point names found") - .long_about(schema_leaked::>()) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the projects reside.") - .long("exercise-path") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("find-exercises") - .about("Finds all exercise root directories inside the exercise-path") - .long_about(schema_leaked::>()) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the projects reside.") - .long("exercise-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("output-path") - .help("If given, the search results will be written to this path. Overwritten if it already exists.") - .long("output-path") - .takes_value(true))) - - .subcommand(SubCommand::with_name("get-exercise-packaging-configuration") - .about("Returns a configuration which separately lists the student files and exercise files inside the given exercise") - .long_about(schema_leaked::()) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the exercise resides.") - .long("exercise-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("output-path") - .help("If given, the configuration will be written to this path. Overwritten if it already exists.") - .long("output-path") - .takes_value(true))) - - .subcommand(SubCommand::with_name("list-local-course-exercises") - .about("Returns a list of local exercises for the given course") - .long_about(schema_leaked::>()) - .arg(Arg::with_name("client-name") - .help("The client for which exercises should be listed.") - .long("client-name") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("course-slug") - .help("The course slug the local exercises of which should be listed.") - .long("course-slug") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("prepare-solutions") - .about("Processes the exercise files in exercise-path, removing all code marked as stubs") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the exercise resides.") - .long("exercise-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("output-path") - .help("Path to the directory where the processed files will be written.") - .long("output-path") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("prepare-stubs") - .about("Processes the exercise files in exercise-path, removing all code marked as solutions") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the exercise resides.") - .long("exercise-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("output-path") - .help("Path to the directory where the processed files will be written.") - .long("output-path") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("prepare-submission") - .about("Takes a submission ZIP archive and turns it into an archive with reset test files, and tmc-params, ready for further processing") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("output-format") - .help("The output format of the submission archive. Defaults to tar.") - .long("output-format") - .default_value("tar") - .possible_values(&["tar", "zip", "zstd"])) - .arg(Arg::with_name("clone-path") - .help("Path to exercise's clone path, where the unmodified test files will be copied from.") - .long("clone-path") - .takes_value(true) - .required(true)) - .arg(Arg::with_name("output-path") - .help("Path to the resulting archive. Overwritten if it already exists.") - .long("output-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("stub-zip-path") - .help("If given, the tests will be copied from this stub ZIP instead, effectively ignoring hidden tests.") - .long("stub-zip-path") - .takes_value(true)) - .arg(Arg::with_name("submission-path") - .help("Path to the submission ZIP archive.") - .long("submission-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("tmc-param") - .help("A key-value pair in the form key=value to be written into .tmcparams. If multiple pairs with the same key are given, the values are collected into an array.") - .long("tmc-param") - .takes_value(true) - .multiple(true)) - .arg(Arg::with_name("top-level-dir-name") - .help("If given, the contents in the resulting archive will be nested inside a directory with this name.") - .long("top-level-dir-name") - .takes_value(true))) - - .subcommand(SubCommand::with_name("refresh-course") - .about("Refresh the given course") - // .long_about(schema_leaked::()) // can't format YAML mapping - .arg(Arg::with_name("cache-path") - .help("Path to the cached course.") - .long("cache-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("cache-root") - .help("The cache root.") - .long("cache-root") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("course-name") - .help("The name of the course.") - .long("course-name") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("git-branch") - .help("Version control branch.") - .long("git-branch") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("source-url") - .help("Version control URL.") - .long("source-url") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("run-tests") - .about("Run the tests for the exercise using the appropriate language plugin") - .long_about(schema_leaked::()) - .arg(Arg::with_name("checkstyle-output-path") - .help("Runs checkstyle if given. Path to the file where the style results will be written.") - .long("checkstyle-output-path") - .takes_value(true) - .requires("locale")) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the exercise resides.") - .long("exercise-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("locale") - .help("Language as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'. Required if checkstyle-output-path is given.") - .long("locale") - .takes_value(true)) - .arg(Arg::with_name("output-path") - .help("If defined, the test results will be written to this path. Overwritten if it already exists.") - .long("output-path") - .takes_value(true)) - .arg(Arg::with_name("wait-for-secret") - .help("If defined, the command will wait for a string to be written to stdin, used for signing the output file with jwt.") - .long("wait-for-secret"))) - - .subcommand(create_settings_app()) // "settings" - - .subcommand(SubCommand::with_name("scan-exercise") - .about("Produces a description of an exercise using the appropriate language plugin") - .long_about(schema_leaked::()) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the project resides.") - .long("exercise-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("output-path") - .help("If given, the scan results will be written to this path. Overwritten if it already exists.") - .long("output-path") - .takes_value(true))) +#[derive(StructOpt)] +#[structopt( + name = env!("CARGO_PKG_NAME"), + version = env!("CARGO_PKG_VERSION"), + author = env!("CARGO_PKG_AUTHORS"), + about = env!("CARGO_PKG_DESCRIPTION"), + setting = AppSettings::SubcommandRequiredElseHelp, +)] +pub struct Opt { + /// Pretty-prints all output + #[structopt(long)] + pub pretty: bool, + #[structopt(subcommand)] + pub cmd: Command, } -/// Constructs the core sub-command. -fn create_core_app() -> App<'static, 'static> { - App::new("core") - .setting(AppSettings::SubcommandRequiredElseHelp) - .about("Various commands that communicate with the TMC server") - .arg(Arg::with_name("client-name") - .help("Name used to differentiate between different TMC clients.") - .long("client-name") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("client-version") - .help("Client version.") - .long("client-version") - .required(true) - .takes_value(true)) - - .subcommand(SubCommand::with_name("check-exercise-updates") - .about("Checks for updates to any exercises that exist locally.") - .long_about(schema_leaked::>())) - - .subcommand(SubCommand::with_name("download-model-solution") - .about("Downloads an exercise's model solution") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("solution-download-url") - .help("URL to the solution download.") - .long("solution-download-url") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("target") - .help("Path to where the model solution will be downloaded.") - .long("target") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("download-old-submission") - .about("Downloads an old submission. Resets the exercise at output-path if any, downloading the exercise base from the server. The old submission is then downloaded and extracted on top of the base, using the student file policy to retain student files") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("save-old-state") // TODO: unnecessary, remove (submission-url is enough, but probaly needs a rename if the flag is removed) - .help("If set, the exercise is submitted to the server before resetting it.") - .long("save-old-state") - .requires("submission-url")) - .arg(Arg::with_name("exercise-id") - .help("The ID of the exercise.") - .long("exercise-id") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("output-path") - .help("Path to where the submission should be downloaded.") - .long("output-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("submission-id") - .help("The ID of the submission.") - .long("submission-id") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("submission-url") - .help("Required if save-old-state is set. The URL where the submission should be posted.") - .long("submission-url") - .takes_value(true))) - - .subcommand(SubCommand::with_name("download-or-update-course-exercises") - .about("Downloads exercises. If downloading an exercise that has been downloaded before, the student file policy will be used to avoid overwriting student files, effectively just updating the exercise files") - .long_about(schema_leaked::()) - .arg(Arg::with_name("download-template") - .help("If set, will always download the course template instead of the latest submission, even if one exists.") - .long("download-template")) - .arg(Arg::with_name("exercise-id") - .help("Exercise id of an exercise that should be downloaded. Multiple ids can be given.") - .long("exercise-id") - .required(true) - .takes_value(true) - .multiple(true))) - - .subcommand(SubCommand::with_name("get-course-data") - .about("Fetches course data. Combines course details, course exercises and course settings") - .long_about(schema_leaked::()) - .arg(Arg::with_name("course-id") - .help("The ID of the course.") - .long("course-id") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("get-course-details") - .about("Fetches course details") - .long_about(schema_leaked::()) - .arg(Arg::with_name("course-id") - .help("The ID of the course.") - .long("course-id") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("get-course-exercises") - .about("Lists a course's exercises") - .long_about(schema_leaked::>()) - .arg(Arg::with_name("course-id") - .help("The ID of the course.") - .long("course-id") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("get-course-settings") - .about("Fetches course settings") - .long_about(schema_leaked::()) - .arg(Arg::with_name("course-id") - .help("The ID of the course.") - .long("course-id") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("get-courses") - .about("Lists courses") - .long_about(schema_leaked::>()) - .arg(Arg::with_name("organization") - .help("Organization slug (e.g. mooc, hy).") - .long("organization") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("get-exercise-details") - .about("Fetches exercise details") - .long_about(schema_leaked::()) - .arg(Arg::with_name("exercise-id") - .help("The ID of the exercise.") - .long("exercise-id") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("get-exercise-submissions") - .about("Fetches the current user's old submissions for an exercise") - .long_about(schema_leaked::>()) - .arg(Arg::with_name("exercise-id") - .help("The ID of the exercise.") - .long("exercise-id") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("get-exercise-updates") - .about("Checks for updates to exercises") - .long_about(schema_leaked::()) - .arg(Arg::with_name("course-id") - .help("The ID of the course") - .long("course-id") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("exercise") - .help("An exercise. Takes two values, an exercise id and a checksum. Multiple exercises can be given.") - .long("exercise") - .required(true) - .takes_value(true) - .number_of_values(2) - .value_names(&["exercise-id", "checksum"]) - .multiple(true))) - - .subcommand(SubCommand::with_name("get-organization") - .about("Fetches an organization") - .long_about(schema_leaked::()) - .arg(Arg::with_name("organization") - .help("Organization slug (e.g. mooc, hy).") - .long("organization") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("get-organizations") - .about("Fetches a list of all organizations from the TMC server") - .long_about(schema_leaked::>())) - - .subcommand(SubCommand::with_name("get-unread-reviews") - .about("Fetches unread reviews") - .long_about(schema_leaked::>()) - .arg(Arg::with_name("reviews-url") - .help("URL to the reviews API.") - .long("reviews-url") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("logged-in") - .about("Checks if the CLI is authenticated. Prints the access token if so") - .long_about(SCHEMA_TOKEN)) - - .subcommand(SubCommand::with_name("login") - .about("Authenticates with the TMC server and stores the OAuth2 token in config. You can log in either by email and password or an access token") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("base64") - .help("If set, the password is expected to be a base64 encoded string. This can be useful if the password contains special characters.") - .long("base64")) - .arg(Arg::with_name("email") - .help("The email address of your TMC account. The password will be read through stdin.") - .long("email") - .takes_value(true) - .required_unless("set-access-token")) - .arg(Arg::with_name("set-access-token") - .help("The OAUTH2 access token that should be used for authentication.") - .long("set-access-token") - .takes_value(true) - .required_unless("email"))) - - .subcommand(SubCommand::with_name("logout") - .about("Logs out and removes the OAuth2 token from config") - .long_about(SCHEMA_NULL)) - - .subcommand(SubCommand::with_name("mark-review-as-read") - .about("Marks a review as read") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("review-update-url") - .help("URL to the review update API.") - .long("review-update-url") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("paste") - .about("Sends an exercise to the TMC pastebin") - .long_about(schema_leaked::()) - .arg(Arg::with_name("locale") - .help("Language as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'.") - .long("locale") - .takes_value(true)) - .arg(Arg::with_name("paste-message") - .help("Optional message to attach to the paste.") - .long("paste-message") - .takes_value(true)) - .arg(Arg::with_name("submission-path") - .help("Path to the exercise to be submitted.") - .long("submission-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("submission-url") - .help("The URL where the submission should be posted.") - .long("submission-url") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("request-code-review") - .about("Requests code review") - .long_about(schema_leaked::()) - .arg(Arg::with_name("locale") - .help("Language as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'.") - .long("locale") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("message-for-reviewer") - .help("Message for the review.") - .long("message-for-reviewer") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("submission-path") - .help("Path to the directory where the submission resides.") - .long("submission-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("submission-url") - .help("URL where the submission should be posted.") - .long("submission-url") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("reset-exercise") - .about("Resets an exercise. Removes the contents of the exercise directory and redownloads it from the server") - .long_about(SCHEMA_NULL) - .arg(Arg::with_name("save-old-state") - .help("If set, the exercise is submitted to the server before resetting it.") - .long("save-old-state") - .requires("submission-url")) - .arg(Arg::with_name("exercise-id") - .help("The ID of the exercise.") - .long("exercise-id") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("exercise-path") - .help("Path to the directory where the project resides.") - .long("exercise-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("submission-url") - .help("Required if save-old-state is set. The URL where the submission should be posted.") - .long("submission-url") - .takes_value(true))) - - .subcommand(SubCommand::with_name("send-feedback") - .about("Sends feedback for an exercise") - .long_about(schema_leaked::()) - .arg(Arg::with_name("feedback") - .help("A feedback answer. Takes two values, a feedback answer id and the answer. Multiple feedback arguments can be given.") - .long("feedback") - .required(true) - .takes_value(true) - .number_of_values(2) - .value_names(&["feedback-answer-id", "answer"]) - .multiple(true)) - .arg(Arg::with_name("feedback-url") - .help("URL where the feedback should be posted.") - .long("feedback-url") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("submit") - .about("Submits an exercise. By default blocks until the submission results are returned") - .long_about(schema_leaked::()) - .arg(Arg::with_name("dont-block") - .help("Set to avoid blocking.") - .long("dont-block")) - .arg(Arg::with_name("locale") - .help("Language as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'.") - .long("locale") - .takes_value(true)) - .arg(Arg::with_name("submission-path") - .help("Path to the directory where the exercise resides.") - .long("submission-path") - .required(true) - .takes_value(true)) - .arg(Arg::with_name("submission-url") - .help("URL where the submission should be posted.") - .long("submission-url") - .required(true) - .takes_value(true))) - - .subcommand(SubCommand::with_name("update-exercises") - .about("Updates all local exercises that have been updated on the server") - .long_about(SCHEMA_NULL)) - - .subcommand(SubCommand::with_name("wait-for-submission") - .about("Waits for a submission to finish") - .long_about(schema_leaked::()) - .arg(Arg::with_name("submission-url") - .help("URL to the submission's status.") - .long("submission-url") - .required(true) - .takes_value(true))) +#[derive(StructOpt)] +pub enum Command { + /// Checks the code style for the given exercise + #[structopt(long_about = schema_leaked::>())] + Checkstyle { + /// Path to the directory where the project resides. + #[structopt(long)] + exercise_path: PathBuf, + /// Locale as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'. + #[structopt(long)] + locale: Locale, + /// If defined, the check results will be written to this path. Overwritten if it already exists. + #[structopt(long)] + output_path: Option, + }, + + /// Cleans the target exercise using the appropriate language plugin + #[structopt(long_about = SCHEMA_NULL)] + Clean { + /// Path to the directory where the exercise resides. + #[structopt(long)] + exercise_path: PathBuf, + }, + + /// Compresses the target exercise into a ZIP. Only includes student files using the student file policy of the exercise's plugin + #[structopt(long_about = SCHEMA_NULL)] + CompressProject { + /// Path to the directory where the exercise resides. + #[structopt(long)] + exercise_path: PathBuf, + /// Path to the output ZIP archive. Overwritten if it already exists. + #[structopt(long)] + output_path: PathBuf, + }, + + Core(Core), + + /// Extracts an exercise from a ZIP archive. If the output-path is a project root, the plugin's student file policy will be used to avoid overwriting student files + #[structopt(long_about = SCHEMA_NULL)] + ExtractProject { + /// Path to the ZIP archive. + #[structopt(long)] + archive_path: PathBuf, + /// Path to the directory where the archive will be extracted. + #[structopt(long)] + output_path: PathBuf, + }, + + /// Parses @Points notations from an exercise's exercise files and returns the point names found + #[structopt(long_about = schema_leaked::>())] + FastAvailablePoints { + /// Path to the directory where the projects reside. + #[structopt(long)] + exercise_path: PathBuf, + }, + + /// Finds all exercise root directories inside the exercise-path + #[structopt(long_about = schema_leaked::>())] + FindExercises { + /// Path to the directory where the projects reside. + #[structopt(long)] + exercise_path: PathBuf, + /// If given, the search results will be written to this path. Overwritten if it already exists. + #[structopt(long)] + output_path: Option, + }, + + /// Returns a configuration which separately lists the student files and exercise files inside the given exercise + #[structopt(long_about = schema_leaked::())] + GetExercisePackagingConfiguration { + /// Path to the directory where the exercise resides. + #[structopt(long)] + exercise_path: PathBuf, + /// If given, the configuration will be written to this path. Overwritten if it already exists. + #[structopt(long)] + output_path: Option, + }, + + /// Returns a list of local exercises for the given course + #[structopt(long_about = schema_leaked::>())] + ListLocalCourseExercises { + /// The client for which exercises should be listed. + #[structopt(long)] + client_name: String, + /// The course slug the local exercises of which should be listed. + #[structopt(long)] + course_slug: String, + }, + + /// Processes the exercise files in exercise-path, removing all code marked as stubs + #[structopt(long_about = SCHEMA_NULL)] + PrepareSolutions { + /// Path to the directory where the exercise resides. + #[structopt(long)] + exercise_path: PathBuf, + /// Path to the directory where the processed files will be written. + #[structopt(long)] + output_path: PathBuf, + }, + + /// Processes the exercise files in exercise-path, removing all code marked as solutions + #[structopt(long_about = SCHEMA_NULL)] + PrepareStubs { + /// Path to the directory where the exercise resides. + #[structopt(long)] + exercise_path: PathBuf, + /// Path to the directory where the processed files will be written. + #[structopt(long)] + output_path: PathBuf, + }, + + /// Takes a submission ZIP archive and turns it into an archive with reset test files, and tmc-params, ready for further processing + #[structopt(long_about = SCHEMA_NULL)] + PrepareSubmission { + /// The output format of the submission archive. Defaults to tar. + #[structopt(long, default_value = "tar")] + output_format: OutputFormatWrapper, + /// Path to exercise's clone path, where the unmodified test files will be copied from. + #[structopt(long)] + clone_path: PathBuf, + /// Path to the resulting archive. Overwritten if it already exists. + #[structopt(long)] + output_path: PathBuf, + /// If given, the tests will be copied from this stub ZIP instead, effectively ignoring hidden tests. + #[structopt(long)] + stub_zip_path: Option, + /// Path to the submission ZIP archive. + #[structopt(long)] + submission_path: PathBuf, + /// A key-value pair in the form key=value to be written into .tmcparams. If multiple pairs with the same key are given, the values are collected into an array. + #[structopt(long)] + tmc_param: Vec, + #[structopt(long)] + /// If given, the contents in the resulting archive will be nested inside a directory with this name. + top_level_dir_name: Option, + }, + + /// Refresh the given course + RefreshCourse { + /// Path to the cached course. + #[structopt(long)] + cache_path: PathBuf, + /// The cache root. + #[structopt(long)] + cache_root: PathBuf, + /// The name of the course. + #[structopt(long)] + course_name: String, + /// Version control branch. + #[structopt(long)] + git_branch: String, + /// Version control URL. + #[structopt(long)] + source_url: Url, + }, + + /// Run the tests for the exercise using the appropriate language plugin + #[structopt(long_about = schema_leaked::())] + RunTests { + /// Runs checkstyle if given. Path to the file where the style results will be written. + #[structopt(long, requires = "locale")] + checkstyle_output_path: Option, + /// Path to the directory where the exercise resides. + #[structopt(long)] + exercise_path: PathBuf, + /// Language as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'. Required if checkstyle-output-path is given. + #[structopt(long)] + locale: Option, + /// If defined, the test results will be written to this path. Overwritten if it already exists. + #[structopt(long)] + output_path: Option, + /// If defined, the command will wait for a string to be written to stdin, used for signing the output file with jwt. + #[structopt(long)] + wait_for_secret: bool, + }, + + Settings(SettingsCommand), + + /// Produces a description of an exercise using the appropriate language plugin + #[structopt(long_about = schema_leaked::())] + ScanExercise { + /// Path to the directory where the project resides. + #[structopt(long)] + exercise_path: PathBuf, + /// If given, the scan results will be written to this path. Overwritten if it already exists. + #[structopt(long)] + output_path: Option, + }, } -fn create_settings_app() -> App<'static, 'static> { - App::new("settings") - .setting(AppSettings::SubcommandRequiredElseHelp) - .about("Configure the CLI") - .arg( - Arg::with_name("client-name") - .help("The name of the client.") - .long("client-name") - .required(true) - .takes_value(true), - ) - .subcommand( - SubCommand::with_name("get") - .about("Retrieves a value from the settings") - .arg( - Arg::with_name("setting") - .help("The name of the setting.") - .required(true) - .takes_value(true), - ), - ) - .subcommand( - SubCommand::with_name("list").about("Prints every key=value pair in the settings file"), - ) - .subcommand( - SubCommand::with_name("migrate") - .about("Migrates an exercise on disk into the langs project directory") - .arg( - Arg::with_name("exercise-path") - .help("Path to the directory where the project resides.") - .long("exercise-path") - .required(true) - .takes_value(true), - ) - .arg( - Arg::with_name("course-slug") - .help("The course slug, e.g. mooc-java-programming-i.") - .long("course-slug") - .required(true) - .takes_value(true), - ) - .arg( - Arg::with_name("exercise-id") - .help("The exercise id, e.g. 1234.") - .long("exercise-id") - .required(true) - .takes_value(true), - ) - .arg( - Arg::with_name("exercise-slug") - .help("The exercise slug, e.g. part01-Part01_01.Sandbox.") - .long("exercise-slug") - .required(true) - .takes_value(true), - ) - .arg( - Arg::with_name("exercise-checksum") - .help("The checksum of the exercise from the TMC server.") - .long("exercise-checksum") - .required(true) - .takes_value(true), - ), - ) - .subcommand( - SubCommand::with_name("move-projects-dir") - .about( - "Change the projects-dir setting, moving the contents into the new directory", - ) - .arg( - Arg::with_name("dir") - .help("The directory where the projects should be moved.") - .required(true) - .takes_value(true), - ), - ) - .subcommand( - SubCommand::with_name("reset").about("Resets the settings file to the defaults"), - ) - .subcommand( - SubCommand::with_name("set") - .about("Saves a value in the settings") - .arg( - Arg::with_name("key") - .help("The key. Parsed as JSON, assumed to be a string if parsing fails.") - .required(true) - .takes_value(true), - ) - .arg( - Arg::with_name("json") - .help("The value in JSON.") - .required(true) - .takes_value(true), - ), - ) - .subcommand( - SubCommand::with_name("unset") - .about("Unsets a value from the settings") - .arg( - Arg::with_name("setting") - .help("The name of the setting.") - .required(true) - .takes_value(true), - ), - ) +#[derive(StructOpt)] +/// Various commands that communicate with the TMC server +pub struct Core { + /// Name used to differentiate between different TMC clients. + #[structopt(long)] + pub client_name: String, + /// Client version. + #[structopt(long)] + pub client_version: String, + #[structopt(subcommand)] + pub command: CoreCommand, +} + +#[derive(StructOpt)] +#[structopt(setting = AppSettings::SubcommandRequiredElseHelp)] +pub enum CoreCommand { + /// Checks for updates to any exercises that exist locally. + #[structopt(long_about = schema_leaked::>())] + CheckExerciseUpdates, + + /// Downloads an exercise's model solution + #[structopt(long_about = SCHEMA_NULL)] + DownloadModelSolution { + /// URL to the solution download. + #[structopt(long)] + solution_download_url: Url, + /// Path to where the model solution will be downloaded. + #[structopt(long)] + target: PathBuf, + }, + + /// Downloads an old submission. Resets the exercise at output-path if any, downloading the exercise base from the server. The old submission is then downloaded and extracted on top of the base, using the student file policy to retain student files + #[structopt(long_about = SCHEMA_NULL)] + DownloadOldSubmission { + /// If set, the exercise is submitted to the server before resetting it. + #[structopt(long, requires = "submission-url")] + save_old_state: bool, + /// The ID of the exercise. + #[structopt(long)] + exercise_id: usize, + /// Path to where the submission should be downloaded. + #[structopt(long)] + output_path: PathBuf, + /// The ID of the submission. + #[structopt(long)] + submission_id: usize, + /// Required if save-old-state is set. The URL where the submission should be posted. + #[structopt(long)] + submission_url: Option, + }, + + /// Downloads exercises. If downloading an exercise that has been downloaded before, the student file policy will be used to avoid overwriting student files, effectively just updating the exercise files + #[structopt(long_about = schema_leaked::())] + DownloadOrUpdateCourseExercises { + /// If set, will always download the course template instead of the latest submission, even if one exists. + #[structopt(long)] + download_template: bool, + /// Exercise id of an exercise that should be downloaded. Multiple ids can be given. + #[structopt(long, required = true)] + exercise_id: Vec, + }, + + ///Fetches course data. Combines course details, course exercises and course settings + #[structopt(long_about = schema_leaked::())] + GetCourseData { + /// The ID of the course. + #[structopt(long)] + course_id: usize, + }, + + /// Fetches course details + #[structopt(long_about = schema_leaked::())] + GetCourseDetails { + /// The ID of the course. + #[structopt(long)] + course_id: usize, + }, + + /// Lists a course's exercises + #[structopt(long_about = schema_leaked::>())] + GetCourseExercises { + /// The ID of the course. + #[structopt(long)] + course_id: usize, + }, + + /// Fetches course settings + #[structopt(long_about = schema_leaked::())] + GetCourseSettings { + /// The ID of the course. + #[structopt(long)] + course_id: usize, + }, + + /// Lists courses + #[structopt(long_about = schema_leaked::>())] + GetCourses { + /// Organization slug (e.g. mooc, hy). + #[structopt(long)] + organization: String, + }, + + /// Fetches exercise details + #[structopt(long_about = schema_leaked::())] + GetExerciseDetails { + /// The ID of the exercise. + #[structopt(long)] + exercise_id: usize, + }, + + /// Fetches the current user's old submissions for an exercise + #[structopt(long_about = schema_leaked::>())] + GetExerciseSubmissions { + /// The ID of the exercise. + #[structopt(long)] + exercise_id: usize, + }, + + /// Checks for updates to exercises + #[structopt(long_about = schema_leaked::())] + GetExerciseUpdates { + /// The ID of the course. + #[structopt(long)] + course_id: usize, + /// An exercise. Takes two values, an exercise id and a checksum. Multiple exercises can be given. + #[structopt(long, required = true, number_of_values = 2, value_names = &["exercise-id", "checksum"])] + exercise: Vec, + }, + + /// Fetches an organization + #[structopt(long_about = schema_leaked::())] + GetOrganization { + /// Organization slug (e.g. mooc, hy). + #[structopt(long)] + organization: String, + }, + + /// Fetches a list of all organizations from the TMC server + #[structopt(long_about = schema_leaked::>())] + GetOrganizations, + + /// Fetches unread reviews + #[structopt(long_about = schema_leaked::>())] + GetUnreadReviews { + /// URL to the reviews API. + #[structopt(long)] + reviews_url: Url, + }, + + /// Checks if the CLI is authenticated. Prints the access token if so + #[structopt(long_about = SCHEMA_TOKEN)] + LoggedIn, + + /// Authenticates with the TMC server and stores the OAuth2 token in config. You can log in either by email and password or an access token + #[structopt(long_about = SCHEMA_NULL)] + Login { + /// If set, the password is expected to be a base64 encoded string. This can be useful if the password contains special characters. + #[structopt(long)] + base64: bool, + /// The email address of your TMC account. The password will be read through stdin. + #[structopt(long, required_unless = "set-access-token")] + email: Option, + /// The OAUTH2 access token that should be used for authentication. + #[structopt(long, required_unless = "email")] + set_access_token: Option, + }, + + /// Logs out and removes the OAuth2 token from config + #[structopt(long_about = SCHEMA_NULL)] + Logout, + + /// Marks a review as read + #[structopt(long_about = SCHEMA_NULL)] + MarkReviewAsRead { + /// URL to the review update API. + #[structopt(long)] + review_update_url: Url, + }, + + /// Sends an exercise to the TMC pastebin + #[structopt(long_about = schema_leaked::())] + Paste { + /// Language as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'. + #[structopt(long)] + locale: Option, + /// Optional message to attach to the paste. + #[structopt(long)] + paste_message: Option, + /// Path to the exercise to be submitted. + #[structopt(long)] + submission_path: PathBuf, + /// The URL where the submission should be posted. + #[structopt(long)] + submission_url: Url, + }, + + /// Requests code review + #[structopt(long_about = schema_leaked::())] + RequestCodeReview { + /// Language as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'. + #[structopt(long)] + locale: Locale, + /// Message for the review. + #[structopt(long)] + message_for_reviewer: String, + /// Path to the directory where the submission resides. + #[structopt(long)] + submission_path: PathBuf, + /// URL where the submission should be posted. + #[structopt(long)] + submission_url: Url, + }, + + /// Resets an exercise. Removes the contents of the exercise directory and redownloads it from the server + #[structopt(long_about = SCHEMA_NULL)] + ResetExercise { + /// If set, the exercise is submitted to the server before resetting it. + #[structopt(long, requires = "submission-url")] + save_old_state: bool, + /// The ID of the exercise. + #[structopt(long)] + exercise_id: usize, + /// Path to the directory where the project resides. + #[structopt(long)] + exercise_path: PathBuf, + /// Required if save-old-state is set. The URL where the submission should be posted. + #[structopt(long)] + submission_url: Option, + }, + + /// Sends feedback for an exercise + #[structopt(long_about = schema_leaked::())] + SendFeedback { + /// A feedback answer. Takes two values, a feedback answer id and the answer. Multiple feedback arguments can be given. + #[structopt(long, required = true, number_of_values = 2, value_names = &["feedback-answer-id, answer"])] + feedback: Vec, + /// URL where the feedback should be posted. + #[structopt(long)] + feedback_url: Url, + }, + + /// Submits an exercise. By default blocks until the submission results are returned + #[structopt(long_about = schema_leaked::())] + Submit { + /// Set to avoid blocking. + #[structopt(long)] + dont_block: bool, + /// Language as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'. + #[structopt(long)] + locale: Option, + /// Path to the directory where the exercise resides. + #[structopt(long)] + submission_path: PathBuf, + /// URL where the submission should be posted. + #[structopt(long)] + submission_url: Url, + }, + + /// Updates all local exercises that have been updated on the server + #[structopt(long_about = SCHEMA_NULL)] + UpdateExercises, + + /// Waits for a submission to finish + #[structopt(long_about = schema_leaked::())] + WaitForSubmission { + /// URL to the submission's status. + #[structopt(long)] + submission_url: Url, + }, +} + +#[derive(StructOpt)] +/// Configure the CLI +pub struct SettingsCommand { + /// The name of the client. + #[structopt(long)] + pub client_name: String, + #[structopt(subcommand)] + pub command: SettingsSubCommand, +} + +#[derive(StructOpt)] +#[structopt(setting = AppSettings::SubcommandRequiredElseHelp)] +pub enum SettingsSubCommand { + /// Retrieves a value from the settings + Get { + /// The name of the setting. + setting: String, + }, + /// Prints every key=value pair in the settings file + List, + /// Migrates an exercise on disk into the langs project directory + Migrate { + /// Path to the directory where the project resides. + #[structopt(long)] + exercise_path: PathBuf, + /// The course slug, e.g. mooc-java-programming-i. + #[structopt(long)] + course_slug: String, + /// The exercise id, e.g. 1234. + #[structopt(long)] + exercise_id: usize, + /// The exercise slug, e.g. part01-Part01_01.Sandbox. + #[structopt(long)] + exercise_slug: String, + /// The checksum of the exercise from the TMC server. + #[structopt(long)] + exercise_checksum: String, + }, + /// Change the projects-dir setting, moving the contents into the new directory + MoveProjectsDir { + /// The directory where the projects should be moved. + dir: PathBuf, + }, + /// Resets the settings file to the defaults + Reset, + /// Saves a value in the settings + Set { + /// The key. Parsed as JSON, assumed to be a string if parsing fails. + key: String, + /// The value in JSON. + json: Json, + }, + /// Unsets a value from the settings + Unset { + /// The name of the setting. + setting: String, + }, +} + +pub struct Locale(pub Language); + +impl FromStr for Locale { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let locale = Language::from_locale(s) + .or_else(|| Language::from_639_1(s)) + .or_else(|| Language::from_639_3(s)) + .with_context(|| format!("Invalid locale: {}", s))?; + Ok(Locale(locale)) + } +} + +pub struct OutputFormatWrapper(pub OutputFormat); + +impl FromStr for OutputFormatWrapper { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let format = match s { + "tar" => OutputFormat::Tar, + "zip" => OutputFormat::Zip, + "zstd" => OutputFormat::TarZstd, + _ => anyhow::bail!("invalid format"), + }; + Ok(OutputFormatWrapper(format)) + } } // == utilities for printing the JSON schema of the objects printed to stdout by the CLI == @@ -741,14 +610,14 @@ mod base_test { use super::*; fn get_matches(args: &[&str]) { - create_app().get_matches_from(&["tmc-langs-cli"].iter().chain(args).collect::>()); + Opt::from_iter(&["tmc-langs-cli"].iter().chain(args).collect::>()); } #[test] fn sanity() { - assert!(create_app() - .get_matches_from_safe(&["tmc-langs-cli", "checkstyle", "--non-existent-arg"]) - .is_err()); + assert!( + Opt::from_iter_safe(&["tmc-langs-cli", "checkstyle", "--non-existent-arg"]).is_err() + ); } #[test] @@ -892,7 +761,7 @@ mod base_test { "--git-branch", "main", "--source-url", - "example.com", + "http://example.com", ]); } @@ -928,7 +797,7 @@ mod core_test { use super::*; fn get_matches_core(args: &[&str]) { - create_app().get_matches_from( + Opt::from_iter( &[ "tmc-langs-cli", "core", @@ -953,7 +822,7 @@ mod core_test { get_matches_core(&[ "download-model-solution", "--solution-download-url", - "localhost", + "http://localhost", "--target", "path", ]); @@ -971,7 +840,7 @@ mod core_test { "--submission-id", "2345", "--submission-url", - "localhost", + "http://localhost", ]); } @@ -1048,7 +917,7 @@ mod core_test { #[test] fn get_unread_reviews() { - get_matches_core(&["get-unread-reviews", "--reviews-url", "localhost"]); + get_matches_core(&["get-unread-reviews", "--reviews-url", "http://localhost"]); } #[test] @@ -1075,7 +944,11 @@ mod core_test { #[test] fn mark_review_as_read() { - get_matches_core(&["mark-review-as-read", "--review-update-url", "localhost"]); + get_matches_core(&[ + "mark-review-as-read", + "--review-update-url", + "http://localhost", + ]); } #[test] @@ -1089,7 +962,7 @@ mod core_test { "--submission-path", "path", "--submission-url", - "localhost", + "http://localhost", ]); } @@ -1104,7 +977,7 @@ mod core_test { "--submission-path", "path", "--submission-url", - "localhost", + "http://localhost", ]); } @@ -1118,7 +991,7 @@ mod core_test { "--exercise-path", "path", "--submission-url", - "localhost", + "http://localhost", ]); } @@ -1130,7 +1003,7 @@ mod core_test { "1234", "answer", "--feedback-url", - "localhost", + "http://localhost", ]); } @@ -1144,7 +1017,7 @@ mod core_test { "--submission-path", "path", "--submission-url", - "localhost", + "http://localhost", ]); } @@ -1155,7 +1028,11 @@ mod core_test { #[test] fn wait_for_submission() { - get_matches_core(&["wait-for-submission", "--submission-url", "localhost"]); + get_matches_core(&[ + "wait-for-submission", + "--submission-url", + "http://localhost", + ]); } } @@ -1164,7 +1041,7 @@ mod settings_test { use super::*; fn get_matches_settings(args: &[&str]) { - create_app().get_matches_from( + Opt::from_iter( &["tmc-langs-cli", "settings", "--client-name", "client"] .iter() .chain(args) @@ -1211,7 +1088,7 @@ mod settings_test { #[test] fn set() { - get_matches_settings(&["set", "key", "json"]); + get_matches_settings(&["set", "key", "\"json\""]); } #[test] diff --git a/tmc-langs-cli/src/lib.rs b/tmc-langs-cli/src/lib.rs index 4bb08551559..db466ba8db9 100644 --- a/tmc-langs-cli/src/lib.rs +++ b/tmc-langs-cli/src/lib.rs @@ -10,23 +10,24 @@ use self::error::{DownloadsFailedError, InvalidTokenError, SandboxTestError}; use self::output::{ Data, Kind, Output, OutputData, OutputResult, Status, StatusUpdateData, UpdatedExercise, }; +use crate::app::{Locale, Opt}; use anyhow::{Context, Result}; -use clap::{ArgMatches, Error, ErrorKind}; +use app::{Command, Core, CoreCommand, OutputFormatWrapper, SettingsCommand, SettingsSubCommand}; +use clap::{Error, ErrorKind}; use serde::Serialize; +use std::collections::HashMap; +use std::env; use std::fs::File; -use std::io::{Read, Write}; +use std::io::{self, Cursor, Read, Write}; use std::ops::Deref; use std::path::{Path, PathBuf}; -use std::{collections::HashMap, io::stdin}; -use std::{env, io::Cursor}; -use tmc_langs::{file_util, notification_reporter, CommandError, StyleValidationResult}; +use structopt::StructOpt; use tmc_langs::{ - ClientError, Credentials, DownloadOrUpdateCourseExercisesResult, DownloadResult, - FeedbackAnswer, TmcClient, TmcConfig, + file_util, notification_reporter, ClientError, ClientUpdateData, CommandError, Credentials, + DownloadOrUpdateCourseExercisesResult, DownloadResult, FeedbackAnswer, Language, + StyleValidationResult, TmcClient, TmcConfig, }; -use tmc_langs::{ClientUpdateData, Language}; use tmc_langs_util::progress_reporter; -use url::Url; // wraps the run_inner function that actually does the work and handles any panics that occur // any langs library should never panic by itself, but other libraries used may in some rare circumstances @@ -72,8 +73,8 @@ pub fn run() { // sets up warning and progress reporting and calls run_app and does error handling for its result // returns Ok if we should exit with code 0, Err if we should exit with 1 fn run_inner() -> Result<(), ()> { - let matches = app::create_app().get_matches(); - let pretty = matches.is_present("pretty"); + let matches = Opt::from_args(); + let pretty = matches.pretty; notification_reporter::init(Box::new(move |warning| { let warning_output = Output::Notification(warning); @@ -92,7 +93,7 @@ fn run_inner() -> Result<(), ()> { let _r = print_output(&output, pretty); }); - if let Err(e) = run_app(matches, pretty) { + if let Err(e) = run_app(matches) { // error handling let causes: Vec = e.chain().map(|e| format!("Caused by: {}", e)).collect(); let message = error_message_special_casing(&e); @@ -192,298 +193,174 @@ fn check_sandbox_err(e: &anyhow::Error) -> Option { None } -fn run_app(matches: ArgMatches, pretty: bool) -> Result<()> { - // enforces that each branch must return a PrintToken as proof of having printed the output - let _printed: PrintToken = match matches.subcommand() { - ("checkstyle", Some(matches)) => { - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); - - let locale = matches.value_of("locale").unwrap(); - let locale = into_locale(locale)?; - - let output_path = matches.value_of("output-path"); - let output_path = output_path.map(Path::new); - +fn run_app(matches: Opt) -> Result<()> { + let output = match matches.cmd { + Command::Checkstyle { + exercise_path, + locale: Locale(locale), + output_path, + } => { file_util::lock!(exercise_path); - - let check_result = run_checkstyle_write_results(exercise_path, output_path, locale)?; - - let output = - Output::finished_with_data("ran checkstyle", check_result.map(Data::Validation)); - print_output(&output, pretty)? + let check_result = + run_checkstyle_write_results(&exercise_path, output_path.as_deref(), locale)?; + Output::finished_with_data("ran checkstyle", check_result.map(Data::Validation)) } - ("clean", Some(matches)) => { - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); + Command::Clean { exercise_path } => { file_util::lock!(exercise_path); - - tmc_langs::clean(exercise_path)?; - - let output = Output::finished_with_data( - format!("cleaned exercise at {}", exercise_path.display()), - None, - ); - print_output(&output, pretty)? + tmc_langs::clean(&exercise_path)?; + Output::finished(format!("cleaned exercise at {}", exercise_path.display())) } - ("compress-project", Some(matches)) => { - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); - - let output_path = matches.value_of("output-path").unwrap(); - let output_path = Path::new(output_path); + Command::CompressProject { + exercise_path, + output_path, + } => { file_util::lock!(exercise_path); - - tmc_langs::compress_project_to(exercise_path, output_path)?; - - let output = Output::finished_with_data( - format!( - "compressed project from {} to {}", - exercise_path.display(), - output_path.display() - ), - None, - ); - print_output(&output, pretty)? - } - ("core", Some(matches)) => { - let client_name = matches.value_of("client-name").unwrap(); - - let client_version = matches.value_of("client-version").unwrap(); - - let root_url = env::var("TMC_LANGS_ROOT_URL") - .unwrap_or_else(|_| "https://tmc.mooc.fi".to_string()); - - let (client, mut credentials) = - tmc_langs::init_tmc_client_with_credentials(root_url, client_name, client_version)?; - - match run_core(client, client_name, &mut credentials, matches, pretty) { - Ok(token) => token, - Err(error) => { - for cause in error.chain() { - // check if the token was rejected and delete it if so - if let Some(ClientError::HttpError { status, .. }) = - cause.downcast_ref::() - { - if status.as_u16() == 401 { - log::error!("Received HTTP 401 error, deleting credentials"); - if let Some(credentials) = credentials { - credentials.remove()?; - } - return Err(InvalidTokenError { source: error }.into()); - } else { - log::warn!("401 without credentials"); - } - } - } - return Err(error); - } - } - } - /* - ("disk-space", Some(matches)) => { - let path = matches.value_of("path").unwrap(); - let path = Path::new(path); - - let free = tmc_langs::free_disk_space_megabytes(path)?; - - let output = Output::finished_with_data( - format!( - "calculated free disk space for partition containing {}", - path.display() - ), - Data::FreeDiskSpace(free), - ); - print_output(&output, pretty)? + tmc_langs::compress_project_to(&exercise_path, &output_path)?; + Output::finished(format!( + "compressed project from {} to {}", + exercise_path.display(), + output_path.display() + )) } - */ - ("extract-project", Some(matches)) => { - let archive_path = matches.value_of("archive-path").unwrap(); - let archive_path = Path::new(archive_path); - let output_path = matches.value_of("output-path").unwrap(); - let output_path = Path::new(output_path); + Command::Core(core) => run_core(core)?, - let mut archive = file_util::open_file_lock(archive_path)?; + Command::ExtractProject { + archive_path, + output_path, + } => { + let mut archive = file_util::open_file_lock(&archive_path)?; let mut guard = archive.lock()?; let mut data = vec![]; guard.read_to_end(&mut data)?; - tmc_langs::extract_project(Cursor::new(data), output_path, true)?; + tmc_langs::extract_project(Cursor::new(data), &output_path, true)?; - let output = Output::finished_with_data( - format!( - "extracted project from {} to {}", - archive_path.display(), - output_path.display() - ), - None, - ); - print_output(&output, pretty)? + Output::finished(format!( + "extracted project from {} to {}", + archive_path.display(), + output_path.display() + )) } - ("fast-available-points", Some(matches)) => { - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); + Command::FastAvailablePoints { exercise_path } => { file_util::lock!(exercise_path); - - let points = tmc_langs::get_available_points(exercise_path)?; - - let output = Output::finished_with_data( + let points = tmc_langs::get_available_points(&exercise_path)?; + Output::finished_with_data( format!("found {} available points", points.len()), Data::AvailablePoints(points), - ); - print_output(&output, pretty)? + ) } - ("find-exercises", Some(matches)) => { - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); - - let output_path = matches.value_of("output-path"); - let output_path = output_path.map(Path::new); + Command::FindExercises { + exercise_path, + output_path, + } => { file_util::lock!(exercise_path); - let exercises = - tmc_langs::find_exercise_directories(exercise_path).with_context(|| { + tmc_langs::find_exercise_directories(&exercise_path).with_context(|| { format!( "Failed to find exercise directories in {}", exercise_path.display(), ) })?; - if let Some(output_path) = output_path { - write_result_to_file_as_json(&exercises, output_path, pretty, None)?; + write_result_to_file_as_json(&exercises, &output_path, matches.pretty, None)?; } - - let output = Output::finished_with_data( + Output::finished_with_data( format!("found exercises at {}", exercise_path.display()), Data::Exercises(exercises), - ); - print_output(&output, pretty)? + ) } - ("get-exercise-packaging-configuration", Some(matches)) => { - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); - - let output_path = matches.value_of("output-path"); - let output_path = output_path.map(Path::new); + Command::GetExercisePackagingConfiguration { + exercise_path, + output_path, + } => { file_util::lock!(exercise_path); - - let config = tmc_langs::get_exercise_packaging_configuration(exercise_path) + let config = tmc_langs::get_exercise_packaging_configuration(&exercise_path) .with_context(|| { format!( "Failed to get exercise packaging configuration for exercise at {}", exercise_path.display(), ) })?; - if let Some(output_path) = output_path { - write_result_to_file_as_json(&config, output_path, pretty, None)?; + write_result_to_file_as_json(&config, &output_path, matches.pretty, None)?; } - - let output = Output::finished_with_data( + Output::finished_with_data( format!( "created exercise packaging config from {}", exercise_path.display(), ), Data::ExercisePackagingConfiguration(config), - ); - print_output(&output, pretty)? + ) } - ("list-local-course-exercises", Some(matches)) => { - let client_name = matches.value_of("client-name").unwrap(); - let course_slug = matches.value_of("course-slug").unwrap(); + Command::ListLocalCourseExercises { + client_name, + course_slug, + } => { + let local_exercises = + tmc_langs::list_local_course_exercises(&client_name, &course_slug)?; - let local_exercises = tmc_langs::list_local_course_exercises(client_name, course_slug)?; - - let output = Output::finished_with_data( + Output::finished_with_data( format!("listed local exercises for {}", course_slug), Data::LocalExercises(local_exercises), - ); - print_output(&output, pretty)? + ) } - ("prepare-solutions", Some(matches)) => { - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); - - let output_path = matches.value_of("output-path").unwrap(); - let output_path = Path::new(output_path); + Command::PrepareSolutions { + exercise_path, + output_path, + } => { file_util::lock!(exercise_path); - - tmc_langs::prepare_solution(exercise_path, output_path).with_context(|| { + tmc_langs::prepare_solution(&exercise_path, &output_path).with_context(|| { format!( "Failed to prepare solutions for exercise at {}", exercise_path.display(), ) })?; - - let output = Output::finished_with_data( - format!( - "prepared solutions for {} at {}", - exercise_path.display(), - output_path.display() - ), - None, - ); - print_output(&output, pretty)? + Output::finished(format!( + "prepared solutions for {} at {}", + exercise_path.display(), + output_path.display() + )) } - ("prepare-stubs", Some(matches)) => { - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); - - let output_path = matches.value_of("output-path").unwrap(); - let output_path = Path::new(output_path); + Command::PrepareStubs { + exercise_path, + output_path, + } => { file_util::lock!(exercise_path); - - tmc_langs::prepare_stub(exercise_path, output_path).with_context(|| { + tmc_langs::prepare_stub(&exercise_path, &output_path).with_context(|| { format!( "Failed to prepare stubs for exercise at {}", exercise_path.display(), ) })?; - - let output = Output::finished_with_data( - format!( - "prepared stubs for {} at {}", - exercise_path.display(), - output_path.display() - ), - None, - ); - print_output(&output, pretty)? + Output::finished(format!( + "prepared stubs for {} at {}", + exercise_path.display(), + output_path.display() + )) } - ("prepare-submission", Some(matches)) => { - let clone_path = matches.value_of("clone-path").unwrap(); - let clone_path = Path::new(clone_path); - - let output_format = match matches.value_of("output-format") { - Some("tar") => tmc_langs::OutputFormat::Tar, - Some("zip") => tmc_langs::OutputFormat::Zip, - Some("zstd") => tmc_langs::OutputFormat::TarZstd, - _ => unreachable!("validation error"), - }; - - let output_path = matches.value_of("output-path").unwrap(); - let output_path = Path::new(output_path); - - let stub_zip_path = matches.value_of("stub-zip-path"); - let stub_zip_path = stub_zip_path.map(Path::new); - let submission_path = matches.value_of("submission-path").unwrap(); - let submission_path = Path::new(submission_path); - - let tmc_params_values = matches.values_of("tmc-param").unwrap_or_default(); + Command::PrepareSubmission { + clone_path, + output_format: OutputFormatWrapper(output_format), + output_path, + stub_zip_path, + submission_path, + tmc_param, + top_level_dir_name, + } => { // will contain for each key all the values with that key in a list let mut tmc_params_grouped = HashMap::new(); - for value in tmc_params_values { + for value in &tmc_param { let params: Vec<_> = value.split('=').collect(); if params.len() != 2 { Error::with_description( @@ -511,76 +388,61 @@ fn run_app(matches: ArgMatches, pretty: bool) -> Result<()> { } } - let top_level_dir_name = matches.value_of("top-level-dir-name"); - let top_level_dir_name = top_level_dir_name.map(str::to_string); - tmc_langs::prepare_submission( - submission_path, - output_path, + &submission_path, + &output_path, top_level_dir_name, tmc_params, - clone_path, - stub_zip_path, + &clone_path, + stub_zip_path.as_deref(), output_format, )?; - - let output = Output::finished_with_data( - format!( - "prepared submission for {} at {}", - submission_path.display(), - output_path.display() - ), - None, - ); - print_output(&output, pretty)? + Output::finished(format!( + "prepared submission for {} at {}", + submission_path.display(), + output_path.display() + )) } - ("refresh-course", Some(matches)) => { - let cache_path = matches.value_of("cache-path").unwrap(); - let cache_root = matches.value_of("cache-root").unwrap(); - let course_name = matches.value_of("course-name").unwrap(); - let git_branch = matches.value_of("git-branch").unwrap(); - let source_url = matches.value_of("source-url").unwrap(); + Command::RefreshCourse { + cache_path, + cache_root, + course_name, + git_branch, + source_url, + } => { let refresh_result = tmc_langs::refresh_course( - course_name.to_string(), - PathBuf::from(cache_path), - source_url.to_string(), - git_branch.to_string(), - PathBuf::from(cache_root), + course_name.clone(), + cache_path, + source_url.into_string(), + git_branch, + cache_root, ) .with_context(|| format!("Failed to refresh course {}", course_name))?; - - let output = Output::finished_with_data( + Output::finished_with_data( format!("refreshed course {}", course_name), Data::RefreshResult(refresh_result), - ); - print_output(&output, pretty)? + ) } - ("run-tests", Some(matches)) => { - let checkstyle_output_path = matches.value_of("checkstyle-output-path"); - let checkstyle_output_path: Option<&Path> = checkstyle_output_path.map(Path::new); - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); - - let locale = matches.value_of("locale"); - - let output_path = matches.value_of("output-path"); - let output_path = output_path.map(Path::new); - - let wait_for_secret = matches.is_present("wait-for-secret"); + Command::RunTests { + checkstyle_output_path, + exercise_path, + locale, + output_path, + wait_for_secret, + } => { + file_util::lock!(exercise_path); let secret = if wait_for_secret { let mut s = String::new(); - stdin().read_line(&mut s)?; + io::stdin().read_line(&mut s)?; Some(s.trim().to_string()) } else { None }; - file_util::lock!(exercise_path); - - let test_result = tmc_langs::run_tests(exercise_path).with_context(|| { + let test_result = tmc_langs::run_tests(&exercise_path).with_context(|| { format!( "Failed to run tests for exercise at {}", exercise_path.display() @@ -590,7 +452,7 @@ fn run_app(matches: ArgMatches, pretty: bool) -> Result<()> { let test_result = if env::var("TMC_SANDBOX").is_ok() { // in sandbox, wrap error to signal we want to write the output into a file test_result.map_err(|e| SandboxTestError { - path: output_path.map(Path::to_path_buf), + path: output_path.clone(), source: e, })? } else { @@ -599,29 +461,33 @@ fn run_app(matches: ArgMatches, pretty: bool) -> Result<()> { }; if let Some(output_path) = output_path { - write_result_to_file_as_json(&test_result, output_path, pretty, secret)?; + write_result_to_file_as_json(&test_result, &output_path, matches.pretty, secret)?; } // todo: checkstyle results in stdout? if let Some(checkstyle_output_path) = checkstyle_output_path { - let locale = into_locale(locale.unwrap())?; + let locale = locale.unwrap().0; - run_checkstyle_write_results(exercise_path, Some(checkstyle_output_path), locale)?; + run_checkstyle_write_results( + &exercise_path, + Some(&checkstyle_output_path), + locale, + )?; } - let output = Output::finished_with_data( + Output::finished_with_data( format!("ran tests for {}", exercise_path.display()), Data::TestResult(test_result), - ); - print_output(&output, pretty)? + ) } - ("settings", Some(matches)) => run_settings(matches, pretty)?, - ("scan-exercise", Some(matches)) => { - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); - let output_path = matches.value_of("output-path"); - let output_path = output_path.map(Path::new); + Command::Settings(settings) => run_settings(settings)?, + + Command::ScanExercise { + exercise_path, + output_path, + } => { + file_util::lock!(exercise_path); let exercise_name = exercise_path.file_name().with_context(|| { format!( @@ -637,38 +503,68 @@ fn run_app(matches: ArgMatches, pretty: bool) -> Result<()> { ) })?; - file_util::lock!(exercise_path); - - let scan_result = tmc_langs::scan_exercise(exercise_path, exercise_name.to_string()) + let scan_result = tmc_langs::scan_exercise(&exercise_path, exercise_name.to_string()) .with_context(|| { - format!("Failed to scan exercise at {}", exercise_path.display()) - })?; + format!("Failed to scan exercise at {}", exercise_path.display()) + })?; if let Some(output_path) = output_path { - write_result_to_file_as_json(&scan_result, output_path, pretty, None)?; + write_result_to_file_as_json(&scan_result, &output_path, matches.pretty, None)?; } - let output = Output::finished_with_data( + Output::finished_with_data( format!("scanned exercise at {}", exercise_path.display()), Data::ExerciseDesc(scan_result), - ); - print_output(&output, pretty)? + ) } - _ => unreachable!("missing subcommand arm"), }; + print_output(&output, matches.pretty)?; Ok(()) } -fn run_core( +fn run_core(core: Core) -> Result { + let client_name = &core.client_name; + let client_version = &core.client_version; + + let root_url = + env::var("TMC_LANGS_ROOT_URL").unwrap_or_else(|_| "https://tmc.mooc.fi".to_string()); + let (client, mut credentials) = + tmc_langs::init_tmc_client_with_credentials(root_url, client_name, client_version)?; + + match run_core_inner(core, client, &mut credentials) { + Err(error) => { + for cause in error.chain() { + // check if the token was rejected and delete it if so + if let Some(ClientError::HttpError { status, .. }) = + cause.downcast_ref::() + { + if status.as_u16() == 401 { + log::error!("Received HTTP 401 error, deleting credentials"); + if let Some(credentials) = credentials { + credentials.remove()?; + } + return Err(InvalidTokenError { source: error }.into()); + } else { + log::warn!("401 without credentials"); + } + } + } + Err(error) + } + output => output, + } +} + +fn run_core_inner( + core: Core, mut client: TmcClient, - client_name: &str, credentials: &mut Option, - matches: &ArgMatches, - pretty: bool, -) -> Result { - // proof of having printed the output - let printed: PrintToken = match matches.subcommand() { - ("check-exercise-updates", Some(_)) => { +) -> Result { + let client_name = &core.client_name; + let _client_version = &core.client_version; + + let output = match core.command { + CoreCommand::CheckExerciseUpdates => { let projects_dir = tmc_langs::get_projects_dir(client_name)?; let updated_exercises = tmc_langs::check_exercise_updates(&client, &projects_dir) .context("Failed to check exercise updates")? @@ -676,42 +572,29 @@ fn run_core( .map(|id| UpdatedExercise { id }) .collect(); - let output = Output::finished_with_data( + Output::finished_with_data( "updated exercises", Data::UpdatedExercises(updated_exercises), - ); - print_output(&output, pretty)? + ) } - ("download-model-solution", Some(matches)) => { - let solution_download_url = matches.value_of("solution-download-url").unwrap(); - let solution_download_url = into_url(solution_download_url)?; - - let target = matches.value_of("target").unwrap(); - let target = Path::new(target); + CoreCommand::DownloadModelSolution { + solution_download_url, + target, + } => { client - .download_model_solution(solution_download_url, target) + .download_model_solution(solution_download_url, &target) .context("Failed to download model solution")?; - - let output = Output::finished_with_data("downloaded model solution", None); - print_output(&output, pretty)? + Output::finished("downloaded model solution") } - ("download-old-submission", Some(matches)) => { - let exercise_id = matches.value_of("exercise-id").unwrap(); - let exercise_id = into_usize(exercise_id)?; - - let output_path = matches.value_of("output-path").unwrap(); - let output_path = PathBuf::from(output_path); - - let submission_id = matches.value_of("submission-id").unwrap(); - let submission_id = into_usize(submission_id)?; - - let submission_url = matches.value_of("submission-url"); - let submission_url = match submission_url { - Some(url) => Some(into_url(url)?), - None => None, - }; + CoreCommand::DownloadOldSubmission { + save_old_state: _, + exercise_id, + output_path, + submission_id, + submission_url, + } => { tmc_langs::download_old_submission( &client, exercise_id, @@ -719,19 +602,13 @@ fn run_core( submission_id, submission_url, )?; - - let output = Output::finished_with_data("extracted project", None); - print_output(&output, pretty)? + Output::finished("extracted project") } - ("download-or-update-course-exercises", Some(matches)) => { - let download_template = matches.is_present("download-template"); - - let exercise_ids = matches.values_of("exercise-id").unwrap(); - let exercise_ids = exercise_ids - .into_iter() - .map(into_usize) - .collect::>>()?; + CoreCommand::DownloadOrUpdateCourseExercises { + download_template, + exercise_id: exercise_ids, + } => { let projects_dir = tmc_langs::get_projects_dir(client_name)?; let data = match tmc_langs::download_or_update_course_exercises( &client, @@ -757,110 +634,75 @@ fn run_core( failed: Some(failed), }, }; - let output = Output::finished_with_data( + Output::finished_with_data( "downloaded or updated exercises", Data::ExerciseDownload(data), - ); - print_output(&output, pretty)? + ) } - ("get-course-data", Some(matches)) => { - let course_id = matches.value_of("course-id").unwrap(); - let course_id = into_usize(course_id)?; + CoreCommand::GetCourseData { course_id } => { let data = tmc_langs::get_course_data(&client, course_id) .context("Failed to get course data")?; - - let output = Output::finished_with_data( + Output::finished_with_data( "fetched course data", Data::CombinedCourseData(Box::new(data)), - ); - print_output(&output, pretty)? + ) } - ("get-course-details", Some(matches)) => { - let course_id = matches.value_of("course-id").unwrap(); - let course_id = into_usize(course_id)?; + CoreCommand::GetCourseDetails { course_id } => { let details = client .get_course_details(course_id) .context("Failed to get course details")?; - - let output = - Output::finished_with_data("fetched course details", Data::CourseDetails(details)); - print_output(&output, pretty)? + Output::finished_with_data("fetched course details", Data::CourseDetails(details)) } - ("get-course-exercises", Some(matches)) => { - let course_id = matches.value_of("course-id").unwrap(); - let course_id = into_usize(course_id)?; + CoreCommand::GetCourseExercises { course_id } => { let exercises = client .get_course_exercises(course_id) .context("Failed to get course")?; - - let output = Output::finished_with_data( - "fetched course exercises", - Data::CourseExercises(exercises), - ); - print_output(&output, pretty)? + Output::finished_with_data("fetched course exercises", Data::CourseExercises(exercises)) } - ("get-course-settings", Some(matches)) => { - let course_id = matches.value_of("course-id").unwrap(); - let course_id = into_usize(course_id)?; + CoreCommand::GetCourseSettings { course_id } => { let settings = client .get_course(course_id) .context("Failed to get course")?; - - let output = - Output::finished_with_data("fetched course settings", Data::CourseData(settings)); - print_output(&output, pretty)? + Output::finished_with_data("fetched course settings", Data::CourseData(settings)) } - ("get-courses", Some(matches)) => { - let organization_slug = matches.value_of("organization").unwrap(); + CoreCommand::GetCourses { organization } => { let courses = client - .list_courses(organization_slug) + .list_courses(&organization) .context("Failed to get courses")?; - - let output = Output::finished_with_data("fetched courses", Data::Courses(courses)); - print_output(&output, pretty)? + Output::finished_with_data("fetched courses", Data::Courses(courses)) } - ("get-exercise-details", Some(matches)) => { - let exercise_id = matches.value_of("exercise-id").unwrap(); - let exercise_id = into_usize(exercise_id)?; + CoreCommand::GetExerciseDetails { exercise_id } => { let course = client .get_exercise_details(exercise_id) .context("Failed to get course")?; - - let output = Output::finished_with_data( - "fetched exercise details", - Data::ExerciseDetails(course), - ); - print_output(&output, pretty)? + Output::finished_with_data("fetched exercise details", Data::ExerciseDetails(course)) } - ("get-exercise-submissions", Some(matches)) => { - let exercise_id = matches.value_of("exercise-id").unwrap(); - let exercise_id = into_usize(exercise_id)?; + CoreCommand::GetExerciseSubmissions { exercise_id } => { let submissions = client .get_exercise_submissions_for_current_user(exercise_id) .context("Failed to get submissions")?; - - let output = Output::finished_with_data( + Output::finished_with_data( "fetched exercise submissions", Data::Submissions(submissions), - ); - print_output(&output, pretty)? + ) } - ("get-exercise-updates", Some(matches)) => { - let course_id = matches.value_of("course-id").unwrap(); - let course_id = into_usize(course_id)?; + CoreCommand::GetExerciseUpdates { + course_id, + exercise, + } => { // collects exercise checksums into an {id: checksum} map + let mut exercise_checksums = exercise.into_iter(); let mut checksums = HashMap::new(); - let mut exercise_checksums = matches.values_of("exercise").unwrap(); while let Some(exercise_id) = exercise_checksums.next() { - let exercise_id = into_usize(exercise_id)?; + let exercise_id = into_usize(&exercise_id)?; let checksum = exercise_checksums.next().unwrap(); // safe unwrap due to exercise taking two values checksums.insert(exercise_id, checksum.to_string()); } @@ -868,75 +710,61 @@ fn run_core( let update_result = client .get_exercise_updates(course_id, checksums) .context("Failed to get exercise updates")?; - - let output = Output::finished_with_data( + Output::finished_with_data( "fetched exercise updates", Data::UpdateResult(update_result), - ); - print_output(&output, pretty)? + ) } - ("get-organization", Some(matches)) => { - let organization_slug = matches.value_of("organization").unwrap(); + CoreCommand::GetOrganization { organization } => { let org = client - .get_organization(organization_slug) + .get_organization(&organization) .context("Failed to get organization")?; - - let output = - Output::finished_with_data("fetched organization", Data::Organization(org)); - print_output(&output, pretty)? + Output::finished_with_data("fetched organization", Data::Organization(org)) } - ("get-organizations", Some(_matches)) => { + + CoreCommand::GetOrganizations => { let orgs = client .get_organizations() .context("Failed to get organizations")?; - - let output = - Output::finished_with_data("fetched organizations", Data::Organizations(orgs)); - print_output(&output, pretty)? + Output::finished_with_data("fetched organizations", Data::Organizations(orgs)) } - ("get-unread-reviews", Some(matches)) => { - let reviews_url = matches.value_of("reviews-url").unwrap(); - let reviews_url = into_url(reviews_url)?; + CoreCommand::GetUnreadReviews { reviews_url } => { let reviews = client .get_unread_reviews(reviews_url) .context("Failed to get unread reviews")?; - - let output = - Output::finished_with_data("fetched unread reviews", Data::Reviews(reviews)); - print_output(&output, pretty)? + Output::finished_with_data("fetched unread reviews", Data::Reviews(reviews)) } - ("logged-in", Some(_matches)) => { + + CoreCommand::LoggedIn => { if let Some(credentials) = credentials { - let output = Output::OutputData(OutputData { + Output::OutputData(OutputData { status: Status::Finished, message: "currently logged in".to_string(), result: OutputResult::LoggedIn, data: Some(Data::Token(credentials.token())), - }); - print_output(&output, pretty)? + }) } else { - let output = Output::OutputData(OutputData { + Output::OutputData(OutputData { status: Status::Finished, message: "currently not logged in".to_string(), result: OutputResult::NotLoggedIn, data: None, - }); - print_output(&output, pretty)? + }) } } - ("login", Some(matches)) => { - let base64 = matches.is_present("base64"); - - let email = matches.value_of("email"); - let set_access_token = matches.value_of("set-access-token"); + CoreCommand::Login { + base64, + email, + set_access_token, + } => { // get token from argument or server let token = if let Some(token) = set_access_token { - tmc_langs::login_with_token(token.to_string()) + tmc_langs::login_with_token(token) } else if let Some(email) = email { - tmc_langs::login_with_password(&mut client, base64, client_name, email.to_string())? + tmc_langs::login_with_password(&mut client, base64, client_name, email)? } else { unreachable!("validation error"); }; @@ -944,302 +772,212 @@ fn run_core( // create token file Credentials::save(client_name, token)?; - let output = Output::OutputData(OutputData { + Output::OutputData(OutputData { status: Status::Finished, message: "logged in".to_string(), result: OutputResult::LoggedIn, data: None, - }); - print_output(&output, pretty)? + }) } - ("logout", Some(_matches)) => { + + CoreCommand::Logout => { if let Some(credentials) = credentials.take() { credentials.remove()?; } - - let output = Output::OutputData(OutputData { + Output::OutputData(OutputData { status: Status::Finished, message: "logged out".to_string(), result: OutputResult::LoggedOut, data: None, - }); - print_output(&output, pretty)? + }) } - ("mark-review-as-read", Some(matches)) => { - let review_update_url = matches.value_of("review-update-url").unwrap(); + CoreCommand::MarkReviewAsRead { review_update_url } => { client .mark_review_as_read(review_update_url.to_string()) .context("Failed to mark review as read")?; - - let output = Output::finished_with_data("marked review as read", None); - print_output(&output, pretty)? + Output::finished("marked review as read") } - ("paste", Some(matches)) => { - let locale = matches.value_of("locale"); - let locale = if let Some(locale) = locale { - Some(into_locale(locale)?) - } else { - None - }; - - let paste_message = matches.value_of("paste-message"); - - let submission_path = matches.value_of("submission-path").unwrap(); - let submission_path = Path::new(submission_path); - - let submission_url = matches.value_of("submission-url").unwrap(); - let submission_url = into_url(submission_url)?; + CoreCommand::Paste { + locale, + paste_message, + submission_path, + submission_url, + } => { file_util::lock!(submission_path); - + let locale = locale.map(|l| l.0); let new_submission = client - .paste( - submission_url, - submission_path, - paste_message.map(str::to_string), - locale, - ) + .paste(submission_url, &submission_path, paste_message, locale) .context("Failed to get paste with comment")?; - - let output = - Output::finished_with_data("sent paste", Data::NewSubmission(new_submission)); - print_output(&output, pretty)? + Output::finished_with_data("sent paste", Data::NewSubmission(new_submission)) } - ("request-code-review", Some(matches)) => { - let locale = matches.value_of("locale"); - let locale = if let Some(locale) = locale { - Some(into_locale(locale)?) - } else { - None - }; - - let message_for_reviewer = matches.value_of("message-for-reviewer").unwrap(); - - let submission_path = matches.value_of("submission-path").unwrap(); - let submission_path = Path::new(submission_path); - - let submission_url = matches.value_of("submission-url").unwrap(); - let submission_url = into_url(submission_url)?; + CoreCommand::RequestCodeReview { + locale: Locale(locale), + message_for_reviewer, + submission_path, + submission_url, + } => { file_util::lock!(submission_path); - let new_submission = client .request_code_review( submission_url, - submission_path, - message_for_reviewer.to_string(), - locale, + &submission_path, + message_for_reviewer, + Some(locale), ) .context("Failed to request code review")?; - - let output = Output::finished_with_data( - "requested code review", - Data::NewSubmission(new_submission), - ); - print_output(&output, pretty)? + Output::finished_with_data("requested code review", Data::NewSubmission(new_submission)) } - ("reset-exercise", Some(matches)) => { - let save_old_state = matches.is_present("save-old-state"); - - let exercise_id = matches.value_of("exercise-id").unwrap(); - let exercise_id = into_usize(exercise_id)?; - - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = PathBuf::from(exercise_path); - - let submission_url = matches.value_of("submission-url"); - - file_util::lock!(&exercise_path); + CoreCommand::ResetExercise { + save_old_state, + exercise_id, + exercise_path, + submission_url, + } => { + file_util::lock!(exercise_path); if save_old_state { // submit current state - let submission_url = into_url(submission_url.unwrap())?; - client.submit(submission_url, &exercise_path, None)?; + client.submit( + submission_url.expect("validation error"), + &exercise_path, + None, + )?; } - - // reset exercise tmc_langs::reset(&client, exercise_id, exercise_path)?; - - let output = Output::finished_with_data("reset exercise", None); - print_output(&output, pretty)? + Output::finished("reset exercise") } - ("send-feedback", Some(matches)) => { - // collect feedback values into a list - let mut feedback_answers = matches.values_of("feedback").unwrap(); + + CoreCommand::SendFeedback { + feedback, + feedback_url, + } => { + let mut feedback_answers = feedback.into_iter(); let mut feedback = vec![]; while let Some(feedback_id) = feedback_answers.next() { - let question_id = into_usize(feedback_id)?; - let answer = feedback_answers.next().unwrap().to_string(); // safe unwrap because --feedback always takes 2 values + let question_id = into_usize(&feedback_id)?; + let answer = feedback_answers + .next() + .expect("validation error") + .to_string(); feedback.push(FeedbackAnswer { question_id, answer, }); } - - let feedback_url = matches.value_of("feedback-url").unwrap(); - let feedback_url = into_url(feedback_url)?; - let response = client .send_feedback(feedback_url, feedback) .context("Failed to send feedback")?; - - let output = Output::finished_with_data( - "sent feedback", - Data::SubmissionFeedbackResponse(response), - ); - print_output(&output, pretty)? + Output::finished_with_data("sent feedback", Data::SubmissionFeedbackResponse(response)) } - ("submit", Some(matches)) => { - let dont_block = matches.is_present("dont-block"); - - let locale = matches.value_of("locale"); - let locale = if let Some(locale) = locale { - Some(into_locale(locale)?) - } else { - None - }; - - let submission_path = matches.value_of("submission-path").unwrap(); - let submission_path = Path::new(submission_path); - - let submission_url = matches.value_of("submission-url").unwrap(); - let submission_url = into_url(submission_url)?; + CoreCommand::Submit { + dont_block, + locale, + submission_path, + submission_url, + } => { file_util::lock!(submission_path); - + let locale = locale.map(|l| l.0); let new_submission = client - .submit(submission_url, submission_path, locale) + .submit(submission_url, &submission_path, locale) .context("Failed to submit")?; if dont_block { - let output = Output::finished_with_data( - "submit exercise", - Data::NewSubmission(new_submission), - ); - print_output(&output, pretty)? + Output::finished_with_data("submit exercise", Data::NewSubmission(new_submission)) } else { // same as wait-for-submission let submission_url = new_submission.submission_url; let submission_finished = client .wait_for_submission(&submission_url) .context("Failed while waiting for submissions")?; - - let output = Output::finished_with_data( + Output::finished_with_data( "submit exercise", Data::SubmissionFinished(submission_finished), - ); - print_output(&output, pretty)? + ) } } - ("update-exercises", Some(_)) => { + + CoreCommand::UpdateExercises => { let data = tmc_langs::update_exercises(&client, client_name)?; - let output = Output::finished_with_data( + Output::finished_with_data( "downloaded or updated exercises", Data::ExerciseDownload(data), - ); - print_output(&output, pretty)? + ) } - ("wait-for-submission", Some(matches)) => { - let submission_url = matches.value_of("submission-url").unwrap(); + CoreCommand::WaitForSubmission { submission_url } => { let submission_finished = client - .wait_for_submission(submission_url) + .wait_for_submission(&submission_url.into_string()) .context("Failed while waiting for submissions")?; - - let output = Output::finished_with_data( + Output::finished_with_data( "finished waiting for submission", Data::SubmissionFinished(submission_finished), - ); - print_output(&output, pretty)? + ) } - _ => unreachable!(), }; - - Ok(printed) + Ok(output) } -fn run_settings(matches: &ArgMatches, pretty: bool) -> Result { - let client_name = matches.value_of("client-name").unwrap(); +fn run_settings(settings: SettingsCommand) -> Result { + let client_name = &settings.client_name; - match matches.subcommand() { - ("get", Some(matches)) => { - let key = matches.value_of("setting").unwrap(); - let value = tmc_langs::get_setting(client_name, key)?; - let output = Output::finished_with_data("retrieved value", Data::ConfigValue(value)); - print_output(&output, pretty) + let output = match settings.command { + SettingsSubCommand::Get { setting } => { + let value = tmc_langs::get_setting(client_name, &setting)?; + Output::finished_with_data("retrieved value", Data::ConfigValue(value)) } - ("list", Some(_)) => { + + SettingsSubCommand::List => { let tmc_config = tmc_langs::get_settings(client_name)?; - let output = - Output::finished_with_data("retrieved settings", Data::TmcConfig(tmc_config)); - print_output(&output, pretty) + Output::finished_with_data("retrieved settings", Data::TmcConfig(tmc_config)) } - ("migrate", Some(matches)) => { - let exercise_path = matches.value_of("exercise-path").unwrap(); - let exercise_path = Path::new(exercise_path); - - let course_slug = matches.value_of("course-slug").unwrap(); - - let exercise_id = matches.value_of("exercise-id").unwrap(); - let exercise_id = into_usize(exercise_id)?; - - let exercise_slug = matches.value_of("exercise-slug").unwrap(); - - let exercise_checksum = matches.value_of("exercise-checksum").unwrap(); + SettingsSubCommand::Migrate { + exercise_path, + course_slug, + exercise_id, + exercise_slug, + exercise_checksum, + } => { let config_path = TmcConfig::get_location(client_name)?; let tmc_config = TmcConfig::load(client_name, &config_path)?; - tmc_langs::migrate_exercise( tmc_config, - course_slug, - exercise_slug, + &course_slug, + &exercise_slug, exercise_id, - exercise_checksum, - exercise_path, + &exercise_checksum, + &exercise_path, )?; - - let output = Output::finished_with_data("migrated exercise", None); - print_output(&output, pretty) + Output::finished("migrated exercise") } - ("move-projects-dir", Some(matches)) => { - let dir = matches.value_of("dir").unwrap(); - let target = PathBuf::from(dir); + SettingsSubCommand::MoveProjectsDir { dir } => { let config_path = TmcConfig::get_location(client_name)?; let tmc_config = TmcConfig::load(client_name, &config_path)?; - - tmc_langs::move_projects_dir(tmc_config, &config_path, target)?; - - let output = Output::finished_with_data("moved project directory", None); - print_output(&output, pretty) + tmc_langs::move_projects_dir(tmc_config, &config_path, dir)?; + Output::finished("moved project directory") } - ("set", Some(matches)) => { - let key = matches.value_of("key").unwrap(); - let value = matches.value_of("json").unwrap(); - - tmc_langs::set_setting(client_name, key, value)?; - let output = Output::finished_with_data("set setting", None); - print_output(&output, pretty) + SettingsSubCommand::Set { key, json } => { + tmc_langs::set_setting(client_name, &key, &json.to_string())?; + Output::finished("set setting") } - ("reset", Some(_)) => { - tmc_langs::reset_settings(client_name)?; - let output = Output::finished_with_data("reset settings", None); - print_output(&output, pretty) + SettingsSubCommand::Reset => { + tmc_langs::reset_settings(client_name)?; + Output::finished("reset settings") } - ("unset", Some(matches)) => { - let key = matches.value_of("setting").unwrap(); - - tmc_langs::unset_setting(client_name, key)?; - let output = Output::finished_with_data("unset setting", None); - print_output(&output, pretty) + SettingsSubCommand::Unset { setting } => { + tmc_langs::unset_setting(client_name, &setting)?; + Output::finished("unset setting") } - _ => unreachable!("validation error"), - } + }; + Ok(output) } fn print_output(output: &Output, pretty: bool) -> Result { @@ -1315,17 +1053,6 @@ fn into_usize(arg: &str) -> Result { }) } -fn into_locale(arg: &str) -> Result { - Language::from_locale(arg) - .or_else(|| Language::from_639_1(arg)) - .or_else(|| Language::from_639_3(arg)) - .with_context(|| format!("Invalid locale: {}", arg)) -} - -fn into_url(arg: &str) -> Result { - Url::parse(arg).with_context(|| format!("Failed to parse url {}", arg)) -} - // if output_path is Some, the checkstyle results are written to that path fn run_checkstyle_write_results( exercise_path: &Path, diff --git a/tmc-langs-cli/src/output.rs b/tmc-langs-cli/src/output.rs index 124698b8905..a4ba6e441f5 100644 --- a/tmc-langs-cli/src/output.rs +++ b/tmc-langs-cli/src/output.rs @@ -34,6 +34,15 @@ impl Output { data: data.into(), }) } + + pub fn finished(message: impl Into) -> Self { + Self::OutputData(OutputData { + status: Status::Finished, + message: message.into(), + result: OutputResult::ExecutedCommand, + data: None, + }) + } } #[derive(Debug, Serialize)] diff --git a/tmc-langs-util/src/file_util.rs b/tmc-langs-util/src/file_util.rs index 9e91a75313f..de1b4934c80 100644 --- a/tmc-langs-util/src/file_util.rs +++ b/tmc-langs-util/src/file_util.rs @@ -14,7 +14,7 @@ use walkdir::WalkDir; macro_rules! lock { ( $( $path: expr ),+ ) => { $( - let path_buf: PathBuf = $path.into(); + let path_buf: PathBuf = (&$path).into(); let mut fl = $crate::file_util::FileLock::new(path_buf)?; let _lock = fl.lock()?; )* diff --git a/tmc-langs/src/config/credentials.rs b/tmc-langs/src/config/credentials.rs index fe459c7ac9b..90c27af1e20 100644 --- a/tmc-langs/src/config/credentials.rs +++ b/tmc-langs/src/config/credentials.rs @@ -73,7 +73,7 @@ impl Credentials { } pub fn remove(self) -> Result<(), LangsError> { - file_util::lock!(&self.path); + file_util::lock!(self.path); file_util::remove_file(&self.path)?; Ok(())