aptu_core/repos/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Curated and custom repository management for Aptu.
4//!
5//! Repositories can come from two sources:
6//! - Curated: fetched from a remote JSON file with TTL-based caching
7//! - Custom: stored locally in TOML format at `~/.config/aptu/repos.toml`
8//!
9//! The curated list contains repositories known to be:
10//! - Active (commits in last 30 days)
11//! - Welcoming (good first issue labels exist)
12//! - Responsive (maintainers reply within 1 week)
13
14pub mod custom;
15pub mod discovery;
16
17use chrono::Duration;
18use serde::{Deserialize, Serialize};
19use tracing::{debug, error, warn};
20
21use crate::cache::FileCache;
22use crate::config::load_config;
23
24/// Embedded curated repositories as fallback when network fetch fails.
25const EMBEDDED_REPOS: &str = include_str!("../../data/curated-repos.json");
26
27/// A curated repository for contribution.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CuratedRepo {
30 /// Repository owner (user or organization).
31 pub owner: String,
32 /// Repository name.
33 pub name: String,
34 /// Primary programming language.
35 pub language: String,
36 /// Short description.
37 pub description: String,
38}
39
40impl CuratedRepo {
41 /// Returns the full repository name in "owner/name" format.
42 #[must_use]
43 pub fn full_name(&self) -> String {
44 format!("{}/{}", self.owner, self.name)
45 }
46}
47
48/// Parse embedded curated repositories from the compiled-in JSON.
49///
50/// # Returns
51///
52/// A vector of `CuratedRepo` structs parsed from the embedded JSON.
53///
54/// # Panics
55///
56/// Panics if the embedded JSON is malformed (should never happen in production).
57fn embedded_defaults() -> Vec<CuratedRepo> {
58 serde_json::from_str(EMBEDDED_REPOS).expect("embedded repos JSON is valid")
59}
60
61/// Fetch repositories from remote URL.
62///
63/// Network errors propagate; JSON parse failures fall back to embedded defaults.
64/// Shared HTTP client with a 30s timeout, consistent with the AI-layer clients.
65/// A static client benefits from connection pooling across repeated calls.
66static HTTP_CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
67 // LazyLock closures must return a value, not Result, so errors cannot be
68 // propagated. build() fails only on TLS initialisation errors; in that case
69 // a second builder with the same options would fail identically, so the
70 // fallback uses Client::default() (infallible). The timeout is absent on the
71 // fallback path, but if TLS is broken all outbound HTTP will fail regardless.
72 reqwest::Client::builder()
73 .timeout(std::time::Duration::from_secs(30))
74 .build()
75 .unwrap_or_else(|e| {
76 error!(%e, "Failed to build HTTP client, falling back to default");
77 reqwest::Client::default()
78 })
79});
80
81async fn fetch_from_remote(url: &str) -> crate::Result<Vec<CuratedRepo>> {
82 debug!("Fetching curated repositories from {}", url);
83 let response = HTTP_CLIENT.get(url).send().await?;
84 if let Ok(repos) = response.json::<Vec<CuratedRepo>>().await {
85 Ok(repos)
86 } else {
87 warn!("Failed to parse remote curated repositories, using embedded defaults");
88 Ok(embedded_defaults())
89 }
90}
91
92/// Fetch curated repositories from remote URL with TTL-based caching.
93///
94/// Fetches the curated repository list from a remote JSON file
95/// (configured via `cache.curated_repos_url`), caching the result with a TTL
96/// based on `cache.repo_ttl_hours`.
97///
98/// If the network fetch fails, falls back to embedded defaults with a warning.
99///
100/// # Returns
101///
102/// A vector of `CuratedRepo` structs.
103///
104/// # Errors
105///
106/// Returns an error if:
107/// - Configuration cannot be loaded
108pub async fn fetch() -> crate::Result<Vec<CuratedRepo>> {
109 let config = load_config()?;
110 let url = &config.cache.curated_repos_url;
111 let ttl = Duration::hours(config.cache.repo_ttl_hours);
112
113 // Try cache first
114 let cache: crate::cache::FileCacheImpl<Vec<CuratedRepo>> =
115 crate::cache::FileCacheImpl::new("repos", ttl);
116 if let Ok(Some(repos)) = cache.get("curated_repos").await {
117 debug!("Using cached curated repositories");
118 return Ok(repos);
119 }
120
121 // Fetch from remote and cache the result
122 let repos = fetch_from_remote(url).await?;
123 let _ = cache.set("curated_repos", &repos).await;
124 debug!("Fetched and cached {} curated repositories", repos.len());
125
126 Ok(repos)
127}
128
129/// Repository filter for fetching repositories.
130#[derive(Debug, Clone, Copy)]
131pub enum RepoFilter {
132 /// Include all repositories (curated and custom).
133 All,
134 /// Include only curated repositories.
135 Curated,
136 /// Include only custom repositories.
137 Custom,
138}
139
140/// Add filtered repositories to result, deduplicating by full name.
141fn add_filtered_repos(
142 repos: &mut Vec<CuratedRepo>,
143 seen: &mut std::collections::HashSet<String>,
144 new_repos: Vec<CuratedRepo>,
145) {
146 for repo in new_repos {
147 if seen.insert(repo.full_name()) {
148 repos.push(repo);
149 }
150 }
151}
152
153/// Fetch repositories based on filter and configuration.
154///
155/// Merges curated and custom repositories based on the filter and config settings.
156/// Deduplicates by full repository name.
157///
158/// # Arguments
159///
160/// * `filter` - Repository filter (All, Curated, or Custom)
161///
162/// # Returns
163///
164/// A vector of `CuratedRepo` structs.
165///
166/// # Errors
167///
168/// Returns an error if configuration cannot be loaded or repositories cannot be fetched.
169pub async fn fetch_all(filter: RepoFilter) -> crate::Result<Vec<CuratedRepo>> {
170 let config = load_config()?;
171 let mut repos = Vec::new();
172 let mut seen = std::collections::HashSet::new();
173
174 // Add curated repos if enabled and filter allows
175 match filter {
176 RepoFilter::All | RepoFilter::Curated => {
177 if config.repos.curated {
178 let curated = fetch().await?;
179 add_filtered_repos(&mut repos, &mut seen, curated);
180 }
181 }
182 RepoFilter::Custom => {}
183 }
184
185 // Add custom repos if filter allows
186 match filter {
187 RepoFilter::All | RepoFilter::Custom => {
188 let custom = custom::read_custom_repos()?;
189 add_filtered_repos(&mut repos, &mut seen, custom);
190 }
191 RepoFilter::Curated => {}
192 }
193
194 debug!(
195 "Fetched {} repositories with filter {:?}",
196 repos.len(),
197 filter
198 );
199 Ok(repos)
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn full_name_format() {
208 let repo = CuratedRepo {
209 owner: "owner".to_string(),
210 name: "repo".to_string(),
211 language: "Rust".to_string(),
212 description: "Test repository".to_string(),
213 };
214 assert_eq!(repo.full_name(), "owner/repo");
215 }
216
217 #[test]
218 fn embedded_defaults_returns_non_empty() {
219 let repos = embedded_defaults();
220 assert!(
221 !repos.is_empty(),
222 "embedded defaults should contain repositories"
223 );
224 }
225}