Skip to main content

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    /// DCO sign-off on commits (overrides global config if set).
45    #[serde(default)]
46    pub dco_signoff: Option<bool>,
47}
48
49impl From<CustomRepoEntry> for CuratedRepo {
50    fn from(entry: CustomRepoEntry) -> Self {
51        CuratedRepo {
52            owner: entry.owner,
53            name: entry.name,
54            language: entry.language,
55            description: entry.description,
56        }
57    }
58}
59
60/// Read custom repositories from TOML file.
61///
62/// Returns an empty vector if the file does not exist.
63///
64/// # Errors
65///
66/// Returns an error if the file exists but is invalid TOML.
67#[instrument]
68pub fn read_custom_repos() -> crate::Result<Vec<CuratedRepo>> {
69    let path = repos_file_path();
70
71    if !path.exists() {
72        debug!("Custom repos file does not exist: {:?}", path);
73        return Ok(Vec::new());
74    }
75
76    let content = fs::read_to_string(&path).map_err(|e| AptuError::Config {
77        message: format!("Failed to read custom repos file: {e}"),
78    })?;
79
80    let file: CustomReposFile = toml::from_str(&content).map_err(|e| AptuError::Config {
81        message: format!("Failed to parse custom repos TOML: {e}"),
82    })?;
83
84    let repos: Vec<CuratedRepo> = file.repos.into_values().map(CuratedRepo::from).collect();
85
86    debug!("Read {} custom repositories", repos.len());
87    Ok(repos)
88}
89
90/// Write custom repositories to TOML file.
91///
92/// Creates the config directory if it does not exist.
93///
94/// # Errors
95///
96/// Returns an error if the file cannot be written.
97#[instrument(skip(repos))]
98pub fn write_custom_repos(repos: &[CuratedRepo]) -> crate::Result<()> {
99    let path = repos_file_path();
100
101    // Ensure config directory exists
102    if let Some(parent) = path.parent() {
103        fs::create_dir_all(parent).map_err(|e| AptuError::Config {
104            message: format!("Failed to create config directory: {e}"),
105        })?;
106    }
107
108    let mut file = CustomReposFile::default();
109    for repo in repos {
110        file.repos.insert(
111            repo.full_name(),
112            CustomRepoEntry {
113                owner: repo.owner.clone(),
114                name: repo.name.clone(),
115                language: repo.language.clone(),
116                description: repo.description.clone(),
117                dco_signoff: None,
118            },
119        );
120    }
121
122    let content = toml::to_string_pretty(&file).map_err(|e| AptuError::Config {
123        message: format!("Failed to serialize custom repos: {e}"),
124    })?;
125
126    fs::write(&path, content).map_err(|e| AptuError::Config {
127        message: format!("Failed to write custom repos file: {e}"),
128    })?;
129
130    debug!("Wrote {} custom repositories", repos.len());
131    Ok(())
132}
133
134/// Validate and fetch metadata for a repository via GitHub API.
135///
136/// Fetches repository metadata from GitHub to ensure the repository exists
137/// and is accessible.
138///
139/// # Arguments
140///
141/// * `owner` - Repository owner
142/// * `name` - Repository name
143///
144/// # Returns
145///
146/// A `CuratedRepo` with metadata fetched from GitHub.
147///
148/// # Errors
149///
150/// Returns an error if the repository cannot be found or accessed.
151#[instrument]
152pub async fn validate_and_fetch_metadata(owner: &str, name: &str) -> crate::Result<CuratedRepo> {
153    use octocrab::Octocrab;
154
155    let client = Octocrab::builder().build()?;
156    let repo = client
157        .repos(owner, name)
158        .get()
159        .await
160        .map_err(|e| AptuError::GitHub {
161            message: format!("Failed to fetch repository metadata: {e}"),
162        })?;
163
164    let language = repo
165        .language
166        .map_or_else(|| "Unknown".to_string(), |v| v.to_string());
167    let description = repo.description.map_or_else(String::new, |v| v.clone());
168
169    Ok(CuratedRepo {
170        owner: owner.to_string(),
171        name: name.to_string(),
172        language,
173        description,
174    })
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_custom_repos_toml_roundtrip() {
183        let repos = vec![
184            CuratedRepo {
185                owner: "test".to_string(),
186                name: "repo1".to_string(),
187                language: "Rust".to_string(),
188                description: "Test repo 1".to_string(),
189            },
190            CuratedRepo {
191                owner: "test".to_string(),
192                name: "repo2".to_string(),
193                language: "Python".to_string(),
194                description: "Test repo 2".to_string(),
195            },
196        ];
197
198        // Serialize to TOML
199        let mut file = CustomReposFile::default();
200        for repo in &repos {
201            file.repos.insert(
202                repo.full_name(),
203                CustomRepoEntry {
204                    owner: repo.owner.clone(),
205                    name: repo.name.clone(),
206                    language: repo.language.clone(),
207                    description: repo.description.clone(),
208                    dco_signoff: None,
209                },
210            );
211        }
212
213        let toml_str = toml::to_string_pretty(&file).expect("should serialize");
214
215        // Deserialize from TOML
216        let parsed: CustomReposFile = toml::from_str(&toml_str).expect("should deserialize");
217        let result: Vec<CuratedRepo> = parsed.repos.into_values().map(CuratedRepo::from).collect();
218
219        assert_eq!(result.len(), 2);
220        let full_names: std::collections::HashSet<_> = result
221            .iter()
222            .map(super::super::CuratedRepo::full_name)
223            .collect();
224        assert!(full_names.contains("test/repo1"));
225        assert!(full_names.contains("test/repo2"));
226    }
227}