aptu_core/repos/
custom.rs1use 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#[must_use]
21pub fn repos_file_path() -> PathBuf {
22 config_dir().join("repos.toml")
23}
24
25#[derive(Debug, Serialize, Deserialize, Default)]
27pub struct CustomReposFile {
28 #[serde(default)]
30 pub repos: HashMap<String, CustomRepoEntry>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CustomRepoEntry {
36 pub owner: String,
38 pub name: String,
40 pub language: String,
42 pub description: String,
44 #[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#[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#[instrument(skip(repos))]
98pub fn write_custom_repos(repos: &[CuratedRepo]) -> crate::Result<()> {
99 let path = repos_file_path();
100
101 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#[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 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 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}