Integration: track t15

# Conflicts:
#	contributors/people.toml
#	crates/engine/src/tests.rs
#	docs/file-format-parity.md
#	docs/localization-parity.md
This commit is contained in:
aiongg
2026-10-22 03:05:39 +00:00
26 changed files with 1342 additions and 137 deletions
+115 -36
View File
@@ -139,9 +139,10 @@ impl RoundTrip {
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FileReport {
pub path: String,
/// GridCraft couldn't open the file: its message.
/// Opening the file or discovering the requested inputs failed: its message.
pub error: Option<String>,
pub warnings: usize,
/// Import warnings, in the order the importer produced them.
pub warnings: Vec<String>,
pub calc: CalcReport,
pub round_trip: RoundTrip,
}
@@ -204,67 +205,112 @@ pub fn main(args: &[String]) -> ExitCode {
Err(_) => return usage(format!("--max-report needs a whole number, not `{n}`")),
}
}
let files = collect_inputs(&a.positional);
let reports: Vec<FileReport> = files
.iter()
.map(|p| {
let path = p.to_string_lossy().into_owned();
match gridcraft_engine::io::read_file(&path) {
Ok(bytes) => check_bytes(&path, &bytes, &opts),
Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() },
}
})
.collect();
let Discovery { files, failures: mut reports } = collect_inputs(&a.positional);
reports.extend(files.iter().map(|p| {
let path = p.to_string_lossy().into_owned();
match gridcraft_engine::io::read_file(&path) {
Ok(bytes) => check_bytes(&path, &bytes, &opts),
Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() },
}
}));
if a.has("json") {
outln!("{}", pretty(&report_json(&reports, &opts)));
} else {
print_human(&reports, &opts);
}
if reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE }
if !files.is_empty() && reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE }
}
#[derive(Default)]
struct Discovery {
files: Vec<PathBuf>,
failures: Vec<FileReport>,
}
impl Discovery {
fn fail(&mut self, path: &Path, error: String) {
self.failures.push(FileReport { path: path.to_string_lossy().into_owned(), error: Some(error), ..Default::default() });
}
/// False means the file budget is exhausted: the remaining scan is incomplete.
fn add_file(&mut self, path: PathBuf) -> bool {
if self.files.len() >= MAX_FILES {
self.fail(&path, format!("file limit of {MAX_FILES} reached; this and remaining inputs were not checked"));
return false;
}
self.files.push(path);
true
}
}
/// Files named on the command line are checked whatever their extension; directories contribute
/// their `.xlsx`/`.xlsm` files, recursively, in name order. Hidden files and Excel lock files
/// (`~$…`) are skipped.
fn collect_inputs(inputs: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
fn collect_inputs(inputs: &[String]) -> Discovery {
let mut out = Discovery::default();
for i in inputs {
let p = PathBuf::from(i);
if p.is_dir() {
walk(&p, 0, &mut out);
} else {
// A missing path is reported as a file that failed to open.
out.push(p);
}
if out.len() >= MAX_FILES {
out.truncate(MAX_FILES);
if !walk(&p, 0, &mut out) {
break;
}
} else if !out.add_file(p) {
break;
}
}
if out.files.is_empty()
&& out.failures.is_empty()
&& let Some(input) = inputs.first()
{
out.fail(Path::new(input), "no workbook files were found in the requested inputs".into());
}
out
}
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth > MAX_DEPTH || out.len() >= MAX_FILES {
return;
/// Returns false only when the global file budget stops traversal.
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
if depth > MAX_DEPTH {
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
return true;
}
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) => {
out.fail(dir, format!("cannot read directory: {e}"));
return true;
}
};
let mut entries = Vec::new();
for entry in rd {
match entry {
Ok(entry) => entries.push(entry.path()),
Err(e) => out.fail(dir, format!("cannot read directory entry: {e}")),
}
}
let Ok(rd) = std::fs::read_dir(dir) else { return };
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if name.starts_with('.') || name.starts_with("~$") {
continue;
}
if p.is_dir() {
walk(&p, depth + 1, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) {
out.push(p);
}
if out.len() >= MAX_FILES {
return;
let metadata = match p.metadata() {
Ok(metadata) => metadata,
Err(e) => {
out.fail(&p, format!("cannot inspect input: {e}"));
continue;
}
};
if metadata.is_dir() {
if !walk(&p, depth + 1, out) {
return false;
}
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
&& !out.add_file(p)
{
return false;
}
}
true
}
/// Checks one file's bytes. Never fails: problems are recorded in the report. A panic escaping
@@ -287,7 +333,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
return report;
}
};
report.warnings = warnings.len();
report.warnings = warnings;
let mut fresh = cached.clone();
let mut calc = Calc::new();
calc.recalc_all(&mut fresh);
@@ -1270,6 +1316,30 @@ mod tests {
use super::*;
#[test]
fn discovery_reports_a_directory_read_error() {
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
std::fs::write(&path, "not a directory").unwrap();
let mut discovery = Discovery::default();
assert!(walk(&path, 0, &mut discovery));
assert!(discovery.files.is_empty());
assert_eq!(discovery.failures.len(), 1);
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory"));
let _ = std::fs::remove_file(path);
}
#[test]
fn discovery_reports_omissions_only_after_the_file_limit() {
let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() };
assert!(discovery.add_file(PathBuf::from("last.xlsx")));
assert!(discovery.failures.is_empty());
assert!(!discovery.add_file(PathBuf::from("omitted.xlsx")));
assert_eq!(discovery.files.len(), MAX_FILES);
assert_eq!(discovery.failures[0].path, "omitted.xlsx");
assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
}
fn at(a1: &str) -> CellRef {
CellRef::parse(a1).unwrap()
}
@@ -1600,6 +1670,15 @@ mod tests {
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn json_lists_warning_messages() {
let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() };
let without = FileReport { path: "b.xlsx".into(), ..Default::default() };
let v = report_json(&[with, without], &Options::default());
assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"]));
assert_eq!(v["files"][1]["warnings"], json!([]));
}
#[test]
fn unopenable_files_are_reported() {
let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
+32
View File
@@ -174,3 +174,35 @@ fn check_exit_codes_and_json() {
assert_eq!(run(&["check", book_s, "--tolerance", "x"]).status.code(), Some(2));
assert_eq!(run(&["check", book_s, "--bogus"]).status.code(), Some(2));
}
#[test]
fn check_rejects_an_empty_scan() {
let dir = tmpdir("check-empty");
std::fs::write(dir.join("notes.txt"), "not a workbook").unwrap();
std::fs::write(dir.join(".hidden.xlsx"), "ignored").unwrap();
std::fs::write(dir.join("~$locked.xlsx"), "ignored").unwrap();
let out = run(&["check", dir.to_str().unwrap(), "--json"]);
assert_eq!(out.status.code(), Some(1));
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(report["files"][0]["error"].as_str().unwrap().contains("no workbook files"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
let dir = tmpdir("check-depth");
let book = dir.join("readable.xlsx");
ok(&["run", "--out", book.to_str().unwrap(), "--quiet"]);
let mut deep = dir.clone();
for _ in 0..34 {
deep.push("d");
}
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("skipped.xlsx"), "not reached").unwrap();
let out = run(&["check", dir.to_str().unwrap(), "--json"]);
assert_eq!(out.status.code(), Some(1));
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(report["totals"]["opened"], 1);
assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit"))));
let _ = std::fs::remove_dir_all(dir);
}