Skip to main content

harness/
registry.rs

1use std::path::PathBuf;
2
3use crate::models::ModelRegistry;
4
5/// URL to fetch the canonical models.toml from GitHub.
6const REGISTRY_URL: &str =
7    "https://raw.githubusercontent.com/ayshptk/harness/main/models.toml";
8
9/// Cache TTL in seconds (24 hours).
10const TTL_SECS: u64 = 86400;
11
12/// HTTP request timeout in seconds.
13const FETCH_TIMEOUT_SECS: u64 = 5;
14
15/// Path to the cached registry: `~/.harness/models.toml`.
16pub fn canonical_path() -> Option<PathBuf> {
17    dirs::home_dir().map(|d| d.join(".harness").join("models.toml"))
18}
19
20/// Load the canonical model registry.
21///
22/// Resolution order:
23/// 1. Load from `~/.harness/models.toml` if it exists and is fresh (< 24h old).
24/// 2. If missing or stale, attempt to fetch from GitHub and cache.
25/// 3. If fetch fails, use cached version (even if stale).
26/// 4. If no cache at all, fall back to the builtin registry.
27///
28/// This function **never** fails — it always returns a usable registry.
29pub fn load_canonical() -> ModelRegistry {
30    let path = match canonical_path() {
31        Some(p) => p,
32        None => {
33            tracing::debug!("cannot determine home directory, using builtin registry");
34            return ModelRegistry::builtin();
35        }
36    };
37
38    // If the file exists and is fresh, use it.
39    if path.exists() && !is_stale(&path) {
40        if let Some(reg) = load_from_disk(&path) {
41            return reg;
42        }
43    }
44
45    // Try to fetch and cache a fresh copy.
46    match fetch_and_cache(&path) {
47        Ok(reg) => return reg,
48        Err(e) => {
49            tracing::debug!("failed to fetch models registry: {e}");
50        }
51    }
52
53    // Fall back to stale cache.
54    if path.exists() {
55        if let Some(reg) = load_from_disk(&path) {
56            tracing::debug!("using stale cached registry");
57            return reg;
58        }
59    }
60
61    // Ultimate fallback: builtin.
62    tracing::debug!("using builtin registry");
63    ModelRegistry::builtin()
64}
65
66/// Force-fetch the registry from GitHub and cache it.
67/// Returns a human-readable status message.
68pub fn force_update() -> Result<String, String> {
69    let path = canonical_path().ok_or("cannot determine home directory")?;
70    match fetch_and_cache(&path) {
71        Ok(_) => Ok(format!("Updated registry at {}", path.display())),
72        Err(e) => Err(format!("failed to fetch: {e}")),
73    }
74}
75
76/// Check if the cached file is older than `TTL_SECS`.
77fn is_stale(path: &std::path::Path) -> bool {
78    let metadata = match std::fs::metadata(path) {
79        Ok(m) => m,
80        Err(_) => return true,
81    };
82    let modified = match metadata.modified() {
83        Ok(t) => t,
84        Err(_) => return true,
85    };
86    let age = std::time::SystemTime::now()
87        .duration_since(modified)
88        .unwrap_or_default();
89    age.as_secs() > TTL_SECS
90}
91
92/// Load and parse a registry from disk, returning `None` on any error.
93fn load_from_disk(path: &std::path::Path) -> Option<ModelRegistry> {
94    let content = std::fs::read_to_string(path).ok()?;
95    match ModelRegistry::from_toml(&content) {
96        Ok(reg) => Some(reg),
97        Err(e) => {
98            tracing::warn!("failed to parse cached registry at {}: {e}", path.display());
99            None
100        }
101    }
102}
103
104/// Fetch from GitHub, parse, and atomically write to disk.
105fn fetch_and_cache(path: &std::path::Path) -> Result<ModelRegistry, String> {
106    let body = fetch_registry_content()?;
107    let reg = ModelRegistry::from_toml(&body)?;
108
109    // Ensure parent directory exists.
110    if let Some(parent) = path.parent() {
111        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {e}"))?;
112    }
113
114    // Atomic write: write to .tmp, then rename.
115    let tmp_path = path.with_extension("toml.tmp");
116    std::fs::write(&tmp_path, &body).map_err(|e| format!("write failed: {e}"))?;
117    std::fs::rename(&tmp_path, path).map_err(|e| format!("rename failed: {e}"))?;
118
119    tracing::debug!("cached registry at {}", path.display());
120    Ok(reg)
121}
122
123/// HTTP GET the registry content.
124fn fetch_registry_content() -> Result<String, String> {
125    let agent = ureq::Agent::config_builder()
126        .timeout_global(Some(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS)))
127        .build()
128        .new_agent();
129    let body = agent
130        .get(REGISTRY_URL)
131        .call()
132        .map_err(|e| format!("HTTP request failed: {e}"))?
133        .body_mut()
134        .read_to_string()
135        .map_err(|e| format!("failed to read response body: {e}"))?;
136    Ok(body)
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn canonical_path_is_under_home() {
145        if let Some(path) = canonical_path() {
146            assert!(path.to_string_lossy().contains(".harness"));
147            assert!(path.to_string_lossy().ends_with("models.toml"));
148        }
149    }
150
151    #[test]
152    fn is_stale_missing_file() {
153        assert!(is_stale(std::path::Path::new("/nonexistent/file")));
154    }
155
156    #[test]
157    fn load_from_disk_valid() {
158        let tmp = tempfile::NamedTempFile::new().unwrap();
159        std::fs::write(
160            tmp.path(),
161            r#"
162[models.test]
163description = "Test Model"
164provider = "test"
165claude = "test-id"
166"#,
167        )
168        .unwrap();
169        let reg = load_from_disk(tmp.path());
170        assert!(reg.is_some());
171        assert!(reg.unwrap().models.contains_key("test"));
172    }
173
174    #[test]
175    fn load_from_disk_invalid_returns_none() {
176        let tmp = tempfile::NamedTempFile::new().unwrap();
177        std::fs::write(tmp.path(), "{{{{ not toml").unwrap();
178        assert!(load_from_disk(tmp.path()).is_none());
179    }
180
181    #[test]
182    fn load_canonical_returns_something() {
183        // This should always succeed, at minimum returning the builtin.
184        let reg = load_canonical();
185        assert!(!reg.models.is_empty());
186    }
187}