aptu_core/repos/
custom.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Custom repository management.
4//!
5//! Provides functionality to read, write, and validate custom repositories
6//! stored in TOML format at `~/.config/aptu/repos.toml`.
7
8use std::collections::HashMap;
9use std::fs;
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13use tracing::{debug, instrument};
14
15use crate::config::config_dir;
16use crate::error::AptuError;
17use crate::repos::CuratedRepo;
18
19/// Returns the path to the custom repositories file.
20#[must_use]
21pub fn repos_file_path() -> PathBuf {
22    config_dir().join("repos.toml")
23}
24
25/// Custom repositories file structure.
26#[derive(Debug, Serialize, Deserialize, Default)]
27pub struct CustomReposFile {
28    /// Map of repository full names to repository data.
29    #[serde(default)]
30    pub repos: HashMap<String, CustomRepoEntry>,
31}
32
33/// A custom repository entry in the TOML file.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CustomRepoEntry {
36    /// Repository owner.
37    pub owner: String,
38    /// Repository name.
39    pub name: String,
40    /// Primary programming language.
41    pub language: String,
42    /// Short description.
43    pub description: String,
44}
45
46impl From<CustomRepoEntry> for CuratedRepo {
47    fn from(entry: CustomRepoEntry) -> Self {
48        CuratedRepo {
49            owner: entry.owner,
50            name: entry.name,
51            language: entry.language,
52            description: entry.description,
53        }
54    }
55}
56
57/// Read custom repositories from TOML file.
58///
59/// Returns an empty vector if the file does not exist.
60///
61/// # Errors
62///
63/// Returns an error if the file exists but is invalid TOML.
64#[instrument]
65pub fn read_custom_repos() -> crate::Result<Vec<CuratedRepo>> {
66    let path = repos_file_path();
67
68    if !path.exists() {
69        debug!("Custom repos file does not exist: {:?}", path);
70        return Ok(Vec::new());
71    }
72
73    let content = fs::read_to_string(&path).map_err(|e| AptuError::Config {
74        message: format!("Failed to read custom repos file: {e}"),
75    })?;
76
77    let file: CustomReposFile = toml::from_str(&content).map_err(|e| AptuError::Config {
78        message: format!("Failed to parse custom repos TOML: {e}"),
79    })?;
80
81    let repos: Vec<CuratedRepo> = file.repos.into_values().map(CuratedRepo::from).collect();
82
83    debug!("Read {} custom repositories", repos.len());
84    Ok(repos)
85}
86
87/// Write custom repositories to TOML file.
88///
89/// Creates the config directory if it does not exist.
90///
91/// # Errors
92///
93/// Returns an error if the file cannot be written.
94#[instrument(skip(repos))]
95pub fn write_custom_repos(repos: &[CuratedRepo]) -> crate::Result<()> {
96    let path = repos_file_path();
97
98    // Ensure config directory exists
99    if let Some(parent) = path.parent() {
100        fs::create_dir_all(parent).map_err(|e| AptuError::Config {
101            message: format!("Failed to create config directory: {e}"),
102        })?;
103    }
104
105    let mut file = CustomReposFile::default();
106    for repo in repos {
107        file.repos.insert(
108            repo.full_name(),
109            CustomRepoEntry {
110                owner: repo.owner.clone(),
111                name: repo.name.clone(),
112                language: repo.language.clone(),
113                description: repo.description.clone(),
114            },
115        );
116    }
117
118    let content = toml::to_string_pretty(&file).map_err(|e| AptuError::Config {
119        message: format!("Failed to serialize custom repos: {e}"),
120    })?;
121
122    fs::write(&path, content).map_err(|e| AptuError::Config {
123        message: format!("Failed to write custom repos file: {e}"),
124    })?;
125
126    debug!("Wrote {} custom repositories", repos.len());
127    Ok(())
128}
129
130/// Validate and fetch metadata for a repository via GitHub API.
131///
132/// Fetches repository metadata from GitHub to ensure the repository exists
133/// and is accessible.
134///
135/// # Arguments
136///
137/// * `owner` - Repository owner
138/// * `name` - Repository name
139///
140/// # Returns
141///
142/// A `CuratedRepo` with metadata fetched from GitHub.
143///
144/// # Errors
145///
146/// Returns an error if the repository cannot be found or accessed.
147#[instrument]
148pub async fn validate_and_fetch_metadata(owner: &str, name: &str) -> crate::Result<CuratedRepo> {
149    use octocrab::Octocrab;
150
151    let client = Octocrab::builder().build()?;
152    let repo = client
153        .repos(owner, name)
154        .get()
155        .await
156        .map_err(|e| AptuError::GitHub {
157            message: format!("Failed to fetch repository metadata: {e}"),
158        })?;
159
160    let language = repo
161        .language
162        .map_or_else(|| "Unknown".to_string(), |v| v.to_string());
163    let description = repo.description.map_or_else(String::new, |v| v.clone());
164
165    Ok(CuratedRepo {
166        owner: owner.to_string(),
167        name: name.to_string(),
168        language,
169        description,
170    })
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_custom_repos_toml_roundtrip() {
179        let repos = vec![
180            CuratedRepo {
181                owner: "test".to_string(),
182                name: "repo1".to_string(),
183                language: "Rust".to_string(),
184                description: "Test repo 1".to_string(),
185            },
186            CuratedRepo {
187                owner: "test".to_string(),
188                name: "repo2".to_string(),
189                language: "Python".to_string(),
190                description: "Test repo 2".to_string(),
191            },
192        ];
193
194        // Serialize to TOML
195        let mut file = CustomReposFile::default();
196        for repo in &repos {
197            file.repos.insert(
198                repo.full_name(),
199                CustomRepoEntry {
200                    owner: repo.owner.clone(),
201                    name: repo.name.clone(),
202                    language: repo.language.clone(),
203                    description: repo.description.clone(),
204                },
205            );
206        }
207
208        let toml_str = toml::to_string_pretty(&file).expect("should serialize");
209
210        // Deserialize from TOML
211        let parsed: CustomReposFile = toml::from_str(&toml_str).expect("should deserialize");
212        let result: Vec<CuratedRepo> = parsed.repos.into_values().map(CuratedRepo::from).collect();
213
214        assert_eq!(result.len(), 2);
215        let full_names: std::collections::HashSet<_> = result
216            .iter()
217            .map(super::super::CuratedRepo::full_name)
218            .collect();
219        assert!(full_names.contains("test/repo1"));
220        assert!(full_names.contains("test/repo2"));
221    }
222}