1pub mod custom;
15pub mod discovery;
16
17use chrono::Duration;
18use serde::{Deserialize, Serialize};
19use tracing::{debug, warn};
20
21use crate::cache::{self, CacheEntry};
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
61pub async fn fetch() -> crate::Result<Vec<CuratedRepo>> {
78 let config = load_config()?;
79 let url = &config.cache.curated_repos_url;
80 let ttl = Duration::hours(config.cache.repo_ttl_hours.try_into().unwrap_or(24));
81
82 let cache_key = "curated_repos.json";
84 if let Ok(Some(entry)) = cache::read_cache::<Vec<CuratedRepo>>(cache_key)
85 && entry.is_valid(ttl)
86 {
87 debug!("Using cached curated repositories");
88 return Ok(entry.data);
89 }
90
91 debug!("Fetching curated repositories from {}", url);
93 let repos = if let Ok(repos) = reqwest::Client::new().get(url).send().await?.json().await {
94 repos
95 } else {
96 warn!("Failed to fetch remote curated repositories, using embedded defaults");
97 embedded_defaults()
98 };
99
100 let entry = CacheEntry::new(repos.clone());
102 let _ = cache::write_cache(cache_key, &entry);
103 debug!("Fetched and cached {} curated repositories", repos.len());
104
105 Ok(repos)
106}
107
108#[derive(Debug, Clone, Copy)]
110pub enum RepoFilter {
111 All,
113 Curated,
115 Custom,
117}
118
119pub async fn fetch_all(filter: RepoFilter) -> crate::Result<Vec<CuratedRepo>> {
136 let config = load_config()?;
137 let mut repos = Vec::new();
138 let mut seen = std::collections::HashSet::new();
139
140 match filter {
142 RepoFilter::All | RepoFilter::Curated => {
143 if config.repos.curated {
144 let curated = fetch().await?;
145 for repo in curated {
146 if seen.insert(repo.full_name()) {
147 repos.push(repo);
148 }
149 }
150 }
151 }
152 RepoFilter::Custom => {}
153 }
154
155 match filter {
157 RepoFilter::All | RepoFilter::Custom => {
158 let custom = custom::read_custom_repos()?;
159 for repo in custom {
160 if seen.insert(repo.full_name()) {
161 repos.push(repo);
162 }
163 }
164 }
165 RepoFilter::Curated => {}
166 }
167
168 debug!(
169 "Fetched {} repositories with filter {:?}",
170 repos.len(),
171 filter
172 );
173 Ok(repos)
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn full_name_format() {
182 let repo = CuratedRepo {
183 owner: "owner".to_string(),
184 name: "repo".to_string(),
185 language: "Rust".to_string(),
186 description: "Test repository".to_string(),
187 };
188 assert_eq!(repo.full_name(), "owner/repo");
189 }
190
191 #[test]
192 fn embedded_defaults_returns_non_empty() {
193 let repos = embedded_defaults();
194 assert!(
195 !repos.is_empty(),
196 "embedded defaults should contain repositories"
197 );
198 }
199}