1use crate::units::BYTES_PER_GB;
4use anyhow::{Context, Result, ensure};
5use serde::Serialize;
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8
9pub const DEFAULT_REPO: &str = "Sawfwair/Qwen3.8-Flash-Next-MLX-4bit";
11pub const DEFAULT_REVISION: &str = "6cc9bbc0fae9ce26b7670b3ed1e26d557c154506";
13
14pub fn default_model_reference() -> String {
16 format!("hf://{DEFAULT_REPO}@{DEFAULT_REVISION}")
17}
18
19#[derive(Debug, Clone, Serialize)]
21pub struct Paths {
22 pub data: PathBuf,
24 pub scratch: PathBuf,
26 pub config: PathBuf,
28}
29
30impl Paths {
31 pub fn new(root: Option<&Path>) -> Result<Self> {
36 if let Some(root) = root {
37 let root = crate::config::absolute(root)?;
38
39 return Ok(Self {
40 scratch: root.join("scratch"),
41 config: root.join("cherenkov.toml"),
42 data: root,
43 });
44 }
45
46 let home = dirs::home_dir();
48 let base =
49 |variable, fallback| xdg_dir(std::env::var_os(variable), home.as_deref(), fallback);
50
51 Ok(Self {
52 data: base("XDG_DATA_HOME", ".local/share")?.join("cherenkov"),
53 scratch: base("XDG_CACHE_HOME", ".cache")?.join("cherenkov"),
54 config: base("XDG_CONFIG_HOME", ".config")?.join("cherenkov/cherenkov.toml"),
55 })
56 }
57
58 pub fn downloads(&self) -> PathBuf {
60 self.data.join("downloads")
61 }
62
63 pub fn model(&self, repo: &str, commit: &str) -> Result<PathBuf> {
66 validate_identity(repo, commit)?;
67
68 Ok(self.data.join("models").join(repo).join(commit))
69 }
70
71 pub fn default_model(&self) -> PathBuf {
73 self.model(DEFAULT_REPO, DEFAULT_REVISION)
74 .expect("built-in model identity")
75 }
76
77 pub(crate) fn snapshot(&self, repo: &str, commit: &str) -> Result<PathBuf> {
79 validate_identity(repo, commit)?;
80
81 Ok(self
82 .downloads()
83 .join(format!("models--{}", repo.replace('/', "--")))
84 .join("snapshots")
85 .join(commit))
86 }
87}
88
89fn xdg_dir(value: Option<OsString>, home: Option<&Path>, fallback: &str) -> Result<PathBuf> {
90 if let Some(path) = value.map(PathBuf::from).filter(|path| path.is_absolute()) {
92 return Ok(path);
93 }
94
95 let home =
96 home.context("home directory unavailable; supply --root or absolute XDG directories")?;
97
98 Ok(home.join(fallback))
99}
100
101fn validate_identity(repo: &str, commit: &str) -> Result<()> {
102 let parts: Vec<_> = repo.split('/').collect();
103
104 ensure!(
105 parts.len() == 2 && parts.iter().all(|p| safe_component(p)),
106 "model ID must be owner/name"
107 );
108 ensure!(
109 commit.len() == 40 && commit.bytes().all(|b| b.is_ascii_hexdigit()),
110 "model revision must resolve to a full commit hash"
111 );
112
113 Ok(())
114}
115
116pub(crate) fn safe_component(value: &str) -> bool {
117 !value.is_empty()
118 && value != "."
119 && value != ".."
120 && value
121 .bytes()
122 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
123}
124
125pub(crate) fn create_private_dir(path: &Path) -> Result<()> {
126 use std::os::unix::fs::DirBuilderExt;
127
128 std::fs::DirBuilder::new()
129 .recursive(true)
130 .mode(0o700)
131 .create(path)
132 .with_context(|| format!("creating {}", path.display()))
133}
134
135pub(crate) fn require_space(path: &Path, additional_bytes: u64) -> Result<()> {
137 let path_c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
138 let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
139
140 if unsafe { libc::statfs(path_c.as_ptr(), &mut stat) } != 0 {
141 return Err(std::io::Error::last_os_error()).context("checking available disk space");
142 }
143
144 let free = stat.f_bavail as u64 * stat.f_bsize as u64;
145 let required = additional_bytes
146 .checked_add(2_000_000_000)
147 .context("disk budget overflow")?;
148
149 ensure!(
150 free >= required,
151 "{} needs {:.1} GB more disk space plus a 2 GB reserve; only {:.1} GB is available",
152 path.display(),
153 additional_bytes as f64 / BYTES_PER_GB as f64,
154 free as f64 / BYTES_PER_GB as f64
155 );
156
157 Ok(())
158}
159
160#[cfg(test)]
161pub(crate) fn test_model_dir() -> Option<PathBuf> {
162 let path = std::env::var_os("CHERENKOV_MODEL_DIR")
163 .map(PathBuf::from)
164 .or_else(|| Paths::new(None).ok().map(|p| p.default_model()))?;
165
166 path.join("packed/manifest.json").exists().then_some(path)
167}
168
169#[cfg(test)]
170#[path = "../tests/unit/storage.rs"]
171mod tests;