use std::path::PathBuf;
use alix::{
deck::Deck,
trace::{SourceBase, Trace},
workspace,
};
use anyhow::{Context, Result, bail};
use crate::DoctorArgs;
fn val_name<T: clap::ValueEnum>(value: T) -> String {
value
.to_possible_value()
.map(|p| p.get_name().to_string())
.unwrap_or_default()
}
fn check(decks: Vec<PathBuf>) -> Result<()> {
let mut errors = 0usize;
let mut warnings = 0usize;
for path in &decks {
if path.is_dir() && alix::workspace::is_workspace(path) {
if let Some(rel) = alix::workspace::manifest_icon(path)
&& !path.join(&rel).is_file()
{
warnings += 1;
eprintln!(
"warning: {}: `icon = \"{rel}\"` points at a missing file",
path.display()
);
}
for complaint in alix::config::local_review_lint(path) {
warnings += 1;
eprintln!("warning: {}: {complaint}", path.display());
}
continue;
}
match Deck::load(path) {
Err(e) => {
errors += 1;
eprintln!("error: {e}");
}
Ok(deck) => {
println!("{}: {} cards", deck.subject, deck.cards.len());
let s = &deck.settings;
let declared: Vec<String> = [
s.reveal.map(|r| format!("reveal: {}", val_name(r))),
s.order.map(|o| format!("order: {}", val_name(o))),
s.exam_strictness
.map(|v| format!("strictness: {}", val_name(v))),
]
.into_iter()
.flatten()
.collect();
if !declared.is_empty() {
println!(" settings: {}", declared.join(", "));
}
if !deck.requires.is_empty() {
println!(" requires: {}", deck.requires.join(", "));
}
if !deck.sources.is_empty() {
println!(" sources: {}", deck.sources.join(", "));
}
if let Some(desc) = &deck.trace {
println!(" trace: {desc}");
}
for (a, b) in deck.duplicates() {
warnings += 1;
eprintln!(
"warning: {}: cards at lines {} and {} have identical answers \
and share their learning progress",
deck.subject, a.line, b.line
);
}
for card in &deck.cards {
for image in [&card.image, &card.image_back].into_iter().flatten() {
if !image.exists() {
warnings += 1;
eprintln!(
"warning: {}: card at line {} references a missing image: {}",
deck.subject,
card.line,
image.display()
);
}
}
}
for drift in alix::trace::drifted_cards(&deck) {
warnings += 1;
let what = if drift.gone {
"source file is gone"
} else {
"no longer found in the source"
};
eprintln!(
"warning: {}: card at line {} — frozen excerpt {} ({})",
deck.subject, drift.line, what, drift.at
);
}
if deck.is_trace() && !deck.cards.is_empty() {
match Trace::from_deck(&deck) {
Ok(trace) => {
for issue in trace.lint_locators() {
warnings += 1;
let line = deck.cards.get(issue.checkpoint).map_or(0, |c| c.line);
eprintln!(
"warning: {}: checkpoint at line {}: {}",
deck.subject, line, issue.message
);
}
}
Err(e) => {
warnings += 1;
eprintln!("warning: {}: {e:#}", deck.subject);
}
}
}
if !deck.is_trace() {
let base = SourceBase::for_deck(&deck);
for card in &deck.cards {
if let Some(at) = card.at.as_deref()
&& let Err(e) = base.excerpt(at)
{
warnings += 1;
eprintln!(
"warning: {}: card at line {}: `% at: {at}` — {e:#}",
deck.subject, card.line
);
}
}
}
for prereq in alix::deck::nongating_prerequisites(&deck) {
warnings += 1;
eprintln!(
"warning: {}: requires source-less `{prereq}` — this edge \
doesn't gate its exam; add a `% source:` to `{prereq}` to \
make it a real prerequisite",
deck.subject
);
}
}
}
}
if errors > 0 || warnings > 0 {
eprintln!("{errors} error(s), {warnings} warning(s)");
}
if errors > 0 {
bail!("{errors} error(s) found");
}
Ok(())
}
pub(crate) fn doctor_cmd(args: DoctorArgs) -> Result<()> {
use alix::doctor::{self, Status};
if let Some(path) = &args.dir {
if path.is_file() {
return check(vec![path.clone()]);
}
if alix::workspace::is_workspace(path) {
check(vec![path.clone()])?;
}
}
let (config_finding, config) = doctor::check_config(args.config.as_deref());
let mut findings = vec![config_finding];
let (decks_dir, store_path) = match &args.dir {
Some(path) => (path.clone(), workspace::root_store_path(path)),
None => {
let dir = config.decks_dir().context("cannot determine ~/decks")?;
let store = workspace::root_store_path(&dir);
(dir, store)
}
};
findings.push(doctor::check_store(Some(store_path)));
findings.push(doctor::check_decks(&decks_dir));
findings.push(doctor::check_binary(
"backend",
&config.ask.command,
"the AI features (tutor, exam, generate)",
"install it and log in — or switch `[ask] backend` in the config",
));
findings.push(doctor::check_binary(
"share",
"wormhole",
"sharing (`alix share`/`receive`)",
"install magic-wormhole (e.g. `pipx install magic-wormhole`, or your package manager)",
));
let mut failed = false;
for f in &findings {
let glyph = match f.status {
Status::Ok => "✓",
Status::Warn => "!",
Status::Fail => {
failed = true;
"✗"
}
};
println!("{glyph} {:<8} {}", f.name, f.detail);
if let Some(remedy) = &f.remedy {
println!(" ↳ {remedy}");
}
}
if args.backends || args.all_backends {
println!();
alix::backend::health::check(&config.ask, args.all_backends)?;
}
if args.grading {
println!();
grading_spot_check(&config)?;
}
if failed {
bail!("doctor found problems (✗ above)");
}
Ok(())
}
fn grading_spot_check(config: &alix::config::Config) -> Result<()> {
use alix::calibrate::{self, ProbeKind};
println!(
"grading spot-check: {} probes, 3 real calls to the configured backend…",
calibrate::PROBES.len()
);
let results = calibrate::run(&config.exam, &config.ask)?;
let (mut safety_bad, mut fairness_bad) = (0, 0);
for r in &results {
let expect = match r.kind {
ProbeKind::Safety => "must not pass",
ProbeKind::Fairness => "should pass",
};
let glyph = if r.ok {
"✓"
} else {
match r.kind {
ProbeKind::Safety => {
safety_bad += 1;
"✗"
}
ProbeKind::Fairness => {
fairness_bad += 1;
"!"
}
}
};
println!("{glyph} {:<20} {expect}: got {:?}", r.name, r.verdict);
}
if safety_bad > 0 {
println!(
"✗ the model passed {safety_bad} answer(s) that must not pass: exam grades from \
this model may be too lenient; consider a stronger `[ask]` model for grading"
);
} else if fairness_bad > 0 {
println!(
"! safe, but stricter than intended: {fairness_bad} should-pass probe(s) did not \
pass. Passing an exam stays honest; it may just be harder than calibrated."
);
} else {
println!("✓ grading looks trustworthy (a spot check, not a guarantee)");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_warns_on_a_missing_workspace_icon() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("alix.toml"), "icon = \"assets/gone.svg\"\n").unwrap();
std::fs::write(dir.path().join("a.txt"), "# a\n\t1\n").unwrap();
assert!(check(vec![dir.path().to_path_buf()]).is_ok());
}
#[test]
fn check_warns_on_a_malformed_deadline() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("alix.toml"), "").unwrap();
std::fs::write(
dir.path().join("alix.local.toml"),
"[review]\ndeadline = \"soonish\"\n",
)
.unwrap();
std::fs::write(dir.path().join("a.txt"), "# a\n\t1\n").unwrap();
assert!(check(vec![dir.path().to_path_buf()]).is_ok());
}
}