Skip to main content

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
21#[cfg(not(target_arch = "wasm32"))]
22use crate::cache::FileCache;
23#[cfg(not(target_arch = "wasm32"))]
24use crate::config::load_config;
25
26/// Embedded curated repositories as fallback when network fetch fails.
27const EMBEDDED_REPOS: &str = include_str!("../../data/curated-repos.json");
28
29/// A curated repository for contribution.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CuratedRepo {
32    /// Repository owner (user or organization).
33    pub owner: String,
34    /// Repository name.
35    pub name: String,
36    /// Primary programming language.
37    pub language: String,
38    /// Short description.
39    pub description: String,
40}
41
42impl CuratedRepo {
43    /// Returns the full repository name in "owner/name" format.
44    #[must_use]
45    pub fn full_name(&self) -> String {
46        format!("{}/{}", self.owner, self.name)
47    }
48}
49
50/// Parse embedded curated repositories from the compiled-in JSON.
51///
52/// # Returns
53///
54/// A vector of `CuratedRepo` structs parsed from the embedded JSON.
55///
56/// # Panics
57///
58/// Panics if the embedded JSON is malformed (should never happen in production).
59fn embedded_defaults() -> Vec<CuratedRepo> {
60    serde_json::from_str(EMBEDDED_REPOS).expect("embedded repos JSON is valid")
61}
62
63/// Fetch repositories from remote URL.
64///
65/// Network errors propagate; JSON parse failures fall back to embedded defaults.
66/// Shared HTTP client with a 30s timeout, consistent with the AI-layer clients.
67/// A static client benefits from connection pooling across repeated calls.
68#[cfg(not(target_arch = "wasm32"))]
69static HTTP_CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
70    // LazyLock closures must return a value, not Result, so errors cannot be
71    // propagated. build() fails only on TLS initialisation errors; in that case
72    // a second builder with the same options would fail identically, so the
73    // fallback uses Client::default() (infallible). The timeout is absent on the
74    // fallback path, but if TLS is broken all outbound HTTP will fail regardless.
75    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/// Fetch curated repositories from remote URL with TTL-based caching.
97///
98/// Fetches the curated repository list from a remote JSON file
99/// (configured via `cache.curated_repos_url`), caching the result with a TTL
100/// based on `cache.repo_ttl_hours`.
101///
102/// If the network fetch fails, falls back to embedded defaults with a warning.
103///
104/// # Returns
105///
106/// A vector of `CuratedRepo` structs.
107///
108/// # Errors
109///
110/// Returns an error if:
111/// - Configuration cannot be loaded
112#[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    // Try cache first
119    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    // Fetch from remote and cache the result
127    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/// Repository filter for fetching repositories.
135#[derive(Debug, Clone, Copy)]
136pub enum RepoFilter {
137    /// Include all repositories (curated and custom).
138    All,
139    /// Include only curated repositories.
140    Curated,
141    /// Include only custom repositories.
142    Custom,
143}
144
145/// Add filtered repositories to result, deduplicating by full name.
146fn 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/// Fetch repositories based on filter and configuration.
159///
160/// Merges curated and custom repositories based on the filter and config settings.
161/// Deduplicates by full repository name.
162///
163/// # Arguments
164///
165/// * `filter` - Repository filter (All, Curated, or Custom)
166///
167/// # Returns
168///
169/// A vector of `CuratedRepo` structs.
170///
171/// # Errors
172///
173/// Returns an error if configuration cannot be loaded or repositories cannot be fetched.
174#[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    // Add curated repos if enabled and filter allows
181    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    // Add custom repos if filter allows
192    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}