Skip to main content

gglib_core/paths/
models.rs

1//! Models directory resolution, and the canonical form of a model file path.
2//!
3//! Provides utilities for resolving the models directory from explicit paths,
4//! environment variables, or platform defaults, plus the single definition of
5//! what makes two paths "the same model file" — see
6//! [`canonical_model_path`].
7
8use std::env;
9use std::path::{Path, PathBuf};
10
11use super::error::PathError;
12use super::platform::normalize_user_path;
13
14/// Default relative location for downloaded models on non-Windows platforms.
15#[cfg(not(target_os = "windows"))]
16pub const DEFAULT_MODELS_DIR_RELATIVE: &str = ".local/share/llama_models";
17
18/// How the models directory was derived.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ModelsDirSource {
21    /// The user passed an explicit path (e.g., CLI flag or GUI form).
22    Explicit,
23    /// The path came from environment variables / `.env`.
24    EnvVar,
25    /// Fallback default (`~/.local/share/llama_models` on Linux/macOS,
26    /// `%LOCALAPPDATA%\llama_models` on Windows).
27    Default,
28}
29
30/// Resolution result for the models directory.
31#[derive(Debug, Clone)]
32pub struct ModelsDirResolution {
33    /// The resolved path to the models directory.
34    pub path: PathBuf,
35    /// How the path was determined.
36    pub source: ModelsDirSource,
37}
38
39/// Return the platform-specific default models directory.
40///
41/// - **Windows**: `%LOCALAPPDATA%\llama_models` (e.g. `C:\Users\name\AppData\Local\llama_models`)
42/// - **macOS / Linux**: `~/.local/share/llama_models`
43pub fn default_models_dir() -> Result<PathBuf, PathError> {
44    #[cfg(target_os = "windows")]
45    {
46        let local_app_data = dirs::data_local_dir().ok_or(PathError::NoDataDir)?;
47        Ok(local_app_data.join("llama_models"))
48    }
49    #[cfg(not(target_os = "windows"))]
50    {
51        let home = dirs::home_dir().ok_or(PathError::NoHomeDir)?;
52        Ok(home.join(DEFAULT_MODELS_DIR_RELATIVE))
53    }
54}
55
56/// Resolve the models directory from an explicit override, env var, or default.
57///
58/// Resolution order:
59/// 1. Explicit path provided by caller (highest priority)
60/// 2. `GGLIB_MODELS_DIR` environment variable
61/// 3. Default models directory (`~/.local/share/llama_models`)
62pub fn resolve_models_dir(explicit: Option<&str>) -> Result<ModelsDirResolution, PathError> {
63    if let Some(path_str) = explicit {
64        return Ok(ModelsDirResolution {
65            path: normalize_user_path(path_str)?,
66            source: ModelsDirSource::Explicit,
67        });
68    }
69
70    if let Ok(env_path) = env::var("GGLIB_MODELS_DIR")
71        && !env_path.trim().is_empty()
72    {
73        return Ok(ModelsDirResolution {
74            path: normalize_user_path(&env_path)?,
75            source: ModelsDirSource::EnvVar,
76        });
77    }
78
79    Ok(ModelsDirResolution {
80        path: default_models_dir()?,
81        source: ModelsDirSource::Default,
82    })
83}
84
85/// Resolve `path` to the one form the library identifies a model file by.
86///
87/// Three separate places have to agree about what "the same file" means: the
88/// `file_path` column a model is stored under, the `model_key` that decides
89/// whether an insert is really an update, and the duplicate lookup an
90/// explicit add performs before inserting. While they disagreed, the failure
91/// was silent and destructive — two *different* files sharing a relative name
92/// (`model.gguf` in two directories) hashed to one key, the duplicate check
93/// compared resolved paths and saw no match, and the UPSERT merged them into
94/// a single row carrying the first file's name and the second file's path.
95///
96/// Everything that needs that answer resolves it here, so the three cannot
97/// drift apart again.
98///
99/// # Errors
100///
101/// Returns the underlying [`std::io::Error`] when the path cannot be
102/// resolved — most often because no file exists there.
103///
104/// Fallible on purpose. The infallible "canonicalise, or keep the literal
105/// path" shape reads as the convenient one and is precisely the shape of the
106/// bug: a caller asking *is this file already in the library?* gets a literal
107/// path back, compares it against a stored canonical one, matches nothing,
108/// and reports "no duplicate" for a file that is plainly there. Callers for
109/// which this genuinely cannot fail have already established that the file
110/// exists; they should say so by propagating rather than by swallowing.
111pub fn canonical_model_path(path: &Path) -> std::io::Result<PathBuf> {
112    std::fs::canonicalize(path)
113}
114
115/// The canonical path as the string the `file_path` column stores.
116///
117/// Falls back to the literal path when the file cannot be resolved, because a
118/// row whose file has since been deleted still has to round-trip through the
119/// database. Reach for [`canonical_model_path`] anywhere a failure to resolve
120/// should be visible to the caller rather than papered over.
121#[must_use]
122pub fn canonical_model_path_string(path: &Path) -> String {
123    canonical_model_path(path).map_or_else(
124        |_| path.to_string_lossy().into_owned(),
125        |resolved| resolved.to_string_lossy().into_owned(),
126    )
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::paths::test_utils::{ENV_LOCK, EnvVarGuard};
133
134    #[test]
135    fn test_default_models_dir_platform_path() {
136        let dir = default_models_dir().unwrap();
137        let path_str = dir.to_string_lossy();
138        // On Windows the path should be under %LOCALAPPDATA% and use native
139        // separators throughout — no forward-slash fragments.
140        #[cfg(target_os = "windows")]
141        {
142            assert!(
143                path_str.contains("llama_models"),
144                "Expected 'llama_models' in path: {path_str}"
145            );
146            assert!(
147                !path_str.contains('/'),
148                "Path must not contain forward slashes on Windows: {path_str}"
149            );
150        }
151        // On non-Windows the path should sit under ~/.local/share/llama_models.
152        #[cfg(not(target_os = "windows"))]
153        assert!(
154            path_str.contains(DEFAULT_MODELS_DIR_RELATIVE),
155            "Expected '{DEFAULT_MODELS_DIR_RELATIVE}' in path: {path_str}"
156        );
157    }
158
159    #[test]
160    fn test_resolve_models_dir_prefers_explicit() {
161        let _guard = ENV_LOCK.lock().unwrap();
162        let _env = EnvVarGuard::set("GGLIB_MODELS_DIR", "/tmp/env-value");
163        let resolved = resolve_models_dir(Some("/tmp/explicit")).unwrap();
164        assert_eq!(resolved.source, ModelsDirSource::Explicit);
165        assert!(resolved.path.ends_with("explicit"));
166    }
167
168    #[test]
169    fn test_resolve_models_dir_env_value() {
170        let _guard = ENV_LOCK.lock().unwrap();
171        let _env = EnvVarGuard::set("GGLIB_MODELS_DIR", "/tmp/from-env");
172        let resolved = resolve_models_dir(None).unwrap();
173        assert_eq!(resolved.source, ModelsDirSource::EnvVar);
174        assert!(resolved.path.ends_with("from-env"));
175    }
176
177    /// Two spellings of one file resolve to one answer. This is the property
178    /// the model key, the stored column and the duplicate lookup all lean on;
179    /// if it stops holding, a re-add silently merges two models into one row.
180    #[test]
181    fn canonical_model_path_agrees_across_spellings_of_one_file() {
182        let dir = tempfile::tempdir().unwrap();
183        let file = dir.path().join("Model.gguf");
184        std::fs::File::create(&file).unwrap();
185
186        let direct = canonical_model_path(&file).unwrap();
187        let indirect = canonical_model_path(&dir.path().join(".").join("Model.gguf")).unwrap();
188
189        assert_eq!(direct, indirect);
190    }
191
192    /// The fallible form reports a path it cannot resolve instead of handing
193    /// back the literal one. A caller that treats "cannot resolve" as "not a
194    /// duplicate" reinstates the silent overwrite, so the error has to be
195    /// reachable.
196    #[test]
197    fn canonical_model_path_reports_a_path_that_does_not_resolve() {
198        let dir = tempfile::tempdir().unwrap();
199        assert!(canonical_model_path(&dir.path().join("Absent.gguf")).is_err());
200    }
201
202    /// The string form is the one the database column stores, and it keeps
203    /// the literal path when the file is gone so an existing row still
204    /// round-trips.
205    #[test]
206    fn canonical_model_path_string_falls_back_to_the_literal_path() {
207        let dir = tempfile::tempdir().unwrap();
208        let absent = dir.path().join("Absent.gguf");
209
210        assert_eq!(
211            canonical_model_path_string(&absent),
212            absent.to_string_lossy()
213        );
214    }
215}