1pub mod custom;
15pub mod discovery;
16
17use chrono::Duration;
18use serde::{Deserialize, Serialize};
19use tracing::{debug, error, warn};
20
21#[cfg(not(target_arch = "wasm32"))]
22use crate::cache::FileCache;
23#[cfg(not(target_arch = "wasm32"))]
24use crate::config::load_config;
25
26const EMBEDDED_REPOS: &str = include_str!("../../data/curated-repos.json");
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CuratedRepo {
32 pub owner: String,
34 pub name: String,
36 pub language: String,
38 pub description: String,
40}
41
42impl CuratedRepo {
43 #[must_use]
45 pub fn full_name(&self) -> String {
46 format!("{}/{}", self.owner, self.name)
47 }
48}
49
50fn embedded_defaults() -> Vec<CuratedRepo> {
60 serde_json::from_str(EMBEDDED_REPOS).expect("embedded repos JSON is valid")
61}
62
63#[cfg(not(target_arch = "wasm32"))]
69static HTTP_CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
70 reqwest::Client::builder()
76 .timeout(std::time::Duration::from_secs(30))
77 .build()
78 .unwrap_or_else(|e| {
79 error!(%e, "Failed to build HTTP client, falling back to default");
80 reqwest::Client::default()
81 })
82});
83
84#[cfg(not(target_arch = "wasm32"))]
85async fn fetch_from_remote(url: &str) -> crate::Result<Vec<CuratedRepo>> {
86 debug!("Fetching curated repositories from {}", url);
87 let response = HTTP_CLIENT.get(url).send().await?;
88 if let Ok(repos) = response.json::<Vec<CuratedRepo>>().await {
89 Ok(repos)
90 } else {
91 warn!("Failed to parse remote curated repositories, using embedded defaults");
92 Ok(embedded_defaults())
93 }
94}
95
96#[cfg(not(target_arch = "wasm32"))]
113pub async fn fetch() -> crate::Result<Vec<CuratedRepo>> {
114 let config = load_config()?;
115 let url = &config.cache.curated_repos_url;
116 let ttl = Duration::hours(config.cache.repo_ttl_hours);
117
118 let cache: crate::cache::FileCacheImpl<Vec<CuratedRepo>> =
120 crate::cache::FileCacheImpl::new("repos", ttl);
121 if let Ok(Some(repos)) = cache.get("curated_repos").await {
122 debug!("Using cached curated repositories");
123 return Ok(repos);
124 }
125
126 let repos = fetch_from_remote(url).await?;
128 let _ = cache.set("curated_repos", &repos).await;
129 debug!("Fetched and cached {} curated repositories", repos.len());
130
131 Ok(repos)
132}
133
134#[derive(Debug, Clone, Copy)]
136pub enum RepoFilter {
137 All,
139 Curated,
141 Custom,
143}
144
145fn add_filtered_repos(
147 repos: &mut Vec<CuratedRepo>,
148 seen: &mut std::collections::HashSet<String>,
149 new_repos: Vec<CuratedRepo>,
150) {
151 for repo in new_repos {
152 if seen.insert(repo.full_name()) {
153 repos.push(repo);
154 }
155 }
156}
157
158#[cfg(not(target_arch = "wasm32"))]
175pub async fn fetch_all(filter: RepoFilter) -> crate::Result<Vec<CuratedRepo>> {
176 let config = load_config()?;
177 let mut repos = Vec::new();
178 let mut seen = std::collections::HashSet::new();
179
180 match filter {
182 RepoFilter::All | RepoFilter::Curated => {
183 if config.repos.curated {
184 let curated = fetch().await?;
185 add_filtered_repos(&mut repos, &mut seen, curated);
186 }
187 }
188 RepoFilter::Custom => {}
189 }
190
191 match filter {
193 RepoFilter::All | RepoFilter::Custom => {
194 let custom = custom::read_custom_repos()?;
195 add_filtered_repos(&mut repos, &mut seen, custom);
196 }
197 RepoFilter::Curated => {}
198 }
199
200 debug!(
201 "Fetched {} repositories with filter {:?}",
202 repos.len(),
203 filter
204 );
205 Ok(repos)
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn full_name_format() {
214 let repo = CuratedRepo {
215 owner: "owner".to_string(),
216 name: "repo".to_string(),
217 language: "Rust".to_string(),
218 description: "Test repository".to_string(),
219 };
220 assert_eq!(repo.full_name(), "owner/repo");
221 }
222
223 #[test]
224 fn embedded_defaults_returns_non_empty() {
225 let repos = embedded_defaults();
226 assert!(
227 !repos.is_empty(),
228 "embedded defaults should contain repositories"
229 );
230 }
231}