cherenkov/model/index/
disk.rs1use super::{Catalog, ModelIndex};
4use anyhow::{Context, Result, ensure};
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7
8#[derive(Clone, Copy, Default, Serialize, Deserialize, clap::ValueEnum)]
10#[serde(rename_all = "snake_case")]
11pub enum DiskLayout {
12 #[default]
14 Directory,
15 HfCache,
17}
18
19#[derive(Clone, Serialize, Deserialize)]
21pub struct DiskStore {
22 pub id: String,
24 pub name: String,
26 pub path: PathBuf,
28 pub layout: DiskLayout,
30 pub enabled: bool,
32}
33
34impl ModelIndex {
35 pub fn add_disk_store(&self, name: &str, path: &Path, layout: DiskLayout) -> Result<DiskStore> {
37 ensure!(
38 crate::storage::safe_component(name),
39 "invalid disk store name"
40 );
41
42 let path = path.canonicalize().context("opening disk store")?;
43
44 ensure!(path.is_dir(), "disk store root must be a directory");
45
46 let mut catalog = self.lock()?;
47
48 ensure!(
49 !catalog.value.stores.contains_key(name),
50 "store {name:?} is already registered"
51 );
52
53 let store = DiskStore {
54 id: super::id(),
55 name: name.to_owned(),
56 path,
57 layout,
58 enabled: true,
59 };
60
61 catalog.value.stores.insert(name.to_owned(), store.clone());
62 catalog.save()?;
63
64 Ok(store)
65 }
66
67 pub fn disk_stores(&self) -> Result<Vec<DiskStore>> {
69 if !self.paths.data.join("index.json").exists() {
70 return Ok(Vec::new());
71 }
72
73 Ok(self.lock()?.value.stores.values().cloned().collect())
74 }
75
76 pub fn set_disk_store_enabled(&self, name: &str, enabled: bool) -> Result<()> {
78 let mut catalog = self.lock()?;
79 catalog
80 .value
81 .stores
82 .get_mut(name)
83 .context("store is not registered")?
84 .enabled = enabled;
85
86 catalog.save()
87 }
88
89 pub fn remove_disk_store(&self, name: &str) -> Result<()> {
91 let mut catalog = self.lock()?;
92
93 ensure!(
94 catalog.value.stores.remove(name).is_some(),
95 "store is not registered"
96 );
97
98 catalog.save()
99 }
100}
101
102pub(super) fn registered<'a>(catalog: &'a Catalog, name: &str) -> Result<&'a DiskStore> {
104 let store = catalog
105 .stores
106 .get(name)
107 .context("disk store is not registered")?;
108
109 ensure!(store.enabled, "disk store {name:?} is disabled");
110
111 Ok(store)
112}
113
114impl DiskStore {
115 pub(super) fn locate(&self, repo: &str, revision: Option<&str>) -> Result<PathBuf> {
117 let candidate = match self.layout {
118 DiskLayout::Directory => self.path.join(repo),
119 DiskLayout::HfCache => self.snapshot(repo, revision)?,
120 };
121 let path = candidate
122 .canonicalize()
123 .context("model is absent from the disk store")?;
124
125 ensure!(
126 path.starts_with(&self.path),
127 "model path escapes the disk store"
128 );
129
130 Ok(path)
131 }
132
133 fn snapshot(&self, repo: &str, revision: Option<&str>) -> Result<PathBuf> {
135 let root = self
136 .path
137 .join(format!("models--{}", repo.replace('/', "--")));
138 let snapshots = root.join("snapshots");
139 let requested = revision.unwrap_or("main");
140
141 ensure!(
142 !Path::new(requested).is_absolute()
143 && requested.split('/').all(crate::storage::safe_component),
144 "invalid cache revision"
145 );
146
147 let cached_ref = match std::fs::read_to_string(root.join("refs").join(requested)) {
148 Ok(commit) => Some(commit),
149 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
150 Err(error) => return Err(error).context("reading cached revision"),
151 };
152
153 if let Some(commit) = cached_ref {
154 let commit = commit.trim();
155
156 ensure!(
157 super::reference::is_revision(commit),
158 "invalid cached commit"
159 );
160
161 return Ok(snapshots.join(commit));
162 }
163
164 ensure!(
165 revision.is_none_or(super::reference::is_revision),
166 "cached ref not found; commit prefixes require at least eight hexadecimal characters"
167 );
168
169 let mut matches = std::fs::read_dir(&snapshots)?
170 .collect::<std::io::Result<Vec<_>>>()?
171 .into_iter()
172 .filter(|entry| {
173 entry.file_name().to_str().is_some_and(|name| {
174 super::reference::is_revision(name)
175 && revision.is_none_or(|rev| name.starts_with(rev))
176 })
177 });
178 let first = matches.next().context("cached revision not found")?;
179
180 ensure!(
181 matches.next().is_none(),
182 "cached revision is ambiguous; append @commit"
183 );
184
185 Ok(first.path())
186 }
187
188 pub(super) fn selector(&self, path: &Path) -> Option<super::selector::Selector> {
190 let relative = path.strip_prefix(&self.path).ok()?;
191 let parts: Vec<_> = relative
192 .iter()
193 .map(|part| part.to_str())
194 .collect::<Option<_>>()?;
195 let (repo, revision) = match (self.layout, parts.as_slice()) {
196 (DiskLayout::Directory, [owner, repo]) => (format!("{owner}/{repo}"), None),
197 (DiskLayout::HfCache, [repo, "snapshots", commit]) => {
198 let parts: Vec<_> = repo.split("--").collect();
199 let ["models", owner, repo] = parts.as_slice() else {
200 return None;
201 };
202
203 (format!("{owner}/{repo}"), Some((*commit).to_owned()))
204 }
205 _ => return None,
206 };
207
208 Some(super::selector::Selector::Disk {
209 store: self.name.clone(),
210 repo,
211 revision,
212 })
213 }
214}