1use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9
10use crate::backend::{Backend, Ctx};
11use crate::error::{Error, Result};
12use crate::source::{
13 candidate_fingerprint, ProbeCache, ProbeResult, Selection, Source, SourceKind,
14};
15
16const SOURCE_CACHE_SCHEMA_VERSION: u32 = 2;
17
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19struct VersionedProbeCache {
20 schema_version: u32,
21 candidate_fingerprint: String,
22 results: Vec<ProbeResult>,
23}
24
25impl VersionedProbeCache {
26 fn new(candidate_fingerprint: String, results: Vec<ProbeResult>) -> Self {
27 Self {
28 schema_version: SOURCE_CACHE_SCHEMA_VERSION,
29 candidate_fingerprint,
30 results,
31 }
32 }
33
34 fn is_compatible(&self, candidate_fingerprint: &str) -> bool {
35 self.schema_version == SOURCE_CACHE_SCHEMA_VERSION
36 && self.candidate_fingerprint == candidate_fingerprint
37 }
38
39 fn into_probe_cache(self) -> ProbeCache {
40 ProbeCache {
41 results: self.results,
42 }
43 }
44}
45
46pub fn effective_sources(ctx: &Ctx, backend: &dyn Backend) -> Vec<Source> {
49 let mut sources = backend.default_sources();
50 if let Some(tool_cfg) = ctx.config.tool_sources(backend.id()) {
51 if !tool_cfg.disable.is_empty() {
52 sources.retain(|s| !tool_cfg.disable.iter().any(|d| d == &s.id));
53 }
54 for custom in &tool_cfg.custom {
55 sources.retain(|s| s.id != custom.id);
57 sources.push(custom.clone());
58 }
59 }
60 sources.retain(|s| s.enabled);
61 sources.sort_by_key(|s| s.priority);
63 sources
64}
65
66pub async fn active_source(ctx: &Ctx, backend: &dyn Backend) -> Result<Source> {
72 ranked_source_list(ctx, backend)
73 .await?
74 .into_iter()
75 .next()
76 .ok_or_else(|| Error::NoUsableSource {
77 tool: backend.id().to_string(),
78 tried: 0,
79 })
80}
81
82pub async fn ranked_source_list(ctx: &Ctx, backend: &dyn Backend) -> Result<Vec<Source>> {
87 ranked_source_candidates(ctx, backend, effective_sources(ctx, backend)).await
88}
89
90pub async fn ranked_source_candidates(
95 ctx: &Ctx,
96 backend: &dyn Backend,
97 sources: Vec<Source>,
98) -> Result<Vec<Source>> {
99 if sources.is_empty() {
100 return Err(Error::NoUsableSource {
101 tool: backend.id().to_string(),
102 tried: 0,
103 });
104 }
105
106 if let Some(tool_cfg) = ctx.config.tool_sources(backend.id()) {
108 if let Some(pin) = &tool_cfg.pin {
109 if let Some(idx) = sources.iter().position(|s| &s.id == pin) {
110 let mut ordered = sources.clone();
111 let pinned = ordered.remove(idx);
112 let mut out = vec![pinned];
113 out.extend(ordered);
114 return Ok(out);
115 }
116 }
117 }
118
119 if ctx.config.settings.offline {
120 if matches!(ctx.config.sources.selection, Selection::Auto) {
121 if let Some(cache) = load_cache(ctx, backend.id(), &sources) {
122 return Ok(order_sources_by_probe_results(sources, cache.results));
123 }
124 }
125 return Ok(sources);
126 }
127
128 match ctx.config.sources.selection {
129 Selection::Ordered | Selection::Pinned => Ok(sources),
130 Selection::Auto => {
131 let ranked_ids = ranked_sources(ctx, backend, &sources).await;
132 Ok(order_sources_by_ids(sources, &ranked_ids))
133 }
134 }
135}
136
137fn order_sources_by_probe_results(
138 sources: Vec<Source>,
139 mut results: Vec<ProbeResult>,
140) -> Vec<Source> {
141 results.sort_by(|left, right| right.score().total_cmp(&left.score()));
142 let ids = results
143 .into_iter()
144 .filter(|result| result.ok)
145 .map(|result| result.source_id)
146 .collect::<Vec<_>>();
147 order_sources_by_ids(sources, &ids)
148}
149
150fn order_sources_by_ids(sources: Vec<Source>, ids: &[String]) -> Vec<Source> {
151 let mut ordered = Vec::with_capacity(sources.len());
154 for id in ids {
155 if let Some(source) = sources.iter().find(|source| &source.id == id) {
156 ordered.push(source.clone());
157 }
158 }
159 for source in sources {
160 if !ordered.iter().any(|ranked| ranked.id == source.id) {
161 ordered.push(source);
162 }
163 }
164 ordered
165}
166
167async fn ranked_sources(ctx: &Ctx, backend: &dyn Backend, sources: &[Source]) -> Vec<String> {
169 if let Some(cache) = load_cache(ctx, backend.id(), sources) {
171 let ttl = ctx.config.sources.cache_ttl_secs();
172 let now = crate::source::now_secs();
173 let fresh = cache
174 .results
175 .iter()
176 .all(|r| now.saturating_sub(r.measured_at) <= ttl)
177 && !cache.results.is_empty();
178 if fresh {
179 let mut results = cache.results.clone();
180 results.sort_by(|a, b| b.score().total_cmp(&a.score()));
181 return results
182 .into_iter()
183 .filter(|r| r.ok)
184 .map(|r| r.source_id)
185 .collect();
186 }
187 }
188
189 let results = probe_all(ctx, backend, sources).await;
191 save_cache(ctx, backend.id(), sources, &results);
192 let mut ok: Vec<ProbeResult> = results.into_iter().filter(|r| r.ok).collect();
193 ok.sort_by(|a, b| b.score().total_cmp(&a.score()));
194 ok.into_iter().map(|r| r.source_id).collect()
195}
196
197pub async fn probe_all(ctx: &Ctx, backend: &dyn Backend, sources: &[Source]) -> Vec<ProbeResult> {
199 let timeout = Duration::from_millis(ctx.config.sources.probe_timeout_ms);
200 let mut handles = Vec::new();
201 for s in sources {
202 let url = backend.probe_url(ctx, s);
203 let client = ctx.client.clone();
204 let source = s.clone();
205 let to = timeout;
206 handles.push(tokio::spawn(async move {
207 match url {
208 Some(u) => probe_one(&client, &source, &u, to).await,
209 None => ProbeResult::failed(&source.id),
210 }
211 }));
212 }
213 let mut out = Vec::new();
214 for h in handles {
215 if let Ok(r) = h.await {
216 out.push(r)
217 }
218 }
219 out
220}
221
222async fn probe_one(
225 client: &reqwest::Client,
226 source: &Source,
227 url: &str,
228 timeout: Duration,
229) -> ProbeResult {
230 use futures_util::StreamExt;
231
232 let start = Instant::now();
233 let fut = async {
234 let resp = crate::http::get_source_response(client, source, url)
235 .await
236 .ok()?
237 .error_for_status()
238 .ok()?;
239 let ttfb = start.elapsed();
240 let mut stream = resp.bytes_stream();
241 let mut downloaded: u64 = 0;
242 let body_start = Instant::now();
243 while let Some(chunk) = stream.next().await {
244 match chunk {
245 Ok(c) => {
246 downloaded += c.len() as u64;
247 if downloaded >= 1_000_000 {
248 break;
249 }
250 }
251 Err(_) => break,
252 }
253 }
254 let secs = body_start.elapsed().as_secs_f64().max(0.001);
255 let throughput = downloaded as f64 / secs;
256 Some((ttfb, throughput, downloaded))
257 };
258
259 match tokio::time::timeout(timeout, fut).await {
260 Ok(Some((ttfb, throughput, downloaded))) if downloaded > 0 => ProbeResult {
261 source_id: source.id.clone(),
262 throughput,
263 ttfb_ms: ttfb.as_millis() as u64,
264 ok: true,
265 measured_at: crate::source::now_secs(),
266 },
267 _ => ProbeResult::failed(&source.id),
268 }
269}
270
271fn cache_path(ctx: &Ctx, tool: &str) -> PathBuf {
272 let mut path = ctx
273 .dirs
274 .sources_cache()
275 .join(crate::dirs::sanitize_tool_id(tool));
276 path.set_extension("json");
277 path
278}
279
280fn read_versioned_cache(path: &Path, candidate_fingerprint: &str) -> Option<ProbeCache> {
281 let bytes = std::fs::read(path).ok()?;
282 let cache: VersionedProbeCache = serde_json::from_slice(&bytes).ok()?;
283 cache
284 .is_compatible(candidate_fingerprint)
285 .then(|| cache.into_probe_cache())
286}
287
288fn load_cache(ctx: &Ctx, tool: &str, sources: &[Source]) -> Option<ProbeCache> {
289 let candidate_fingerprint = candidate_fingerprint(sources);
290 read_versioned_cache(&cache_path(ctx, tool), &candidate_fingerprint)
291}
292
293fn save_cache(ctx: &Ctx, tool: &str, sources: &[Source], results: &[ProbeResult]) {
294 let p = cache_path(ctx, tool);
295 if let Some(parent) = p.parent() {
296 let _ = std::fs::create_dir_all(parent);
297 }
298 let cache = VersionedProbeCache::new(candidate_fingerprint(sources), results.to_vec());
299 if let Ok(bytes) = serde_json::to_vec_pretty(&cache) {
300 let _ = std::fs::write(&p, bytes);
301 }
302}
303
304pub async fn refresh(ctx: &Ctx, backend: &dyn Backend) -> Result<Vec<ProbeResult>> {
307 if ctx.config.settings.offline {
308 return Err(Error::other("cannot refresh sources while offline"));
309 }
310 let sources = effective_sources(ctx, backend);
311 let results = probe_all(ctx, backend, &sources).await;
312 save_cache(ctx, backend.id(), &sources, &results);
313 Ok(results)
314}
315
316pub fn kind_label(kind: SourceKind) -> &'static str {
318 match kind {
319 SourceKind::Official => "official",
320 SourceKind::Mirror => "mirror",
321 SourceKind::Custom => "custom",
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use std::collections::BTreeMap;
329 use std::io::{Read, Write};
330 use std::net::TcpListener;
331 use std::sync::Arc;
332
333 use async_trait::async_trait;
334
335 use crate::config::{Config, Settings};
336 use crate::dirs::Dirs;
337 use crate::platform::Platform;
338 use crate::store::Cas;
339 use crate::version::{ToolVersion, VersionInfo};
340
341 struct FixtureBackend {
342 id: &'static str,
343 sources: Vec<Source>,
344 }
345
346 #[async_trait]
347 impl Backend for FixtureBackend {
348 fn id(&self) -> &str {
349 self.id
350 }
351
352 fn default_sources(&self) -> Vec<Source> {
353 self.sources.clone()
354 }
355
356 fn probe_url(&self, _ctx: &Ctx, source: &Source) -> Option<String> {
357 Some(source.download_url.clone())
358 }
359
360 async fn list_remote_versions(&self, _ctx: &Ctx) -> Result<Vec<VersionInfo>> {
361 Ok(Vec::new())
362 }
363
364 async fn install(
365 &self,
366 _ctx: &crate::backend::InstallCtx<'_>,
367 _tv: &ToolVersion,
368 ) -> Result<()> {
369 Ok(())
370 }
371
372 fn bin_paths(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<Vec<PathBuf>> {
373 Ok(Vec::new())
374 }
375
376 fn bin_names(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<Vec<String>> {
377 Ok(Vec::new())
378 }
379 }
380
381 #[test]
382 fn cache_path_uses_nested_sanitized_tool_ids() {
383 let temporary = tempfile::tempdir().unwrap();
384 let ctx = test_ctx(temporary.path(), false);
385 assert_eq!(
386 cache_path(&ctx, "github:owner/repo"),
387 ctx.dirs
388 .sources_cache()
389 .join("github")
390 .join("owner")
391 .join("repo.json")
392 );
393 assert_eq!(
394 cache_path(&ctx, "../:owner\\\\repo"),
395 ctx.dirs.sources_cache().join("owner").join("repo.json")
396 );
397 }
398
399 #[test]
400 fn load_cache_treats_legacy_cache_as_stale() {
401 let temporary = tempfile::tempdir().unwrap();
402 let ctx = test_ctx(temporary.path(), false);
403 let sources = vec![Source::mirror("fixture", "https://mirror.example.test", 1)];
404 let legacy_path = cache_path(&ctx, "tool");
405 std::fs::write(
406 &legacy_path,
407 serde_json::to_vec_pretty(&ProbeCache {
408 results: vec![ProbeResult {
409 source_id: "fixture".into(),
410 throughput: 10.0,
411 ttfb_ms: 1,
412 ok: true,
413 measured_at: crate::source::now_secs(),
414 }],
415 })
416 .unwrap(),
417 )
418 .unwrap();
419
420 assert!(load_cache(&ctx, "tool", &sources).is_none());
421 }
422
423 #[test]
424 fn load_cache_rejects_changed_candidate_sets() {
425 let temporary = tempfile::tempdir().unwrap();
426 let ctx = test_ctx(temporary.path(), false);
427 let base_sources = vec![
428 Source::mirror("fixture", "https://mirror.example.test", 1),
429 Source::mirror("extra", "https://extra.example.test", 2),
430 ];
431 let cache_path = cache_path(&ctx, "tool");
432 save_cache(
433 &ctx,
434 "tool",
435 &base_sources,
436 &[
437 ProbeResult {
438 source_id: "fixture".into(),
439 throughput: 10.0,
440 ttfb_ms: 1,
441 ok: true,
442 measured_at: crate::source::now_secs(),
443 },
444 ProbeResult {
445 source_id: "extra".into(),
446 throughput: 5.0,
447 ttfb_ms: 2,
448 ok: true,
449 measured_at: crate::source::now_secs(),
450 },
451 ],
452 );
453
454 let mut changed_download = base_sources.clone();
455 changed_download[0].download_url = "https://other.example.test".into();
456 assert!(load_cache(&ctx, "tool", &changed_download).is_none());
457
458 let mut changed_priority = base_sources.clone();
459 changed_priority[0].priority = 99;
460 assert!(load_cache(&ctx, "tool", &changed_priority).is_none());
461
462 let mut changed_enabled = base_sources.clone();
463 changed_enabled[0].enabled = false;
464 assert!(load_cache(&ctx, "tool", &changed_enabled).is_none());
465
466 let changed_add = vec![
467 base_sources[0].clone(),
468 base_sources[1].clone(),
469 Source::mirror("third", "https://third.example.test", 3),
470 ];
471 assert!(load_cache(&ctx, "tool", &changed_add).is_none());
472
473 let changed_remove = vec![base_sources[0].clone()];
474 assert!(load_cache(&ctx, "tool", &changed_remove).is_none());
475
476 assert!(cache_path.is_file());
477 }
478
479 #[tokio::test]
480 async fn ranked_sources_reprobes_when_candidates_change() {
481 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
482 let address = listener.local_addr().unwrap();
483 let server = std::thread::spawn(move || {
484 for _ in 0..3 {
485 let (mut stream, _) = listener.accept().unwrap();
486 let mut request = Vec::new();
487 let mut buffer = [0u8; 2048];
488 while !request.ends_with(b"\r\n\r\n") {
489 let read = stream.read(&mut buffer).unwrap();
490 if read == 0 {
491 break;
492 }
493 request.extend_from_slice(&buffer[..read]);
494 }
495 stream
496 .write_all(
497 b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndata",
498 )
499 .unwrap();
500 }
501 });
502
503 let temporary = tempfile::tempdir().unwrap();
504 let ctx = test_ctx(temporary.path(), false);
505 let backend = FixtureBackend {
506 id: "tool",
507 sources: vec![Source::mirror("fixture", &format!("http://{address}"), 1)],
508 };
509
510 let first = ranked_sources(&ctx, &backend, &backend.sources).await;
511 assert_eq!(first, vec!["fixture"]);
512
513 let second = ranked_sources(&ctx, &backend, &backend.sources).await;
514 assert_eq!(second, vec!["fixture"]);
515
516 let changed_sources = vec![
517 Source::mirror("fixture", &format!("http://{address}"), 1),
518 Source::mirror("new", &format!("http://{address}"), 2),
519 ];
520 let mut third = ranked_sources(&ctx, &backend, &changed_sources).await;
521 third.sort();
522 assert_eq!(third, vec!["fixture", "new"]);
523
524 server.join().unwrap();
525 }
526
527 #[tokio::test]
528 async fn source_probe_applies_explicit_headers_without_forward_credentials() {
529 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
530 let address = listener.local_addr().unwrap();
531 let server = std::thread::spawn(move || {
532 let (mut stream, _) = listener.accept().unwrap();
533 let mut request = Vec::new();
534 let mut buffer = [0u8; 2048];
535 while !request.ends_with(b"\r\n\r\n") {
536 let read = stream.read(&mut buffer).unwrap();
537 if read == 0 {
538 break;
539 }
540 request.extend_from_slice(&buffer[..read]);
541 }
542 let request = String::from_utf8(request).unwrap().to_ascii_lowercase();
543 assert!(request.contains("x-probe-key: source-secret"));
544 stream
545 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndata")
546 .unwrap();
547 });
548
549 let temporary = tempfile::tempdir().unwrap();
550 let ctx = test_ctx(temporary.path(), false);
551 let mut source = Source::mirror("fixture", &format!("http://{address}/"), 1);
552 source.forward_credentials = false;
553 source.headers = vec![("X-Probe-Key".into(), "source-secret".into())];
554 let backend = FixtureBackend {
555 id: "tool",
556 sources: vec![source.clone()],
557 };
558
559 let results = probe_all(&ctx, &backend, &[source]).await;
560 assert_eq!(results.len(), 1);
561 assert!(results[0].ok);
562 server.join().unwrap();
563 }
564
565 #[tokio::test]
566 async fn offline_ranked_source_list_reuses_compatible_probe_cache() {
567 let temporary = tempfile::tempdir().unwrap();
568 let online_ctx = test_ctx(temporary.path(), false);
569 let backend = FixtureBackend {
570 id: "github:owner/repo",
571 sources: vec![
572 Source::mirror("first", "https://fast.example.test", 20),
573 Source::mirror("second", "https://slow.example.test", 10),
574 ],
575 };
576 let effective = effective_sources(&online_ctx, &backend);
577 save_cache(
578 &online_ctx,
579 backend.id(),
580 &effective,
581 &[
582 ProbeResult {
583 source_id: "first".into(),
584 throughput: 100.0,
585 ttfb_ms: 10,
586 ok: true,
587 measured_at: crate::source::now_secs(),
588 },
589 ProbeResult {
590 source_id: "second".into(),
591 throughput: 10.0,
592 ttfb_ms: 100,
593 ok: true,
594 measured_at: crate::source::now_secs(),
595 },
596 ],
597 );
598
599 let ranked = ranked_sources(&online_ctx, &backend, &effective).await;
600 assert_eq!(ranked, vec!["first", "second"]);
601
602 let mut offline_ctx = test_ctx(temporary.path(), true);
603 offline_ctx.config.sources.selection = Selection::Auto;
604 let resolved = ranked_source_list(&offline_ctx, &backend).await.unwrap();
605 let order: Vec<_> = resolved.into_iter().map(|source| source.id).collect();
606 assert_eq!(order, vec!["first", "second"]);
607 }
608
609 fn test_ctx(root: &Path, offline: bool) -> Ctx {
610 let dirs = Dirs::resolve_from(|key| match key {
611 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
612 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
613 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
614 _ => None,
615 })
616 .unwrap();
617 dirs.ensure().unwrap();
618 let settings = Settings {
619 offline,
620 ..Default::default()
621 };
622 Ctx {
623 dirs: dirs.clone(),
624 platform: Platform::current(),
625 config: Config {
626 settings,
627 sources: Default::default(),
628 tools: BTreeMap::new(),
629 tool_configs: BTreeMap::new(),
630 global_tools: BTreeMap::new(),
631 global_tool_configs: BTreeMap::new(),
632 tool_origins: BTreeMap::new(),
633 aliases: BTreeMap::new(),
634 project_config_path: None,
635 },
636 client: reqwest::Client::new(),
637 cas: Arc::new(Cas::new(dirs.store.clone())),
638 show_progress: false,
639 }
640 }
641}