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::{self, CacheEntry};
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 curated repositories from remote URL with TTL-based caching.
62///
63/// Fetches the curated repository list from a remote JSON file
64/// (configured via `cache.curated_repos_url`), caching the result with a TTL
65/// based on `cache.repo_ttl_hours`.
66///
67/// If the network fetch fails, falls back to embedded defaults with a warning.
68///
69/// # Returns
70///
71/// A vector of `CuratedRepo` structs.
72///
73/// # Errors
74///
75/// Returns an error if:
76/// - Configuration cannot be loaded
77pub 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    // Try cache first
83    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    // Fetch from remote
92    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    // Cache the result
101    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/// Repository filter for fetching repositories.
109#[derive(Debug, Clone, Copy)]
110pub enum RepoFilter {
111    /// Include all repositories (curated and custom).
112    All,
113    /// Include only curated repositories.
114    Curated,
115    /// Include only custom repositories.
116    Custom,
117}
118
119/// Fetch repositories based on filter and configuration.
120///
121/// Merges curated and custom repositories based on the filter and config settings.
122/// Deduplicates by full repository name.
123///
124/// # Arguments
125///
126/// * `filter` - Repository filter (All, Curated, or Custom)
127///
128/// # Returns
129///
130/// A vector of `CuratedRepo` structs.
131///
132/// # Errors
133///
134/// Returns an error if configuration cannot be loaded or repositories cannot be fetched.
135pub 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    // Add curated repos if enabled and filter allows
141    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    // Add custom repos if filter allows
156    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}