aptu_core/repos/
custom.rs1use 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#[cfg(not(target_arch = "wasm32"))]
25#[must_use]
26pub fn repos_file_path() -> PathBuf {
27 config_dir().join("repos.toml")
28}
29
30#[derive(Debug, Serialize, Deserialize, Default)]
32pub struct CustomReposFile {
33 #[serde(default)]
35 pub repos: HashMap<String, CustomRepoEntry>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CustomRepoEntry {
41 pub owner: String,
43 pub name: String,
45 pub language: String,
47 pub description: String,
49 #[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#[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#[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 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#[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 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 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}