Skip to content

Commit

Permalink
Add get command
Browse files Browse the repository at this point in the history
Includes:
* Making everything async
* Dependencies for requests
* Extending languages
* Tests
* Templates
* etc..
  • Loading branch information
avborup committed Feb 8, 2023
1 parent 5f6027b commit b3e8593
Show file tree
Hide file tree
Showing 11 changed files with 366 additions and 5 deletions.
6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ rust-ini = "0.18"
secrecy = "0.8"
yaml-rust = "0.4"
indoc = "2"
reqwest = "0.11.14"
tokio = { version = "1.25", features = ["full"] }
regex = "1.7"
colored = "2.0"
tempfile = "3"
zip = "0.6"

[dev-dependencies]
dockertest = "0.3"
bollard = "0.13"
futures-util = "0.3.26"
futures-util = "0.3"
strip-ansi-escapes = "0.1"
dotenv = "0.15"
52 changes: 49 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,32 @@ pub struct KittyArgs {

#[derive(Subcommand, Debug)]
pub enum KittySubcommand {
Config(ConfigArgs),

Get(GetArgs),

/// List all the languages you can use kitty with based on your config file
///
/// Whenever you need to provide a language as an argument to kitty (for
/// example --lang when running tests), provide its extension exactly as
/// shown in the output of this command.
Langs,

/// A utility to help you configure kitty
Config(ConfigArgs),
}

/// A utility to help you configure kitty
///
/// Kitty needs a few files to work properly. These include:
///
/// - A .kattisrc file, which you can download from https://open.kattis.com/download/kattisrc.
/// This file is used to authenticate you with Kattis, and it controls which
/// URLs kitty uses to interact with Kattis.
///
/// - A kitty.yml file, which is used to configure kitty's behaviour. This is
/// where you can configure which programming languages you can use, which
/// commands are run, and similar.
///
/// - A templates directory, which contains templates that kitty can auto-copy
/// when you fetch new problems.
#[derive(Args, Debug)]
pub struct ConfigArgs {
#[command(subcommand)]
Expand All @@ -44,3 +59,34 @@ pub enum ConfigSubcommand {
/// Shows where the kitty config directory is or should be located
Location,
}

/// Fetches a problem from Kattis by creating a solution folder of the same name
/// and downloading the official test cases. If you have defined a template, it
/// will be copied into your solution folder.
///
/// You can create your own templates for your preferred programming languages.
/// In kitty's config directory, create a 'templates' subfolder, and inside that,
/// create a file such as template.java in which you define your Java template.
#[derive(Args, Debug)]
pub struct GetArgs {
/// The ID of the problem to fetch from Kattis.
///
/// You can find the id in the URL of the problem page on kattis:
/// open.kattis.com/problems/<PROBLEM ID>
pub problem_id: String,

/// If present, remove the host name from the problem ID in templates.
///
/// If the problem ID has a host prefix, this flag will remove it when
/// inserting the ID into a template. For example, 'itu.flights' becomes
/// just 'flights'.
#[arg(short, long, default_value_t = false)]
pub no_domain: bool,

/// Programming language to use the template for.
///
/// Write the typical file extension for the language (java for Java, py for
/// python, js for JavaScript, etc.).
#[arg(short, long)]
pub lang: Option<String>,
}
179 changes: 179 additions & 0 deletions src/commands/get.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
use std::{
env, fs,
io::{self, Write},
path::{Path, PathBuf},
};

use colored::Colorize;
use eyre::{bail, Context};
use reqwest::StatusCode;
use zip::ZipArchive;

use crate::{
cli::GetArgs,
config::{language::Language, Config},
problem::{make_problem_sample_tests_zip_url, make_problem_url, problem_id_is_legal},
App,
};

pub async fn get(app: &App, args: &GetArgs) -> crate::Result<()> {
if !problem_id_is_legal(&args.problem_id) {
bail!("The given problem ID is invalid. It must only contain alphanumeric characters and periods.");
}

if !problem_exists(app, &args.problem_id).await? {
bail!("Problem '{}' does not exist", args.problem_id)
}

let solution_dir = create_solution_dir(&args.problem_id)?;

fetch_tests(app, &solution_dir, &args.problem_id)
.await
.wrap_err("Failed to fetch test cases")?;

populate_template(app, args, &solution_dir)?;

println!(
"{} solution folder for {}",
"created".bright_green(),
args.problem_id
);

Ok(())
}

async fn problem_exists(app: &App, problem_id: &str) -> crate::Result<bool> {
let url = make_problem_url(app, problem_id)?;
let response = reqwest::get(&url)
.await
.wrap_err("Failed to send request to Kattis")?;

match response.status() {
status if status.is_success() => Ok(true),
StatusCode::NOT_FOUND => Ok(false),
_ => bail!(
"Failed to get problem from Kattis (http status code: {})",
response.status()
),
}
}

fn create_solution_dir(problem_id: &str) -> crate::Result<PathBuf> {
let cwd = env::current_dir().wrap_err("Failed to get current working directory")?;
let solution_dir = cwd.join(problem_id);

let result = fs::create_dir(&solution_dir);

if let Err(e) = &result {
if let io::ErrorKind::AlreadyExists = e.kind() {
bail!("Cannot create solution directory since it already exists");
}

result.wrap_err("Failed to create solution directory at this location")?;
}

Ok(solution_dir)
}

async fn fetch_tests(
app: &App,
solution_dir: impl AsRef<Path>,
problem_id: &str,
) -> crate::Result<()> {
let test_dir = solution_dir.as_ref().join("tests");

fs::create_dir(&test_dir).wrap_err("Failed to create test files directory")?;

let zip_url = make_problem_sample_tests_zip_url(app, problem_id)?;
let zip_response = reqwest::get(&zip_url)
.await
.wrap_err("Failed to send request to Kattis")?;

let status = zip_response.status();
if !status.is_success() {
if status == StatusCode::NOT_FOUND {
return Ok(());
}

bail!("Failed to fetch tests from Kattis (http status code: {status})",);
}

let response_bytes = zip_response.bytes().await?;
let mut tmpfile = tempfile::tempfile()?;
tmpfile.write_all(&response_bytes)?;

let mut zip = ZipArchive::new(tmpfile)?;

for i in 0..zip.len() {
let mut file = zip.by_index(i)?;

eyre::ensure!(!file.is_dir(), "Zip file contained directory");

let dest_path = test_dir.join(file.name());
let mut dest = fs::File::create(&dest_path)
.wrap_err_with(|| format!("Failed to create file at '{}'", dest_path.display()))?;

io::copy(&mut file, &mut dest)?;
}

Ok(())
}

fn populate_template(
app: &App,
args: &GetArgs,
solution_dir: impl AsRef<Path>,
) -> crate::Result<()> {
let lang =
match &args.lang {
Some(lang) => Some(app.config.lang_from_file_ext(&lang).ok_or_else(|| {
eyre::eyre!("Could not find a language to use for .{} files", lang)
})?),
None => app.config.default_language(),
};

if let Some(language) = lang {
copy_template_with_lang(args, solution_dir, language)?;
}

Ok(())
}

fn copy_template_with_lang(
args: &GetArgs,
solution_dir: impl AsRef<Path>,
lang: &Language,
) -> crate::Result<()> {
let templates_dir = Config::templates_dir_path();
let template_file = templates_dir
.join("template")
.with_extension(lang.file_ext());

if !template_file.exists() {
println!(
"{} does not exist. kitty will skip creating the file for you.",
template_file.display()
);
return Ok(());
}

let file_name = if args.no_domain {
args.problem_id.split('.').last().unwrap()
} else {
&args.problem_id
};

let template = fs::read_to_string(&template_file)
.wrap_err_with(|| eyre::eyre!("failed to read {}", template_file.display()))?
.replace("$FILENAME", file_name);

let solution_file = solution_dir
.as_ref()
.join(file_name)
.with_extension(lang.file_ext());

fs::write(&solution_file, template)
.wrap_err_with(|| eyre::eyre!("failed to write {}", solution_file.display()))?;

Ok(())
}
2 changes: 2 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod config;
mod get;
mod langs;

pub use config::config;
pub use get::get;
pub use langs::langs;
12 changes: 11 additions & 1 deletion src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use kattisrc::Kattisrc;
use self::{language::Language, parser::parse_config_from_yaml_file};

mod kattisrc;
mod language;
pub mod language;
mod parser;

#[derive(Debug, Default)]
Expand Down Expand Up @@ -56,4 +56,14 @@ impl Config {
pub fn templates_dir_path() -> PathBuf {
Self::dir_path().join("templates")
}

pub fn lang_from_file_ext(&self, file_ext: &str) -> Option<&Language> {
self.languages.iter().find(|l| l.file_ext() == file_ext)
}

pub fn default_language(&self) -> Option<&Language> {
self.default_language
.as_ref()
.and_then(|l| self.lang_from_file_ext(l))
}
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use colored::Colorize;
pub mod cli;
mod commands;
mod config;
mod problem;

pub type Result<T> = eyre::Result<T>;

Expand Down Expand Up @@ -30,6 +31,7 @@ async fn try_run(args: cli::KittyArgs) -> crate::Result<()> {
match &app.args.subcommand {
Langs => commands::langs(&app).await,
Config(args) => commands::config(&app, args).await,
Get(args) => commands::get(&app, args).await,
}
}

Expand Down
19 changes: 19 additions & 0 deletions src/problem.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use regex::Regex;

use crate::App;

pub fn make_problem_url(app: &App, problem_id: &str) -> crate::Result<String> {
let host_name = &app.config.try_kattisrc()?.kattis.host_name;
let url = format!("https://{host_name}/problems/{problem_id}");
Ok(url)
}

pub fn make_problem_sample_tests_zip_url(app: &App, problem_id: &str) -> crate::Result<String> {
let problem_url = make_problem_url(app, problem_id)?;
let zip_url = format!("{problem_url}/file/statement/samples.zip");
Ok(zip_url)
}

pub fn problem_id_is_legal(problem_id: &str) -> bool {
Regex::new(r"^[\w\d\.]+$").unwrap().is_match(problem_id)
}
2 changes: 2 additions & 0 deletions tests/kitty-cli/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
FROM rust:1.67-slim

RUN apt update && apt install -y libssl-dev pkg-config

WORKDIR /
# Create blank project
RUN USER=root cargo new app
Expand Down
9 changes: 9 additions & 0 deletions tests/kitty-cli/data/template.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import java.util.Scanner;

public class $FILENAME {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);

sc.close();
}
}
Loading

0 comments on commit b3e8593

Please sign in to comment.