1pub mod custom;
15pub mod discovery;
16
17use chrono::Duration;
18use serde::{Deserialize, Serialize};
19use tracing::{debug, warn};
20
21use crate::cache::FileCache;
22use crate::config::load_config;
23
24const EMBEDDED_REPOS: &str = include_str!("../../data/curated-repos.json");
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CuratedRepo {
30 pub owner: String,
32 pub name: String,
34 pub language: String,
36 pub description: String,
38}
39
40impl CuratedRepo {
41 #[must_use]
43 pub fn full_name(&self) -> String {
44 format!("{}/{}", self.owner, self.name)
45 }
46}
47
48fn embedded_defaults() -> Vec<CuratedRepo> {
58 serde_json::from_str(EMBEDDED_REPOS).expect("embedded repos JSON is valid")
59}
60
61async fn fetch_from_remote(url: &str) -> crate::Result<Vec<CuratedRepo>> {
65 debug!("Fetching curated repositories from {}", url);
66 let response = reqwest::Client::new().get(url).send().await?;
67 if let Ok(repos) = response.json::<Vec<CuratedRepo>>().await {
68 Ok(repos)
69 } else {
70 warn!("Failed to parse remote curated repositories, using embedded defaults");
71 Ok(embedded_defaults())
72 }
73}
74
75pub async fn fetch() -> crate::Result<Vec<CuratedRepo>> {
92 let config = load_config()?;
93 let url = &config.cache.curated_repos_url;
94 let ttl = Duration::hours(config.cache.repo_ttl_hours);
95
96 let cache: crate::cache::FileCacheImpl<Vec<CuratedRepo>> =
98 crate::cache::FileCacheImpl::new("repos", ttl);
99 if let Ok(Some(repos)) = cache.get("curated_repos") {
100 debug!("Using cached curated repositories");
101 return Ok(repos);
102 }
103
104 let repos = fetch_from_remote(url).await?;
106 let _ = cache.set("curated_repos", &repos);
107 debug!("Fetched and cached {} curated repositories", repos.len());
108
109 Ok(repos)
110}
111
112#[derive(Debug, Clone, Copy)]
114pub enum RepoFilter {
115 All,
117 Curated,
119 Custom,
121}
122
123fn add_filtered_repos(
125 repos: &mut Vec<CuratedRepo>,
126 seen: &mut std::collections::HashSet<String>,
127 new_repos: Vec<CuratedRepo>,
128) {
129 for repo in new_repos {
130 if seen.insert(repo.full_name()) {
131 repos.push(repo);
132 }
133 }
134}
135
136pub async fn fetch_all(filter: RepoFilter) -> crate::Result<Vec<CuratedRepo>> {
153 let config = load_config()?;
154 let mut repos = Vec::new();
155 let mut seen = std::collections::HashSet::new();
156
157 match filter {
159 RepoFilter::All | RepoFilter::Curated => {
160 if config.repos.curated {
161 let curated = fetch().await?;
162 add_filtered_repos(&mut repos, &mut seen, curated);
163 }
164 }
165 RepoFilter::Custom => {}
166 }
167
168 match filter {
170 RepoFilter::All | RepoFilter::Custom => {
171 let custom = custom::read_custom_repos()?;
172 add_filtered_repos(&mut repos, &mut seen, custom);
173 }
174 RepoFilter::Curated => {}
175 }
176
177 debug!(
178 "Fetched {} repositories with filter {:?}",
179 repos.len(),
180 filter
181 );
182 Ok(repos)
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn full_name_format() {
191 let repo = CuratedRepo {
192 owner: "owner".to_string(),
193 name: "repo".to_string(),
194 language: "Rust".to_string(),
195 description: "Test repository".to_string(),
196 };
197 assert_eq!(repo.full_name(), "owner/repo");
198 }
199
200 #[test]
201 fn embedded_defaults_returns_non_empty() {
202 let repos = embedded_defaults();
203 assert!(
204 !repos.is_empty(),
205 "embedded defaults should contain repositories"
206 );
207 }
208}