use chrono::Local;
use clap::{Parser, Subcommand};
use path_slash::PathBufExt;
use regex::{Captures, Regex};
use sanitize_filename::sanitize;
use serde::Serialize;
use std::collections::HashSet;
use std::error::Error;
use std::fmt::{Debug, Display, Formatter};
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::{fs, io, process};
use walkdir::WalkDir;
use weblab_assignment_structure::{
Checklist, InlineQuestionList, MCOption, MCQuestion, MCStyle, OpenQuestion,
ProgrammingAssignment, WeblabAssignment, WeblabFolder,
};
use zip::write::FileOptions;
use zip::ZipWriter;
#[derive(Serialize)]
pub struct Alternative {
#[serde(rename = "text")]
pub text: &'static str,
#[serde(rename = "correct")]
pub correct: bool,
}
#[derive(Serialize, Default)]
struct Question {
#[serde(rename = "type")]
r#type: String,
#[serde(rename = "language")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
language: Option<String>,
#[serde(rename = "libraryVisible")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
library_visible: Option<bool>,
#[serde(rename = "programOutputVisible")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
program_output_visible: Option<bool>,
#[serde(rename = "specTestDetailsVisible")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
spec_test_details_visible: Option<bool>,
#[serde(rename = "dockerImage")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
docker_image: Option<String>,
#[serde(rename = "editorLanguageId")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
editor_language_id: Option<String>,
#[serde(rename = "editorTestLanguageId")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
editor_test_language_id: Option<String>,
#[serde(rename = "editorLibraryLanguageId")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
editor_library_language_id: Option<String>,
#[serde(rename = "solutionFile")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
solution_file: Option<String>,
#[serde(rename = "solutionTemplateFile")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
solution_template_file: Option<String>,
#[serde(rename = "specTestFile")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
spec_test_file: Option<String>,
#[serde(rename = "userTestTemplateFile")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
user_test_template_file: Option<String>,
#[serde(rename = "libraryFile")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
library_file: Option<String>,
#[serde(rename = "essayAnswerFile")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
essay_answer_file: Option<String>,
#[serde(rename = "questionTextFile")]
question_text_file: String,
#[serde(rename = "title")]
title: String,
#[serde(rename = "explanation")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
explanation: Option<String>,
#[serde(rename = "randomOrder")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
random_order: Option<bool>,
#[serde(rename = "selectN")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
num_answers_expected: Option<usize>,
#[serde(rename = "alternatives")]
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
alternatives: Vec<Alternative>,
}
#[derive(Serialize)]
struct Child {
#[serde(rename = "childAssignmentRelPath")]
child_assignment_rel_path: String,
}
#[derive(Serialize)]
struct Folder {
#[serde(rename = "title")]
title: String,
#[serde(rename = "children")]
children: Vec<Child>,
#[serde(rename = "displayInline")]
display_inline: bool,
#[serde(rename = "descriptionFile")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
description_file: Option<String>,
}
#[derive(Serialize)]
pub struct GradingChecklist {
#[serde(default)]
pub items: Vec<GradingChecklistItem>,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub weight: f32,
#[serde(rename = "reviewProbability")]
#[serde(default)]
pub review_probability: f32,
}
impl Default for GradingChecklist {
fn default() -> Self {
Self {
items: Default::default(),
enabled: false,
weight: 1.0,
review_probability: 0.0,
}
}
}
#[derive(Serialize)]
pub struct GradingChecklistItem {
pub description: String,
pub weight: f32,
}
impl Default for GradingChecklistItem {
fn default() -> Self {
GradingChecklistItem {
description: Default::default(),
weight: 1.0,
}
}
}
#[derive(Serialize)]
pub struct Grading {
#[serde(rename = "weightInCollection")]
#[serde(default)]
pub weight_in_collection: f32,
#[serde(rename = "maxGrade")]
#[serde(default)]
pub max_grade: f32,
#[serde(rename = "customItemsTotalWeight")]
#[serde(default)]
pub total_weight: f32,
#[serde(rename = "weightComputedPart")]
#[serde(default)]
pub automatically_graded_part: f32,
#[serde(rename = "baseGrade")]
#[serde(default)]
pub base_grade: f32,
#[serde(rename = "minimumToPass")]
#[serde(default)]
pub minimum_to_pass: f32,
#[serde(rename = "passNSubAssignments")]
#[serde(default)]
pub pass_n_sub_assignments: i32,
#[serde(default)]
pub checklist: GradingChecklist,
#[serde(rename = "additionalGradeComponents")]
#[serde(default)]
pub additional_grading_components: Vec<String>,
#[serde(rename = "customGradingFormula")]
#[serde(default)]
pub custom_grading_formula: String,
}
impl Default for Grading {
fn default() -> Self {
Self {
weight_in_collection: 1.0,
max_grade: 10.0,
total_weight: 1.0,
automatically_graded_part: 1.0,
base_grade: 0.0,
minimum_to_pass: 0.0,
pass_n_sub_assignments: -1,
checklist: Default::default(),
additional_grading_components: Vec::new(),
custom_grading_formula: "".to_string(),
}
}
}
#[derive(Serialize)]
struct AssignmentData {
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "question")]
question: Option<Question>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(flatten)]
folder: Option<Folder>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
grading: Option<Grading>,
#[serde(default)]
created: String,
}
impl Default for AssignmentData {
fn default() -> Self {
Self {
question: None,
folder: None,
grading: None,
created: Local::now().format("%Y-%m-%dT%H:%M:%S%.3f%z").to_string(),
}
}
}
impl AssignmentData {
pub fn new_folder(
title: &str,
relative_paths: &[&str],
inline: bool,
assignment_text: &str,
grading: Grading,
) -> Self {
Self {
grading: Some(grading),
folder: Some(Folder {
title: title.to_string(),
children: relative_paths
.iter()
.map(|i| Child {
child_assignment_rel_path: i.to_string(),
})
.collect(),
display_inline: inline,
description_file: (!assignment_text.is_empty())
.then(|| "assignment_description.md".to_string()),
}),
..Default::default()
}
}
pub fn new_programming(
title: &str,
library_visible: bool,
program_output_visible: bool,
spec_test_details_visible: bool,
grading: Grading,
) -> Self {
Self {
question: Some(Question {
r#type: "ProgrammingQuestion".to_string(),
language: Some("docker:cese4000-2022-rust".to_string()),
library_visible: Some(library_visible),
program_output_visible: Some(program_output_visible),
spec_test_details_visible: Some(spec_test_details_visible),
docker_image: Some("".to_string()),
editor_language_id: Some("rust".to_string()),
editor_test_language_id: Some("rust".to_string()),
editor_library_language_id: Some("rust".to_string()),
solution_file: Some("solution.rs".to_string()),
solution_template_file: Some("solution_template.rs".to_string()),
spec_test_file: Some("test.rs".to_string()),
user_test_template_file: Some("test_template.rs".to_string()),
library_file: Some("library.rs".to_string()),
question_text_file: "question.md".to_string(),
title: title.to_string(),
..Default::default()
}),
grading: Some(grading),
..Default::default()
}
}
pub fn new_mc(
title: &str,
explanation: &str,
randomize: bool,
style: MCStyle,
options: &[MCOption],
grading: Grading,
) -> Self {
Self {
question: Some(Question {
r#type: "MultipleChoiceQuestion".to_string(),
question_text_file: "question.md".to_string(),
explanation: Some(explanation.to_string()),
title: title.to_string(),
random_order: Some(randomize),
num_answers_expected: Some(match style {
MCStyle::AllThatApply => 0,
MCStyle::NumCorrect(i) => i,
}),
alternatives: options
.iter()
.map(|i| Alternative {
text: i.text,
correct: i.is_correct,
})
.collect(),
..Default::default()
}),
grading: Some(grading),
..Default::default()
}
}
pub fn new_open(title: &str, grading: Grading) -> Self {
Self {
question: Some(Question {
r#type: "EssayQuestion".to_string(),
question_text_file: "question.md".to_string(),
essay_answer_file: Some("answer.md".to_string()),
title: title.to_string(),
..Default::default()
}),
grading: Some(grading),
..Default::default()
}
}
}
#[derive(Parser)]
#[clap(author, version, about, long_about = None)]
struct Cli {
#[clap(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Generate {
#[clap(subcommand)]
filetype: GenerateType,
},
}
#[derive(Clone, PartialEq, Eq, Subcommand)]
enum GenerateType {
Zip {
#[clap(default_value_t = String::from("output.zip"))]
#[clap(name = "output")]
#[clap(long = "output")]
#[clap(short = 'o')]
output: String,
#[clap(name = "filter")]
#[clap(long = "filter")]
#[clap(short = 'f')]
assignment_filter: Option<String>,
#[clap(name = "root")]
#[clap(long = "root")]
#[clap(short = 'r')]
zip_root: Option<String>,
},
Folder {
#[clap(default_value_t = String::from("output"))]
#[clap(name = "output")]
#[clap(long = "output")]
#[clap(short = 'o')]
output: String,
#[clap(name = "filter")]
#[clap(long = "filter")]
#[clap(short = 'f')]
assignment_filter: Option<String>,
},
}
#[derive(Debug)]
struct StringError(String);
impl Error for StringError {}
impl Display for StringError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
fn generate_zip(
path: impl AsRef<Path>,
assignment: WeblabAssignment,
root: Option<String>,
assignment_filter: &Option<Regex>,
) -> Result<(), Box<dyn Error>> {
let mut root = root.map(PathBuf::from);
let path = path.as_ref().to_path_buf();
let mut folder_path = path.clone();
folder_path.set_extension("");
let generated = generate_folder(&folder_path, assignment, assignment_filter)?;
if generated.len() == 1 && assignment_filter.is_some() && root.is_none() {
let generated_path = &generated[0];
root = pathdiff::diff_paths(generated_path, &folder_path);
}
if let Some(i) = root {
folder_path = folder_path.join(i);
}
let f = File::create(path)?;
let mut w = ZipWriter::new(f);
for entry in WalkDir::new(&folder_path)
.into_iter()
.filter_map(|e| e.ok())
{
let metadata = entry.metadata()?;
let relative = pathdiff::diff_paths(entry.path(), &folder_path).expect("must be prefix");
if metadata.is_dir() {
w.add_directory(relative.to_slash_lossy(), FileOptions::default())?;
} else {
w.start_file(relative.to_slash_lossy(), FileOptions::default())?;
let mut f = File::open(entry.path())?;
let mut contents = Vec::new();
f.read_to_end(&mut contents)?;
w.write_all(&contents)?;
}
}
w.finish()?;
Ok(())
}
fn write_and_fmt<P: AsRef<Path>, S: ToString>(path: P, code: S) -> io::Result<()> {
let r = regex::Regex::new(r#"#\[doc *= *"(.*)"\]"#).expect("should compile");
let rq = regex::Regex::new(r#"\\(.)"#).expect("should compile");
fs::write(&path, code.to_string())?;
process::Command::new("rustfmt")
.arg(path.as_ref())
.spawn()?
.wait()?;
let code = fs::read_to_string(&path)?;
let replaced = r.replace_all(&code, |caps: &Captures| {
format!("///{}", rq.replace_all(&caps[1], "$1"))
});
fs::write(&path, replaced.as_ref())?;
Ok(())
}
fn generate_folder_internal(
path: impl AsRef<Path>,
WeblabFolder {
title,
weight,
assignment_text,
assignments,
}: &WeblabFolder,
assignment_filter: &Option<Regex>,
inline: bool,
mut match_always: bool,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
if let Some(f) = assignment_filter {
if f.is_match(title) {
match_always = true;
}
}
let p = path.as_ref().to_path_buf().join(sanitize(title));
std::fs::create_dir_all(&p)?;
let mut f = File::create(p.join("assignment-data.json"))?;
let grading = Grading {
weight_in_collection: find_weight(weight, assignments),
..Default::default()
};
let s = serde_json::to_string_pretty(&AssignmentData::new_folder(
title,
&assignments.iter().map(|i| i.title()).collect::<Vec<_>>(),
inline,
assignment_text,
grading,
))?;
f.write_all(s.as_bytes())?;
if !assignment_text.is_empty() {
let mut description = File::create(p.join("assignment_description.md"))?;
description.write_all(assignment_text.as_bytes())?;
}
let mut generated = Vec::new();
for i in *assignments {
generated.extend(recursive_generate_folder(
&p,
i,
assignment_filter,
match_always,
)?);
}
Ok(generated)
}
fn generate_grading(checklist: &Option<Checklist>, weight: f32) -> Grading {
match checklist {
Some(Checklist::PassFail { items }) => {
let grading_c = GradingChecklist {
items: items
.iter()
.map(|i| GradingChecklistItem {
weight: 1.0,
description: i.to_string(),
})
.collect(),
enabled: true,
..Default::default()
};
Grading {
checklist: grading_c,
weight_in_collection: weight,
custom_grading_formula: "if $checklist < 9.9999 then 0 else $computed end"
.to_string(),
..Default::default()
}
}
Some(Checklist::Normal {
items,
total_weight,
checklist_percentage,
}) => {
let mut automatic_part = 1.0;
let mut checklist_part = (1.0 / checklist_percentage) - 1.0;
if checklist_percentage > &0.99 {
automatic_part = 0.0;
checklist_part = 1.0;
}
let grading_c = GradingChecklist {
items: items
.iter()
.map(|i| GradingChecklistItem {
weight: i.weight,
description: i.description.to_string(),
})
.collect(),
enabled: true,
weight: checklist_part,
..Default::default()
};
Grading {
checklist: grading_c,
weight_in_collection: weight,
automatically_graded_part: automatic_part,
total_weight: *total_weight,
..Default::default()
}
}
None => Grading {
checklist: GradingChecklist {
items: vec![],
enabled: false,
weight,
review_probability: 0.0,
},
weight_in_collection: weight,
..Default::default()
},
}
}
fn find_weight(weight: &Option<f32>, members: &[WeblabAssignment]) -> f32 {
if let Some(w) = weight {
*w
} else {
members.iter().map(|i| i.weight()).sum()
}
}
fn recursive_generate_folder(
path: impl AsRef<Path>,
assignment: &WeblabAssignment,
assignment_filter: &Option<Regex>,
match_always: bool,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let mut generated = vec![];
match assignment {
WeblabAssignment::Programming(ProgrammingAssignment {
title,
weight,
assignment_text,
mut library_visible,
spectest_stdout_visible: _,
test,
solution,
library,
test_template,
solution_template,
checklist,
}) => {
if let Some(f) = assignment_filter {
if !f.is_match(title) && !match_always {
return Ok(vec![]);
}
}
let p = &(*path.as_ref()).to_path_buf().join(&sanitize(title));
std::fs::create_dir_all(&p)?;
if let Some(l) = library {
write_and_fmt(p.join("library.rs"), l)?;
} else {
write_and_fmt(p.join("library.rs"), "")?;
library_visible = false;
}
write_and_fmt(p.join("solution.rs"), solution)?;
write_and_fmt(p.join("solution_template.rs"), solution_template)?;
write_and_fmt(p.join("test.rs"), test)?;
write_and_fmt(p.join("test_template.rs"), test_template)?;
let mut f = File::create(p.join("question.md"))?;
f.write_all(assignment_text.as_bytes())?;
let mut f = File::create(p.join("assignment-data.json"))?;
let grading = generate_grading(checklist, *weight);
let s = serde_json::to_string_pretty(&AssignmentData::new_programming(
title,
library_visible,
false,
false,
grading,
))?;
f.write_all(s.as_bytes())?;
generated.push(p.to_path_buf());
}
WeblabAssignment::Open(OpenQuestion {
title,
weight,
assignment_text,
expected_answer,
checklist,
..
}) => {
if let Some(f) = assignment_filter {
if !f.is_match(title) && !match_always {
return Ok(vec![]);
}
}
let p = &(*path.as_ref()).to_path_buf().join(&sanitize(title));
std::fs::create_dir_all(&p)?;
let mut f = File::create(p.join("answer.md"))?;
f.write_all(expected_answer.as_bytes())?;
let mut f = File::create(p.join("question.md"))?;
f.write_all(assignment_text.as_bytes())?;
let mut f = File::create(p.join("assignment-data.json"))?;
let s = serde_json::to_string_pretty(&AssignmentData::new_open(
title,
generate_grading(checklist, *weight),
))?;
f.write_all(s.as_bytes())?;
generated.push(p.to_path_buf());
}
WeblabAssignment::MultipleChoice(MCQuestion {
title,
weight,
assignment_text,
options,
randomize,
style,
explanation,
}) => {
if let Some(f) = assignment_filter {
if !f.is_match(title) && !match_always {
return Ok(vec![]);
}
}
let p = &(*path.as_ref()).to_path_buf().join(&sanitize(title));
std::fs::create_dir_all(&p)?;
let mut f = File::create(p.join("question.md"))?;
f.write_all(assignment_text.as_bytes())?;
let grading = Grading {
weight_in_collection: *weight,
..Default::default()
};
let mut f = File::create(p.join("assignment-data.json"))?;
let s = serde_json::to_string_pretty(&AssignmentData::new_mc(
title,
explanation,
*randomize,
*style,
*options,
grading,
))?;
f.write_all(s.as_bytes())?;
generated.push(p.to_path_buf());
}
WeblabAssignment::Folder(wf) => {
generated.extend(generate_folder_internal(
path,
wf,
assignment_filter,
false,
match_always,
)?);
}
WeblabAssignment::InlineQuestionList(InlineQuestionList {
title,
weight,
assignment_text,
assignments,
}) => {
generated.extend(generate_folder_internal(
path,
&WeblabFolder {
title,
weight: *weight,
assignment_text,
assignments,
},
assignment_filter,
true,
match_always,
)?);
}
}
Ok(generated)
}
fn generate_folder(
path: impl AsRef<Path>,
assignment: WeblabAssignment,
assignment_filter: &Option<Regex>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
if path.as_ref().exists() {
std::fs::remove_dir_all(&path)?;
}
std::fs::create_dir_all(&path)?;
recursive_generate_folder(path, &assignment, assignment_filter, false)
}
fn check_folder(title: &str, assignments: &[WeblabAssignment]) -> Result<(), Box<dyn Error>> {
let mut titles = HashSet::new();
for i in assignments {
if titles.contains(i.title()) {
return Err(Box::new(StringError(format!("folder `{title}` contains multiple assignments with the name `{}`, which leads to ambiguity", i.title()))));
} else {
titles.insert(i.title());
check_assignment_tree(i)?;
}
}
Ok(())
}
fn check_assignment_tree(assignment: &WeblabAssignment) -> Result<(), Box<dyn Error>> {
match assignment {
WeblabAssignment::Programming(_) => {}
WeblabAssignment::Open(_) => {}
WeblabAssignment::MultipleChoice(_) => {}
WeblabAssignment::Folder(WeblabFolder {
title,
weight: _,
assignments,
assignment_text: _,
}) => {
check_folder(title, assignments)?;
}
WeblabAssignment::InlineQuestionList(InlineQuestionList {
title,
weight: _,
assignment_text: _,
assignments,
}) => {
check_folder(title, assignments)?;
}
}
Ok(())
}
pub fn error_main(assignment: WeblabAssignment, args: &[String]) -> Result<(), Box<dyn Error>> {
check_assignment_tree(&assignment)?;
let args: Cli = Cli::parse_from(args);
match args.command {
Command::Generate { filetype } => match filetype {
GenerateType::Zip {
output,
assignment_filter,
zip_root,
..
} => {
let path = PathBuf::from(output);
if let Some("zip") = path
.extension()
.map(|i| i.to_string_lossy().to_string())
.as_deref()
{
generate_zip(
path,
assignment,
zip_root,
&assignment_filter.as_deref().map(Regex::new).transpose()?,
)?;
} else {
return Err(Box::new(StringError(
"expected output file to have a zip extension".to_string(),
)));
}
}
GenerateType::Folder {
output,
assignment_filter,
..
} => {
let path = PathBuf::from(output);
generate_folder(
path,
assignment,
&assignment_filter.as_deref().map(Regex::new).transpose()?,
)?;
}
},
}
Ok(())
}
pub fn main(assignment: WeblabAssignment, args: &[String]) {
if let Err(e) = error_main(assignment, args) {
eprintln!("{}", e);
}
}