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, 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.
64async 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
75/// Fetch curated repositories from remote URL with TTL-based caching.
76///
77/// Fetches the curated repository list from a remote JSON file
78/// (configured via `cache.curated_repos_url`), caching the result with a TTL
79/// based on `cache.repo_ttl_hours`.
80///
81/// If the network fetch fails, falls back to embedded defaults with a warning.
82///
83/// # Returns
84///
85/// A vector of `CuratedRepo` structs.
86///
87/// # Errors
88///
89/// Returns an error if:
90/// - Configuration cannot be loaded
91pub 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    // Try cache first
97    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    // Fetch from remote and cache the result
105    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/// Repository filter for fetching repositories.
113#[derive(Debug, Clone, Copy)]
114pub enum RepoFilter {
115    /// Include all repositories (curated and custom).
116    All,
117    /// Include only curated repositories.
118    Curated,
119    /// Include only custom repositories.
120    Custom,
121}
122
123/// Add filtered repositories to result, deduplicating by full name.
124fn 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
136/// Fetch repositories based on filter and configuration.
137///
138/// Merges curated and custom repositories based on the filter and config settings.
139/// Deduplicates by full repository name.
140///
141/// # Arguments
142///
143/// * `filter` - Repository filter (All, Curated, or Custom)
144///
145/// # Returns
146///
147/// A vector of `CuratedRepo` structs.
148///
149/// # Errors
150///
151/// Returns an error if configuration cannot be loaded or repositories cannot be fetched.
152pub 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    // Add curated repos if enabled and filter allows
158    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    // Add custom repos if filter allows
169    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}