alix 0.4.0

A learning tool built for understanding, not just remembering.
Documentation
//! `alix doctor`: health checks for the config, progress store, decks folder,
//! and optional external CLIs — plus the per-deck lint (`check`) it shares
//! with a deck-file target.

use std::path::PathBuf;

use alix::{
    deck::Deck,
    trace::{SourceBase, Trace},
    workspace,
};
use anyhow::{Context, Result, bail};

use crate::DoctorArgs;

/// The canonical CLI name of a value-enum value (e.g. `Mode::LineByLine` →
/// `"line"`), for echoing a deck's declared settings.
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 {
        // A workspace directory: validate its declared icon, then skip the
        // deck-load (which would error on a directory).
        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()
                );
            }
            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
                    );
                }
                // Image paths are resolved but never checked at load time, so a
                // missing file is reported here (advisory: the deck still works,
                // the web server just 404s the image).
                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()
                            );
                        }
                    }
                }

                // Frozen decks: warn when a card's snapshot no longer matches the
                // live source (the file changed or is gone), so the learner can
                // update or drop that card.
                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
                    );
                }

                // Trace decks: validate each `% at:` locator resolves into the
                // live `% source:` — catches drift (a file that shrank or was
                // renamed) before a walk hits it, like the duplicate/image checks.
                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);
                        }
                    }
                }

                // Fact decks: a card may also cite its source with `% at:`; warn
                // when a citation doesn't resolve (a moved/shrunk file), so a
                // hand-written or generated citation is caught before review.
                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
                            );
                        }
                    }
                }

                // A `% requires:` to a source-less deck never gates this deck's exam
                // (`is_locked` sees through an exam-less prerequisite), so a sourced
                // deck listing one likely meant it to gate — flag the dead edge.
                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
                    );
                }
            }
        }
    }
    // Warnings (e.g. duplicate answers) are advisory and don't fail the check;
    // only a deck that won't parse is an error.
    if errors > 0 || warnings > 0 {
        eprintln!("{errors} error(s), {warnings} warning(s)");
    }
    if errors > 0 {
        bail!("{errors} error(s) found");
    }
    Ok(())
}

/// Runs the health checks and prints the report: `✓` ok, `!` warn (an optional
/// feature is limited), `✗` fail (the core loop is broken). Exits non-zero only
/// on a fail, so a missing optional binary never breaks a script.
pub(crate) fn doctor_cmd(args: DoctorArgs) -> Result<()> {
    use alix::doctor::{self, Status};
    // A deck-file target = lint exactly that deck (syntax, duplicate answers,
    // trace locators) — the old `deck check`, now one more thing doctor checks.
    if let Some(path) = &args.dir
        && path.is_file()
    {
        return check(vec![path.clone()]);
    }
    let (config_finding, config) = doctor::check_config(args.config.as_deref());
    let mut findings = vec![config_finding];
    // The same root/store resolution the launcher applies to `alix <dir>`.
    let (decks_dir, store_path) = match &args.dir {
        Some(path) => {
            let store = if workspace::is_workspace(path) {
                workspace::store_path(path)
            } else {
                path.join(workspace::STORE_FILE)
            };
            (path.clone(), Some(store))
        }
        None => (
            config.decks_dir().context("cannot determine ~/decks")?,
            None,
        ),
    };
    findings.push(doctor::check_store(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}");
        }
    }
    // The costed end-to-end probe is opt-in: one real (tiny) request to the
    // configured backend, or one per backend with --all-backends.
    if args.backends || args.all_backends {
        println!();
        alix::backend::health::check(&config.ask, args.all_backends)?;
    }
    if failed {
        bail!("doctor found problems (✗ above)");
    }
    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();
        // Warnings don't fail the check; the missing-icon path just adds one.
        assert!(check(vec![dir.path().to_path_buf()]).is_ok());
    }
}