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