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
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}