Files
coursebank/src/data/gradescope.rs
T
2026-08-07 11:47:30 -04:00

860 lines
28 KiB
Rust

// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Reading Gradescope's per-question CSV exports.
//!
//! Gradescope exports one file per question, named `1.csv` through `N.csv`, in
//! wide form. Every assumption encoded here was checked against a real 32-question,
//! 24-student export rather than inferred from documentation, because several of
//! them are not what the format suggests.
//!
//! The layout is:
//!
//! ```text
//! Assignment Submission ID, Question Submission ID, First Name, Last Name,
//! SID, Email, Sections, Score, Submission Time, <rubric columns...>,
//! Adjustment, Comments, Grader, Tags
//! ```
//!
//! The rubric columns are the header slice strictly between `Submission Time`
//! and `Adjustment`. Student cells in those columns are the literal strings
//! `true` and `false`.
//!
//! Four things about real exports that a naive parser gets wrong:
//!
//! *Trailing rows are not aligned with the header.* After the student rows come
//! `Point Values`, `Rubric Numbers`, and `Scoring Method` rows with a label, some
//! empty cells, and then the numbers. The CSV reader must be in flexible mode or
//! it errors on the whole file.
//!
//! *Rubric labels are not always bare option letters.* Real ones include
//! `Selected C`, `Eliminated A`, and — this is the interesting case — a letter
//! followed by an instructor's parenthetical, such as
//! `B (Technically speaking, this answer describes HBD/HBA, but I can see how
//! this distractor is poorly written.)`. Those columns carry partial credit.
//!
//! *Partial credit awarded to a distractor is evidence, not noise.* When you gave
//! 1.0 of 1.5 points for choosing B, you decided at grading time that B was
//! partly defensible. That is exactly the ambiguity signal item analysis is
//! trying to detect, so it is captured as [`Flag::Ambiguous`] rather than
//! rounded away.
//!
//! *Bonus questions have `max_points` of zero* while a rubric column still
//! carries points. Key detection therefore uses the largest value across the
//! maximum *and* every column, not the maximum alone.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::responses::{Response, ResponseSet, administration_id};
use crate::taxonomy::Flag;
/// What a rubric column represents.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnKind {
/// The student selected this option.
Select,
/// The student eliminated this option, under elimination scoring.
Eliminate,
/// Something else: a catch-all rubric row such as
/// `Incorrect selection with no eliminations`.
Other,
}
/// One rubric column.
#[derive(Debug, Clone)]
pub struct RubricColumn {
/// The header label, verbatim.
pub label: String,
/// What the column means.
pub kind: ColumnKind,
/// The option letter, when the label named one.
pub letter: Option<String>,
/// The instructor's parenthetical annotation, when there was one. Worth
/// keeping: it is usually the instructor explaining why an item is flawed.
pub note: Option<String>,
/// Points this column awards, from the `Point Values` row.
pub value: Option<f64>,
}
/// One student's row in a question file.
#[derive(Debug, Clone)]
pub struct StudentRow {
/// The institutional student id.
pub sid: Option<String>,
/// First and last name joined.
pub name: Option<String>,
/// The email.
pub email: Option<String>,
/// Section or lab.
pub section: Option<String>,
/// Points awarded, authoritative.
pub score: f64,
/// The submission timestamp, verbatim.
pub submission_time: Option<String>,
/// Indices of rubric columns marked `true`.
pub marks: Vec<usize>,
}
/// One parsed question file.
#[derive(Debug, Clone)]
pub struct Question {
/// The question number, from the file name.
pub number: u32,
/// Where it came from.
pub path: PathBuf,
/// The rubric columns, in header order.
pub columns: Vec<RubricColumn>,
/// The maximum points from the `Point Values` row.
pub max_points: f64,
/// The scoring method, when stated.
pub scoring_method: Option<String>,
/// The student rows.
pub rows: Vec<StudentRow>,
}
impl Question {
/// The largest point value anywhere in the question.
///
/// Bonus questions record a maximum of zero while still awarding points, so
/// the key has to be found by looking at the columns too.
///
/// # Returns
///
/// The largest value seen.
pub fn top_value(&self) -> f64 {
let mut top = self.max_points;
for c in &self.columns {
if let Some(v) = c.value {
if v > top {
top = v;
}
}
}
top
}
/// The option letters that earn full credit.
///
/// # Returns
///
/// The keyed letters, sorted. Empty when the point values were absent.
pub fn keyed(&self) -> Vec<String> {
let top = self.top_value();
if top <= 0.0 {
return Vec::new();
}
let mut out: BTreeSet<String> = BTreeSet::new();
for c in &self.columns {
if c.kind == ColumnKind::Select {
if let (Some(letter), Some(v)) = (&c.letter, c.value) {
if (v - top).abs() < 1e-9 {
out.insert(letter.clone());
}
}
}
}
out.into_iter().collect()
}
/// Distractors that were awarded partial credit at grading time.
///
/// # Returns
///
/// Letter, points awarded, and the instructor's note if any.
pub fn partial_credit(&self) -> Vec<(String, f64, Option<String>)> {
let top = self.top_value();
let mut out = Vec::new();
for c in &self.columns {
if c.kind != ColumnKind::Select {
continue;
}
if let (Some(letter), Some(v)) = (&c.letter, c.value) {
if v > 0.0 && v < top - 1e-9 {
out.push((letter.clone(), v, c.note.clone()));
}
}
}
out
}
/// Whether this looks like a bonus question.
///
/// # Returns
///
/// `true` when the maximum is zero but a column still awards points.
pub fn looks_like_bonus(&self) -> bool {
self.max_points <= 0.0 && self.top_value() > 0.0
}
/// Whether elimination scoring was in use.
pub fn uses_elimination(&self) -> bool {
self.columns.iter().any(|c| c.kind == ColumnKind::Eliminate)
}
/// Points the question was worth.
///
/// # Returns
///
/// The stated maximum, falling back to the largest column value for bonus
/// questions so that credit is still a meaningful fraction.
pub fn points_possible(&self) -> f64 {
if self.max_points > 0.0 {
self.max_points
} else {
self.top_value()
}
}
/// Flags implied by how the question was graded.
///
/// # Returns
///
/// [`Flag::Ambiguous`] when a distractor received partial credit.
pub fn implied_flags(&self) -> Vec<Flag> {
if self.partial_credit().is_empty() {
Vec::new()
} else {
vec![Flag::Ambiguous]
}
}
}
/// What the ingest needs to know that the export does not say.
#[derive(Debug, Clone)]
pub struct Context {
/// The course code.
pub course: String,
/// The term.
pub term: String,
/// The assessment id.
pub assessment_id: String,
/// The administration date.
pub date: Option<Date>,
/// The form, when forms were used.
pub form: Option<String>,
}
/// The result of reading a directory of question files.
#[derive(Debug, Clone)]
pub struct Import {
/// The normalized responses.
pub responses: ResponseSet,
/// The parsed question files, kept so grading-time decisions can be folded
/// into item calibration.
pub questions: Vec<Question>,
}
/// Classifies a rubric column label.
///
/// # Arguments
///
/// * `label` - the header text.
///
/// # Returns
///
/// The kind, the option letter if the label named one, and any trailing
/// annotation with its surrounding punctuation trimmed.
pub fn classify(label: &str) -> (ColumnKind, Option<String>, Option<String>) {
let trimmed = label.trim();
let lower = trimmed.to_ascii_lowercase();
let (kind, rest) = if let Some(r) = lower.strip_prefix("selected ") {
(
ColumnKind::Select,
trimmed[trimmed.len() - r.len()..].trim(),
)
} else if let Some(r) = lower.strip_prefix("eliminated ") {
(
ColumnKind::Eliminate,
trimmed[trimmed.len() - r.len()..].trim(),
)
} else {
(ColumnKind::Select, trimmed)
};
let mut chars = rest.chars();
let Some(first) = chars.next() else {
return (ColumnKind::Other, None, Some(label.to_string()));
};
let upper = first.to_ascii_uppercase();
let tail = chars.as_str();
// A single letter A-H, optionally followed by an annotation that does not
// begin with another alphanumeric. `B (poorly written)` is option B;
// `Both A and C are wrong` is not.
let letter_like = upper.is_ascii_uppercase()
&& ('A'..='H').contains(&upper)
&& tail
.chars()
.next()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
if !letter_like {
return (ColumnKind::Other, None, Some(label.to_string()));
}
let note = tail.trim().trim_matches(|c: char| "().,;: ".contains(c));
let note = if note.is_empty() {
None
} else {
Some(note.to_string())
};
(kind, Some(upper.to_string()), note)
}
/// Parses one question file.
///
/// # Arguments
///
/// * `path` - the CSV file.
///
/// # Returns
///
/// The parsed question.
///
/// # Errors
///
/// Returns [`Error::Csv`] on a malformed file and [`Error::Invalid`] when the
/// header lacks the `Submission Time` column that delimits the rubric.
pub fn parse_question(path: &Path) -> Result<Question> {
let number = path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.parse::<u32>().ok())
.ok_or_else(|| {
Error::Invalid(vec![format!(
"{} is not named like a Gradescope question export (expected `12.csv`)",
path.display()
)])
})?;
// Flexible mode is required: the trailing `Point Values` rows are shorter
// than the header.
let mut reader = csv::ReaderBuilder::new()
.flexible(true)
.has_headers(false)
.from_path(path)
.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let mut records = reader.records();
let header = match records.next() {
Some(r) => r.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?,
None => return Err(Error::Invalid(vec![format!("{} is empty", path.display())])),
};
let header: Vec<String> = header.iter().map(|s| s.trim().to_string()).collect();
let index_of = |name: &str| header.iter().position(|h| h == name);
let start = match index_of("Submission Time") {
Some(i) => i + 1,
None => {
return Err(Error::Invalid(vec![format!(
"{} has no `Submission Time` column, so the rubric columns cannot be located; \
is this a Gradescope per-question export?",
path.display()
)]));
}
};
let end = index_of("Adjustment").unwrap_or(header.len());
if end < start {
return Err(Error::Invalid(vec![format!(
"{} has `Adjustment` before `Submission Time`",
path.display()
)]));
}
let mut columns: Vec<RubricColumn> = header[start..end]
.iter()
.map(|label| {
let (kind, letter, note) = classify(label);
RubricColumn {
label: label.clone(),
kind,
letter,
note,
value: None,
}
})
.collect();
let sid_col = index_of("SID");
let first_col = index_of("First Name");
let last_col = index_of("Last Name");
let email_col = index_of("Email");
let section_col = index_of("Sections");
let score_col = index_of("Score");
let time_col = index_of("Submission Time");
let mut rows = Vec::new();
let mut max_points = 0.0f64;
let mut point_values: Vec<f64> = Vec::new();
let mut scoring_method = None;
for rec in records {
let rec = rec.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let cell = |i: Option<usize>| -> Option<String> {
i.and_then(|i| rec.get(i))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
if rec.iter().all(|c| c.trim().is_empty()) {
continue;
}
let label = rec.get(0).unwrap_or("").trim();
match label {
"Point Values" => {
// Label, then some empty cells, then the maximum, then one value
// per rubric column. Alignment was verified across every file in
// a real export, but collecting the non-empty numbers in order is
// robust to a leading blank moving.
let nums: Vec<f64> = rec
.iter()
.skip(1)
.filter(|c| !c.trim().is_empty())
.filter_map(|c| c.trim().parse::<f64>().ok())
.collect();
if let Some((first, rest)) = nums.split_first() {
max_points = *first;
point_values = rest.to_vec();
}
continue;
}
"Rubric Numbers" => continue,
"Scoring Method" => {
scoring_method = cell(Some(1));
continue;
}
_ => {}
}
// A student row must have a score cell that parses; anything else is a
// trailing annotation row we have not seen before, and skipping it is
// safer than failing the import.
let score = match cell(score_col).and_then(|s| s.parse::<f64>().ok()) {
Some(s) => s,
None => {
if cell(sid_col).is_none() && cell(email_col).is_none() {
continue;
}
0.0
}
};
let marks: Vec<usize> = (0..columns.len())
.filter(|i| {
rec.get(start + i)
.map(|c| c.trim().eq_ignore_ascii_case("true"))
.unwrap_or(false)
})
.collect();
let name = match (cell(first_col), cell(last_col)) {
(Some(f), Some(l)) => Some(format!("{f} {l}")),
(Some(f), None) => Some(f),
(None, Some(l)) => Some(l),
(None, None) => None,
};
rows.push(StudentRow {
sid: cell(sid_col),
name,
email: cell(email_col),
section: cell(section_col),
score,
submission_time: cell(time_col),
marks,
});
}
// Attach point values when they line up; when they do not, leave them off
// rather than misattribute credit to the wrong option.
if point_values.len() == columns.len() {
for (c, v) in columns.iter_mut().zip(point_values.iter()) {
c.value = Some(*v);
}
}
Ok(Question {
number,
path: path.to_path_buf(),
columns,
max_points,
scoring_method,
rows,
})
}
/// Reads every question file in a directory.
///
/// # Arguments
///
/// * `dir` - the directory of `N.csv` files.
/// * `ctx` - identifying information the export does not carry.
///
/// # Returns
///
/// Normalized responses and the parsed questions.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be read and [`Error::Invalid`]
/// when it holds no question files.
pub fn ingest_dir(dir: &Path, ctx: &Context) -> Result<Import> {
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|e| Error::io(dir, e))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.extension().and_then(|e| e.to_str()) == Some("csv")
&& p.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or(false)
})
.collect();
if paths.is_empty() {
return Err(Error::Invalid(vec![format!(
"{} holds no numbered CSV files; Gradescope exports one file per question, \
named `1.csv` through `N.csv`",
dir.display()
)]));
}
// Numeric order, so `10.csv` does not sort before `2.csv`.
paths.sort_by_key(|p| {
p.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(u32::MAX)
});
let mut questions = Vec::new();
for p in &paths {
questions.push(parse_question(p)?);
}
Ok(to_responses(&questions, ctx))
}
/// Normalizes parsed questions into responses.
///
/// # Arguments
///
/// * `questions` - the parsed question files.
/// * `ctx` - identifying information.
///
/// # Returns
///
/// The import, with warnings for anything that looked wrong but not fatal.
pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
let mut set = ResponseSet::new();
let admin = administration_id(&ctx.course, &ctx.term, &ctx.assessment_id);
let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
for q in questions {
let keyed: BTreeSet<String> = q.keyed().into_iter().collect();
let points = q.points_possible();
let bonus = q.looks_like_bonus();
if keyed.is_empty() {
set.warnings.push(format!(
"question {}: no keyed option could be identified from the point values, so \
correctness is unknown; scores are still recorded",
q.number
));
}
for (letter, value, note) in q.partial_credit() {
set.warnings.push(format!(
"question {}: option {letter} was awarded {value} of {points} points at grading \
time, which is recorded as an ambiguity signal{}",
q.number,
note.map(|n| format!(" ({n})")).unwrap_or_default()
));
}
for row in &q.rows {
let mut selected = Vec::new();
let mut eliminated = Vec::new();
let mut other = Vec::new();
for &i in &row.marks {
let c = &q.columns[i];
match (c.kind, &c.letter) {
(ColumnKind::Select, Some(l)) => selected.push(l.clone()),
(ColumnKind::Eliminate, Some(l)) => eliminated.push(l.clone()),
_ => other.push(c.label.clone()),
}
}
selected.sort();
eliminated.sort();
let credit = if points > 0.0 {
row.score / points
} else {
0.0
};
let correct = if keyed.is_empty() {
None
} else if selected.is_empty() && eliminated.is_empty() && other.is_empty() {
// A wholly unmarked row is a blank response, not a wrong one.
None
} else {
let chosen: BTreeSet<String> = selected.iter().cloned().collect();
Some(chosen == keyed)
};
let student_key = row
.sid
.clone()
.or_else(|| row.email.clone())
.unwrap_or_else(|| format!("unknown-{}", counts.len()));
*counts.entry(q.number).or_insert(0) += 1;
set.rows.push(Response {
administration_id: admin.clone(),
course: ctx.course.clone(),
term: ctx.term.clone(),
assessment_id: ctx.assessment_id.clone(),
date: ctx.date,
form: ctx.form.clone(),
student_key,
sid: row.sid.clone(),
name: row.name.clone(),
email: row.email.clone(),
section: row.section.clone(),
item_number: q.number,
item_ref: None,
item_version: None,
selected,
eliminated,
correct,
credit,
points_possible: points,
score: row.score,
response_time_seconds: None,
level: None,
learning_objectives: Vec::new(),
topics: Vec::new(),
bonus,
dropped: false,
});
}
}
// Every question should have the same number of submissions. A mismatch
// usually means a student was excused from one question, which is worth
// saying out loud because it changes per-item denominators.
let sizes: BTreeSet<usize> = counts.values().copied().collect();
if sizes.len() > 1 {
let mut odd: Vec<String> = Vec::new();
let modal = *sizes.iter().next_back().unwrap_or(&0);
for (n, c) in &counts {
if *c != modal {
odd.push(format!("q{n} has {c}"));
}
}
set.warnings.push(format!(
"submission counts differ across questions (most have {modal}): {}",
odd.join(", ")
));
}
Import {
responses: set,
questions: questions.to_vec(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_bare_letters() {
let (k, l, n) = classify("C");
assert_eq!(k, ColumnKind::Select);
assert_eq!(l, Some("C".to_string()));
assert_eq!(n, None);
}
#[test]
fn classifies_selected_and_eliminated_prefixes() {
assert_eq!(classify("Selected C").0, ColumnKind::Select);
assert_eq!(classify("Selected C").1, Some("C".to_string()));
assert_eq!(classify("Eliminated A").0, ColumnKind::Eliminate);
assert_eq!(classify("Eliminated A").1, Some("A".to_string()));
}
#[test]
fn keeps_the_instructors_annotation() {
// Verbatim from a real export.
let (k, l, n) = classify(
"B (Technically speaking, this answer describes HBD/HBA, but I can see how this \
distractor is poorly written.)",
);
assert_eq!(k, ColumnKind::Select);
assert_eq!(l, Some("B".to_string()));
assert!(n.unwrap().starts_with("Technically speaking"));
let (_, l2, n2) = classify("B (preparation does not select the one most likely to bind)");
assert_eq!(l2, Some("B".to_string()));
assert!(n2.is_some());
}
#[test]
fn rejects_prose_rubric_rows() {
// Also verbatim: these are genuinely not option columns.
let (k, l, _) = classify("Incorrect selection with no eliminations.");
assert_eq!(k, ColumnKind::Other);
assert_eq!(l, None);
assert_eq!(classify("").0, ColumnKind::Other);
}
#[test]
fn does_not_mistake_a_sentence_for_option_a() {
// Starts with `A`, but the next character is alphanumeric.
assert_eq!(classify("Answered in the margin").0, ColumnKind::Other);
}
fn q(max: f64, cols: &[(&str, f64)]) -> Question {
Question {
number: 1,
path: PathBuf::from("1.csv"),
columns: cols
.iter()
.map(|(label, v)| {
let (kind, letter, note) = classify(label);
RubricColumn {
label: label.to_string(),
kind,
letter,
note,
value: Some(*v),
}
})
.collect(),
max_points: max,
scoring_method: None,
rows: Vec::new(),
}
}
#[test]
fn finds_the_key_by_top_value() {
let question = q(1.5, &[("A", 0.0), ("B", 0.0), ("C", 1.5), ("D", 0.0)]);
assert_eq!(question.keyed(), vec!["C".to_string()]);
assert!(question.partial_credit().is_empty());
}
#[test]
fn finds_the_key_on_a_bonus_question_with_zero_maximum() {
// Real case: q31 and q32 of the sample exam.
let question = q(0.0, &[("A", 0.0), ("B", 1.5), ("C", 0.0)]);
assert!(question.looks_like_bonus());
assert_eq!(question.keyed(), vec!["B".to_string()]);
assert_eq!(question.points_possible(), 1.5);
}
#[test]
fn partial_credit_to_a_distractor_flags_ambiguity() {
// Real case: q14, where B earned 1.49 of 1.5.
let question = q(1.5, &[("A", 0.0), ("B (poorly written)", 1.49), ("C", 1.5)]);
assert_eq!(question.keyed(), vec!["C".to_string()]);
let partial = question.partial_credit();
assert_eq!(partial.len(), 1);
assert_eq!(partial[0].0, "B");
assert!(partial[0].2.is_some(), "keeps the note");
assert_eq!(question.implied_flags(), vec![Flag::Ambiguous]);
}
#[test]
fn detects_elimination_scoring() {
let question = q(
0.9,
&[
("Selected C", 0.9),
("Eliminated A", 0.3),
("Eliminated B", 0.3),
],
);
assert!(question.uses_elimination());
assert_eq!(question.keyed(), vec!["C".to_string()]);
}
#[test]
fn parses_a_realistic_file() {
let dir = std::env::temp_dir().join(format!("cb-gs-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("7.csv");
std::fs::write(
&path,
"Assignment Submission ID,Question Submission ID,First Name,Last Name,SID,Email,\
Sections,Score,Submission Time,A,B,C,D,Adjustment,Comments,Grader,Tags\n\
1,11,Ada,Lovelace,1234567,ada@x.edu,L01,1.5,2026-04-01T10:00:00Z,false,false,true,\
false,,,,\n\
2,12,Alan,Turing,7654321,alan@x.edu,L01,0.0,2026-04-01T10:05:00Z,true,false,false,\
false,,,,\n\
3,13,Grace,Hopper,1111111,grace@x.edu,L02,0.0,2026-04-01T10:06:00Z,false,false,\
false,false,,,,\n\
Point Values,,,,,1.5,0,0,1.5,0\n\
Scoring Method,positive\n",
)
.unwrap();
let question = parse_question(&path).unwrap();
assert_eq!(question.number, 7);
assert_eq!(question.columns.len(), 4, "four rubric columns");
assert_eq!(question.max_points, 1.5);
assert_eq!(question.keyed(), vec!["C".to_string()]);
assert_eq!(question.rows.len(), 3, "trailing rows are not students");
assert_eq!(question.scoring_method.as_deref(), Some("positive"));
let ctx = Context {
course: "BIOSC1540".into(),
term: "2026s".into(),
assessment_id: "exam-4".into(),
date: None,
form: None,
};
let import = to_responses(&[question], &ctx);
let rows = &import.responses.rows;
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].selected, vec!["C".to_string()]);
assert_eq!(rows[0].correct, Some(true));
assert_eq!(rows[0].credit, 1.0);
assert_eq!(rows[1].correct, Some(false));
// A student who marked nothing left it blank; that is not a wrong answer.
assert_eq!(rows[2].correct, None);
assert_eq!(rows[2].selected.len(), 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_a_file_without_the_rubric_delimiter() {
let dir = std::env::temp_dir().join(format!("cb-gs-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("1.csv");
std::fs::write(&path, "Name,Score\nAda,1\n").unwrap();
let err = parse_question(&path).unwrap_err();
assert!(err.to_string().contains("Submission Time"));
std::fs::remove_dir_all(&dir).ok();
}
}