1use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11use serde_json::Value as JsonValue;
12
13pub const AI_MODEL_CACHE_DIR_NAME: &str = "ai_models_cache";
14pub const AI_MODEL_CACHE_STAGING_DIR_NAME: &str = ".staging";
15pub const AI_MODEL_CACHE_PURGE_DIR_NAME: &str = ".purge";
16pub const AI_MODEL_CACHE_MANIFEST_FILE: &str = "manifest.json";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AiModelCacheManifestFile {
20 pub path: String,
21 pub sha256_hex: String,
22 pub size_bytes: u64,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct AiModelCacheManifest {
27 pub name: String,
28 pub source: String,
29 pub revision: String,
30 pub task: String,
31 pub engine: String,
32 pub dimensions: u32,
33 pub installed_at_unix_ms: u64,
34 pub total_size_bytes: u64,
35 pub files: Vec<AiModelCacheManifestFile>,
36}
37
38pub fn ai_model_cache_root(base: &Path) -> PathBuf {
39 base.join(AI_MODEL_CACHE_DIR_NAME)
40}
41
42pub fn ai_model_cache_staging_root(cache_root: &Path) -> PathBuf {
43 cache_root.join(AI_MODEL_CACHE_STAGING_DIR_NAME)
44}
45
46pub fn ai_model_cache_purge_root(cache_root: &Path) -> PathBuf {
47 cache_root.join(AI_MODEL_CACHE_PURGE_DIR_NAME)
48}
49
50pub fn ai_model_cache_staging_dir(cache_root: &Path, name: &str, unique: &str) -> PathBuf {
51 ai_model_cache_staging_root(cache_root).join(format!("{name}-{unique}"))
52}
53
54pub fn ai_model_cache_purge_dir(cache_root: &Path, name: &str, unique: &str) -> PathBuf {
55 ai_model_cache_purge_root(cache_root).join(format!("{name}-{unique}"))
56}
57
58pub fn ai_model_cache_manifest_path(model_dir: &Path) -> PathBuf {
59 model_dir.join(AI_MODEL_CACHE_MANIFEST_FILE)
60}
61
62pub fn ai_model_cache_manifest_temp_path(dir: &Path) -> PathBuf {
63 dir.join(format!("{AI_MODEL_CACHE_MANIFEST_FILE}.tmp"))
64}
65
66pub fn copy_ai_model_cache_artifact(source: &Path, destination: &Path) -> io::Result<u64> {
67 if let Some(parent) = destination.parent() {
68 fs::create_dir_all(parent)?;
69 }
70 fs::copy(source, destination)
71}
72
73pub fn write_ai_model_cache_manifest(dir: &Path, bytes: &[u8]) -> io::Result<()> {
74 let manifest_tmp = ai_model_cache_manifest_temp_path(dir);
75 fs::write(&manifest_tmp, bytes)?;
76 fs::rename(&manifest_tmp, ai_model_cache_manifest_path(dir))
77}
78
79pub fn promote_ai_model_cache_staging(
80 cache_root: &Path,
81 model_name: &str,
82 unique: &str,
83 staging_dir: &Path,
84 model_dir: &Path,
85) -> io::Result<()> {
86 let purge_root = ai_model_cache_purge_root(cache_root);
87 fs::create_dir_all(&purge_root)?;
88 let purge_dir = ai_model_cache_purge_dir(cache_root, model_name, unique);
89 if model_dir.exists() {
90 fs::rename(model_dir, &purge_dir)?;
91 }
92 if let Err(err) = fs::rename(staging_dir, model_dir) {
93 if purge_dir.exists() {
94 let _ = fs::rename(&purge_dir, model_dir);
95 }
96 let _ = fs::remove_dir_all(staging_dir);
97 return Err(err);
98 }
99 if purge_dir.exists() {
100 let _ = fs::remove_dir_all(&purge_dir);
101 }
102 Ok(())
103}
104
105pub fn drop_ai_model_cache_dir(
106 cache_root: &Path,
107 model_dir: &Path,
108 unique: &str,
109) -> io::Result<bool> {
110 if !model_dir.exists() {
111 return Ok(false);
112 }
113 let purge_root = ai_model_cache_purge_root(cache_root);
114 fs::create_dir_all(&purge_root)?;
115 let model_name = model_dir
116 .file_name()
117 .and_then(|s| s.to_str())
118 .unwrap_or("model");
119 let purge_dir = ai_model_cache_purge_dir(cache_root, model_name, unique);
120 fs::rename(model_dir, &purge_dir)?;
121 let _ = fs::remove_dir_all(&purge_dir);
122 Ok(true)
123}
124
125pub fn encode_ai_model_cache_manifest_json(manifest: &AiModelCacheManifest) -> io::Result<Vec<u8>> {
126 serde_json::to_vec(&manifest_to_json(manifest)).map_err(|err| {
127 io::Error::new(
128 io::ErrorKind::InvalidData,
129 format!("encode AI model cache manifest: {err}"),
130 )
131 })
132}
133
134pub fn decode_ai_model_cache_manifest_json(bytes: &[u8]) -> io::Result<AiModelCacheManifest> {
135 let value: JsonValue = serde_json::from_slice(bytes).map_err(|err| {
136 io::Error::new(
137 io::ErrorKind::InvalidData,
138 format!("AI model cache manifest is not valid JSON: {err}"),
139 )
140 })?;
141 manifest_from_json(&value)
142}
143
144fn manifest_to_json(manifest: &AiModelCacheManifest) -> JsonValue {
145 let mut object = serde_json::Map::new();
146 object.insert("name".to_string(), JsonValue::String(manifest.name.clone()));
147 object.insert(
148 "source".to_string(),
149 JsonValue::String(manifest.source.clone()),
150 );
151 object.insert(
152 "revision".to_string(),
153 JsonValue::String(manifest.revision.clone()),
154 );
155 object.insert("task".to_string(), JsonValue::String(manifest.task.clone()));
156 object.insert(
157 "engine".to_string(),
158 JsonValue::String(manifest.engine.clone()),
159 );
160 object.insert(
161 "dimensions".to_string(),
162 JsonValue::Number(manifest.dimensions.into()),
163 );
164 object.insert(
165 "installed_at_unix_ms".to_string(),
166 JsonValue::Number(manifest.installed_at_unix_ms.into()),
167 );
168 object.insert(
169 "total_size_bytes".to_string(),
170 JsonValue::Number(manifest.total_size_bytes.into()),
171 );
172 let files = manifest
173 .files
174 .iter()
175 .map(|file| {
176 let mut object = serde_json::Map::new();
177 object.insert("path".to_string(), JsonValue::String(file.path.clone()));
178 object.insert(
179 "sha256".to_string(),
180 JsonValue::String(file.sha256_hex.clone()),
181 );
182 object.insert(
183 "size_bytes".to_string(),
184 JsonValue::Number(file.size_bytes.into()),
185 );
186 JsonValue::Object(object)
187 })
188 .collect();
189 object.insert("files".to_string(), JsonValue::Array(files));
190 JsonValue::Object(object)
191}
192
193fn manifest_from_json(value: &JsonValue) -> io::Result<AiModelCacheManifest> {
194 let object = value
195 .as_object()
196 .ok_or_else(|| invalid("manifest is not an object"))?;
197 let name = required_str(object, "name")?;
198 let source = required_str(object, "source")?;
199 let revision = required_str(object, "revision")?;
200 let task = required_str(object, "task")?;
201 let engine = required_str(object, "engine")?;
202 let dimensions = required_u64(object, "dimensions")? as u32;
203 let installed_at_unix_ms = required_u64(object, "installed_at_unix_ms")?;
204 let total_size_bytes = required_u64(object, "total_size_bytes")?;
205 let files_raw = object
206 .get("files")
207 .and_then(JsonValue::as_array)
208 .ok_or_else(|| invalid("manifest field 'files' must be an array"))?;
209 let mut files = Vec::with_capacity(files_raw.len());
210 for (idx, raw) in files_raw.iter().enumerate() {
211 let entry = raw
212 .as_object()
213 .ok_or_else(|| invalid(format!("manifest files[{idx}] is not an object")))?;
214 files.push(AiModelCacheManifestFile {
215 path: required_str_at(entry, "path", idx)?,
216 sha256_hex: required_str_at(entry, "sha256", idx)?,
217 size_bytes: required_u64_at(entry, "size_bytes", idx)?,
218 });
219 }
220 Ok(AiModelCacheManifest {
221 name,
222 source,
223 revision,
224 task,
225 engine,
226 dimensions,
227 installed_at_unix_ms,
228 total_size_bytes,
229 files,
230 })
231}
232
233fn required_str(object: &serde_json::Map<String, JsonValue>, key: &str) -> io::Result<String> {
234 object
235 .get(key)
236 .and_then(JsonValue::as_str)
237 .map(str::to_string)
238 .ok_or_else(|| invalid(format!("manifest field '{key}' missing or not a string")))
239}
240
241fn required_u64(object: &serde_json::Map<String, JsonValue>, key: &str) -> io::Result<u64> {
242 object
243 .get(key)
244 .and_then(JsonValue::as_u64)
245 .ok_or_else(|| invalid(format!("manifest field '{key}' missing or not a number")))
246}
247
248fn required_str_at(
249 object: &serde_json::Map<String, JsonValue>,
250 key: &str,
251 index: usize,
252) -> io::Result<String> {
253 object
254 .get(key)
255 .and_then(JsonValue::as_str)
256 .map(str::to_string)
257 .ok_or_else(|| invalid(format!("manifest files[{index}].{key} missing")))
258}
259
260fn required_u64_at(
261 object: &serde_json::Map<String, JsonValue>,
262 key: &str,
263 index: usize,
264) -> io::Result<u64> {
265 object
266 .get(key)
267 .and_then(JsonValue::as_u64)
268 .ok_or_else(|| invalid(format!("manifest files[{index}].{key} missing")))
269}
270
271fn invalid(message: impl Into<String>) -> io::Error {
272 io::Error::new(io::ErrorKind::InvalidData, message.into())
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn ai_model_cache_manifest_round_trips() {
281 let manifest = AiModelCacheManifest {
282 name: "mini".to_string(),
283 source: "fixture".to_string(),
284 revision: "abc".to_string(),
285 task: "embedding".to_string(),
286 engine: "candle".to_string(),
287 dimensions: 384,
288 installed_at_unix_ms: 42,
289 total_size_bytes: 11,
290 files: vec![AiModelCacheManifestFile {
291 path: "model.bin".to_string(),
292 sha256_hex: "00ff".to_string(),
293 size_bytes: 11,
294 }],
295 };
296
297 let bytes = encode_ai_model_cache_manifest_json(&manifest).expect("encode");
298 let decoded = decode_ai_model_cache_manifest_json(&bytes).expect("decode");
299 assert_eq!(decoded, manifest);
300 assert!(String::from_utf8(bytes)
301 .unwrap()
302 .contains("\"sha256\":\"00ff\""));
303 }
304
305 #[test]
306 fn ai_model_cache_paths_are_canonical() {
307 let root = Path::new("/tmp/reddb");
308 assert_eq!(
309 ai_model_cache_root(root),
310 Path::new("/tmp/reddb").join("ai_models_cache")
311 );
312 assert_eq!(
313 ai_model_cache_staging_dir(root, "m", "u"),
314 Path::new("/tmp/reddb").join(".staging").join("m-u")
315 );
316 assert_eq!(
317 ai_model_cache_purge_dir(root, "m", "u"),
318 Path::new("/tmp/reddb").join(".purge").join("m-u")
319 );
320 assert_eq!(
321 ai_model_cache_manifest_temp_path(root),
322 Path::new("/tmp/reddb").join("manifest.json.tmp")
323 );
324 }
325}