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}