Skip to main content

orbok_models/
readiness.rs

1//! Local model file readiness check (RFC-043 §7).
2//!
3//! orbok checks local model files before starting any download. This
4//! avoids re-downloading files that are already present and valid,
5//! and prevents treating partially downloaded files as usable.
6//!
7//! The readiness check is intentionally lightweight (P0): existence,
8//! regular-file check, and non-empty. Checksum validation (P1) is
9//! layered on top when a manifest is available.
10
11use std::path::Path;
12
13// ── Per-file status ───────────────────────────────────────────────────
14
15/// Status of one required model file (RFC-043 §7.2).
16///
17/// User-facing copy must not expose these names directly:
18/// - `Ready`       → "Ready"
19/// - `Missing`     → "Needed"
20/// - `Partial`     → "Needs to finish"
21/// - `Invalid`     → "Needs to be replaced"
22/// - `CannotCheck` → "Could not check"
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum LocalFileStatus {
25    /// File exists, is non-empty, and passes available validation.
26    Ready,
27    /// File does not exist.
28    Missing,
29    /// A `.part` temporary file exists but the final file is absent or empty.
30    Partial,
31    /// File exists but failed validation (empty, wrong type, corrupted).
32    Invalid,
33    /// File access failed unexpectedly (permissions etc.).
34    CannotCheck,
35}
36
37impl LocalFileStatus {
38    /// User-facing copy (RFC-043 §7.2, avoiding technical terms).
39    pub fn user_label(&self) -> &'static str {
40        match self {
41            LocalFileStatus::Ready => "Ready",
42            LocalFileStatus::Missing => "Needed",
43            LocalFileStatus::Partial => "Needs to finish",
44            LocalFileStatus::Invalid => "Needs to be replaced",
45            LocalFileStatus::CannotCheck => "Could not check",
46        }
47    }
48
49    /// Whether this file needs any download or repair work.
50    pub fn needs_work(&self) -> bool {
51        !matches!(self, LocalFileStatus::Ready)
52    }
53}
54
55// ── Per-file readiness entry ──────────────────────────────────────────
56
57/// Readiness information for one required model file (RFC-043 §10.2).
58#[derive(Debug, Clone)]
59pub struct FileReadiness {
60    /// Logical name (e.g. "tokenizer", "model").
61    pub logical_name: &'static str,
62    /// Relative path within the model directory.
63    pub relative_path: &'static str,
64    /// Current status of the local file.
65    pub status: LocalFileStatus,
66}
67
68// ── Model-level readiness ─────────────────────────────────────────────
69
70/// Overall model readiness (RFC-043 §7.3).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum ModelReadiness {
73    /// Every required file is present and valid.
74    Ready,
75    /// At least one file is missing.
76    NeedsDownload,
77    /// At least one file is partial or invalid (but none missing).
78    NeedsRepair,
79    /// File access failed unexpectedly.
80    CannotCheck,
81}
82
83// ── Readiness report ──────────────────────────────────────────────────
84
85/// Full readiness report for a model directory (RFC-043 §7.2–7.3).
86#[derive(Debug, Clone)]
87pub struct ModelReadinessReport {
88    pub overall: ModelReadiness,
89    pub files: Vec<FileReadiness>,
90}
91
92impl ModelReadinessReport {
93    /// Files that require download or repair.
94    pub fn files_needing_work(&self) -> Vec<&FileReadiness> {
95        self.files
96            .iter()
97            .filter(|f| f.status.needs_work())
98            .collect()
99    }
100
101    /// How many files are already ready.
102    pub fn ready_count(&self) -> usize {
103        self.files
104            .iter()
105            .filter(|f| f.status == LocalFileStatus::Ready)
106            .count()
107    }
108
109    pub fn total_count(&self) -> usize {
110        self.files.len()
111    }
112}
113
114// ── Required files ────────────────────────────────────────────────────
115
116/// Required files for the current default model (RFC-043 §6).
117const REQUIRED_FILES: &[(&str, &str)] = &[
118    ("tokenizer", "tokenizer.json"),
119    ("model", "onnx/model.onnx"),
120];
121
122// ── Readiness check ───────────────────────────────────────────────────
123
124/// Check local model files and return a readiness report (RFC-043 §7).
125///
126/// Called: at startup, before the wizard, before download, after
127/// download, after the user chooses an existing folder, and on retry.
128///
129/// This is a pure filesystem check — no network access.
130pub fn check_model_readiness(model_dir: &Path) -> ModelReadinessReport {
131    let mut files = Vec::new();
132
133    for (logical_name, relative_path) in REQUIRED_FILES {
134        let full_path = model_dir.join(relative_path);
135        let part_path = model_dir.join(format!("{relative_path}.part"));
136        let status = check_single_file(&full_path, &part_path);
137        files.push(FileReadiness {
138            logical_name,
139            relative_path,
140            status,
141        });
142    }
143
144    let overall = derive_overall_readiness(&files);
145    ModelReadinessReport { overall, files }
146}
147
148fn check_single_file(path: &Path, part_path: &Path) -> LocalFileStatus {
149    // Check the final path first.
150    match std::fs::metadata(path) {
151        Ok(meta) => {
152            if !meta.is_file() {
153                return LocalFileStatus::Invalid;
154            }
155            if meta.len() == 0 {
156                return LocalFileStatus::Invalid;
157            }
158            // P0 validation passes: file exists, is a regular file, is non-empty.
159            LocalFileStatus::Ready
160        }
161        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
162            // Final file missing — check for a .part file.
163            if part_path.exists() {
164                LocalFileStatus::Partial
165            } else {
166                LocalFileStatus::Missing
167            }
168        }
169        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => LocalFileStatus::CannotCheck,
170        Err(_) => LocalFileStatus::CannotCheck,
171    }
172}
173
174fn derive_overall_readiness(files: &[FileReadiness]) -> ModelReadiness {
175    if files
176        .iter()
177        .any(|f| f.status == LocalFileStatus::CannotCheck)
178    {
179        return ModelReadiness::CannotCheck;
180    }
181    if files.iter().all(|f| f.status == LocalFileStatus::Ready) {
182        return ModelReadiness::Ready;
183    }
184    if files.iter().any(|f| f.status == LocalFileStatus::Missing) {
185        return ModelReadiness::NeedsDownload;
186    }
187    // Partial or Invalid but no Missing.
188    ModelReadiness::NeedsRepair
189}