1use std::path::PathBuf;
2
3use crate::models::ModelRegistry;
4
5const REGISTRY_URL: &str =
7 "https://raw.githubusercontent.com/ayshptk/harness/main/models.toml";
8
9const TTL_SECS: u64 = 86400;
11
12const FETCH_TIMEOUT_SECS: u64 = 5;
14
15pub fn canonical_path() -> Option<PathBuf> {
17 dirs::home_dir().map(|d| d.join(".harness").join("models.toml"))
18}
19
20pub 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 path.exists() && !is_stale(&path) {
40 if let Some(reg) = load_from_disk(&path) {
41 return reg;
42 }
43 }
44
45 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 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 tracing::debug!("using builtin registry");
63 ModelRegistry::builtin()
64}
65
66pub 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
76fn 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
92fn 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
104fn 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 if let Some(parent) = path.parent() {
111 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {e}"))?;
112 }
113
114 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
123fn 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 let reg = load_canonical();
185 assert!(!reg.models.is_empty());
186 }
187}