use std::collections::BTreeSet;
use std::path::Path;
use csp_solver::sudoku::Difficulty;
fn wire_name(d: Difficulty) -> &'static str {
match d {
Difficulty::Easy => "EASY",
Difficulty::Medium => "MEDIUM",
Difficulty::Hard => "HARD",
}
}
#[derive(Clone, Copy, Debug)]
enum Casing {
Verbatim,
PascalCase,
}
impl Casing {
fn expected(self, canonical_variant: &str) -> String {
match self {
Casing::Verbatim => canonical_variant.to_string(),
Casing::PascalCase => {
let mut chars = canonical_variant.chars();
match chars.next() {
Some(first) => {
first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
}
None => String::new(),
}
}
}
}
}
const SIBLING_DEFINITIONS: &[(&str, &str, Casing)] = &[
(
"py/sudoku.rs::SudokuDifficulty (PyO3)",
"src/py/sudoku.rs",
Casing::Verbatim,
),
(
"wasm/src/sudoku.rs::SudokuDifficulty (wasm, prototype 6 — idiomatic casing)",
"wasm/src/sudoku.rs",
Casing::PascalCase,
),
(
"games/sudoku/types.ts Difficulty (frontend TS)",
"../web/frontend/src/games/sudoku/types.ts",
Casing::Verbatim,
),
];
const CANONICAL_FILE: &str = "src/puzzles/sudoku/generate.rs";
const SCAN_ROOTS: &[&str] = &["src", "wasm/src", "../web/frontend/src"];
const EXCLUDE_DIRS: &[&str] = &[
"node_modules",
"dist",
"target",
"pkg",
".git",
"__pycache__",
];
#[test]
fn difficulty_variants_agree_across_all_known_definitions() {
let canonical: &[&str] = &[
wire_name(Difficulty::Easy),
wire_name(Difficulty::Medium),
wire_name(Difficulty::Hard),
];
assert_eq!(
canonical,
&["EASY", "MEDIUM", "HARD"],
"canonical Difficulty wire names changed shape — update this test's \
expectation deliberately, don't let it drift silently"
);
let mut problems: Vec<String> = Vec::new();
for (label, rel_path, casing) in SIBLING_DEFINITIONS {
let path = Path::new(rel_path);
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
problems.push(format!("{label} ({rel_path}): could not read file — {e}"));
continue;
}
};
for variant in canonical {
let expected = casing.expected(variant);
if !text.contains(&expected) {
problems.push(format!(
"{label} ({rel_path}): missing canonical variant {variant:?} \
(expected {casing:?} spelling {expected:?} in source text)"
));
}
}
}
assert!(
problems.is_empty(),
"Difficulty parity broken across {} known sibling definitions — canonical \
source is csp_solver::sudoku::Difficulty ({CANONICAL_FILE}).\n{}",
SIBLING_DEFINITIONS.len(),
problems.join("\n")
);
}
fn looks_like_difficulty_definition(line: &str, ext: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.starts_with("import") || trimmed.starts_with("from ") || trimmed.starts_with("use ")
{
return false;
}
if !trimmed.contains("Difficulty") {
return false;
}
match ext {
"rs" => trimmed.contains("enum "),
"py" => trimmed.contains("class "),
"ts" | "vue" => {
(trimmed.contains("type ") && trimmed.contains('=')) || trimmed.contains("enum ")
}
_ => false,
}
}
fn file_ext(path: &str) -> &str {
path.rsplit('.').next().unwrap_or("")
}
fn scan_root_for_difficulty_definitions(root: &str, out: &mut BTreeSet<String>) {
let dir = match std::fs::read_dir(root) {
Ok(d) => d,
Err(_) => return, };
for entry in dir.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
let child_path = format!("{root}/{name}");
let file_type = match entry.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if file_type.is_dir() {
if EXCLUDE_DIRS.contains(&name.as_ref()) {
continue;
}
scan_root_for_difficulty_definitions(&child_path, out);
continue;
}
if !file_type.is_file() {
continue;
}
let ext = file_ext(&name);
if !matches!(ext, "rs" | "py" | "ts" | "vue") {
continue;
}
let text = match std::fs::read_to_string(&child_path) {
Ok(t) => t,
Err(_) => continue, };
if text
.lines()
.any(|line| looks_like_difficulty_definition(line, ext))
{
out.insert(child_path);
}
}
}
#[test]
fn no_unscanned_difficulty_definitions_exist() {
let mut discovered: BTreeSet<String> = BTreeSet::new();
for root in SCAN_ROOTS {
scan_root_for_difficulty_definitions(root, &mut discovered);
}
discovered.remove(CANONICAL_FILE);
let allowlisted: BTreeSet<String> = SIBLING_DEFINITIONS
.iter()
.map(|(_, path, _)| (*path).to_string())
.collect();
let unscanned: Vec<&String> = discovered.difference(&allowlisted).collect();
let stale: Vec<&String> = allowlisted.difference(&discovered).collect();
assert!(
unscanned.is_empty(),
"found Difficulty-shaped definition(s) not in SIBLING_DEFINITIONS — this is \
exactly the D10c/T8 failure mode (a new mirror silently invisible to the \
parity check): {unscanned:?}\n\
Add each to SIBLING_DEFINITIONS in this file with an explicit Casing, or \
delete it if it's dead code."
);
assert!(
stale.is_empty(),
"SIBLING_DEFINITIONS lists path(s) that no longer look like a Difficulty \
definition (file deleted, or the enum/class/type declaration was removed) — \
update this file's SIBLING_DEFINITIONS to match reality: {stale:?}"
);
}