1use serde::{Deserialize, Serialize};
6
7use crate::error::{Error, Result};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct LabelConfig {
14 pub name: String,
16
17 pub color: String,
19
20 pub description: Option<String>,
22
23 #[serde(default)]
25 pub aliases: Vec<String>,
26
27 #[serde(default)]
29 pub delete: bool,
30}
31
32impl LabelConfig {
33 pub fn new(name: String, color: String) -> Result<Self> {
42 let label = Self {
43 name,
44 color,
45 description: None,
46 aliases: Vec::new(),
47 delete: false,
48 };
49
50 label.validate()?;
51 Ok(label)
52 }
53
54 pub fn validate(&self) -> Result<()> {
60 if self.name.trim().is_empty() {
61 return Err(Error::label_validation("Label name cannot be empty"));
62 }
63
64 if !self.color.starts_with('#') {
65 return Err(Error::InvalidLabelColor(format!(
66 "Color must start with #: {}",
67 self.color
68 )));
69 }
70
71 let normalized_color = Self::normalize_color(&self.color);
72 if !is_valid_hex_color(&normalized_color) {
73 return Err(Error::InvalidLabelColor(self.color.clone()));
74 }
75
76 Ok(())
77 }
78
79 pub fn normalize_color(color: &str) -> String {
81 color.trim_start_matches('#').to_lowercase()
82 }
83}
84
85#[derive(Debug, Clone)]
89pub struct SyncConfig {
90 pub access_token: String,
92
93 pub repository: String,
95
96 pub dry_run: bool,
98
99 pub allow_added_labels: bool,
101
102 pub labels: Option<Vec<LabelConfig>>,
104}
105
106impl SyncConfig {
107 pub fn validate(&self) -> Result<()> {
114 if self.access_token.trim().is_empty() {
115 return Err(Error::config_validation("Access token is required"));
116 }
117
118 if !is_valid_repository_format(&self.repository) {
119 return Err(Error::InvalidRepositoryFormat(self.repository.clone()));
120 }
121
122 if let Some(labels) = &self.labels {
123 for label in labels {
124 label.validate()?;
125 }
126 }
127
128 Ok(())
129 }
130
131 pub fn parse_repository(&self) -> Result<(String, String)> {
133 let parts: Vec<&str> = self.repository.split('/').collect();
134 if parts.len() != 2 {
135 return Err(Error::InvalidRepositoryFormat(self.repository.clone()));
136 }
137
138 Ok((parts[0].to_string(), parts[1].to_string()))
139 }
140}
141
142pub fn default_labels() -> Vec<LabelConfig> {
146 vec![
147 LabelConfig {
148 name: "bug".to_string(),
149 color: "#d73a4a".to_string(),
150 description: Some("Something isn't working".to_string()),
151 aliases: vec!["defect".to_string()],
152 delete: false,
153 },
154 LabelConfig {
155 name: "enhancement".to_string(),
156 color: "#a2eeef".to_string(),
157 description: Some("New feature or request".to_string()),
158 aliases: vec!["feature".to_string()],
159 delete: false,
160 },
161 LabelConfig {
162 name: "documentation".to_string(),
163 color: "#0075ca".to_string(),
164 description: Some("Improvements or additions to documentation".to_string()),
165 aliases: vec!["docs".to_string()],
166 delete: false,
167 },
168 LabelConfig {
169 name: "duplicate".to_string(),
170 color: "#cfd3d7".to_string(),
171 description: Some("This issue or pull request already exists".to_string()),
172 aliases: Vec::new(),
173 delete: false,
174 },
175 LabelConfig {
176 name: "good first issue".to_string(),
177 color: "#7057ff".to_string(),
178 description: Some("Good for newcomers".to_string()),
179 aliases: vec!["beginner-friendly".to_string()],
180 delete: false,
181 },
182 LabelConfig {
183 name: "help wanted".to_string(),
184 color: "#008672".to_string(),
185 description: Some("Extra attention is needed".to_string()),
186 aliases: Vec::new(),
187 delete: false,
188 },
189 ]
190}
191
192pub fn load_labels_from_json<P: AsRef<std::path::Path>>(path: P) -> Result<Vec<LabelConfig>> {
200 let content = std::fs::read_to_string(path)?;
201 let labels: Vec<LabelConfig> = serde_json::from_str(&content)?;
202
203 for label in &labels {
205 label.validate()?;
206 }
207
208 Ok(labels)
209}
210
211pub fn load_labels_from_yaml<P: AsRef<std::path::Path>>(path: P) -> Result<Vec<LabelConfig>> {
219 let content = std::fs::read_to_string(path)?;
220 let labels: Vec<LabelConfig> = serde_yaml::from_str(&content)?;
221
222 for label in &labels {
224 label.validate()?;
225 }
226
227 Ok(labels)
228}
229
230fn is_valid_hex_color(color: &str) -> bool {
238 if color.len() != 6 {
239 return false;
240 }
241
242 color.chars().all(|c| c.is_ascii_hexdigit())
243}
244
245fn is_valid_repository_format(repo: &str) -> bool {
253 let parts: Vec<&str> = repo.split('/').collect();
254 parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty()
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_valid_hex_color() {
263 assert!(is_valid_hex_color("ff0000"));
264 assert!(is_valid_hex_color("00FF00"));
265 assert!(is_valid_hex_color("123abc"));
266
267 assert!(!is_valid_hex_color("ff00")); assert!(!is_valid_hex_color("ff0000x")); assert!(!is_valid_hex_color("#ff0000")); }
271
272 #[test]
273 fn test_valid_repository_format() {
274 assert!(is_valid_repository_format("owner/repo"));
275 assert!(is_valid_repository_format("org/project"));
276
277 assert!(!is_valid_repository_format("repo")); assert!(!is_valid_repository_format("/repo")); assert!(!is_valid_repository_format("owner/")); assert!(!is_valid_repository_format("owner/repo/sub")); }
282
283 #[test]
284 fn test_label_config_validation() {
285 let valid_with_hash = LabelConfig::new("test".to_string(), "#ff0000".to_string()).unwrap();
287 assert_eq!(valid_with_hash.name, "test");
288 assert_eq!(valid_with_hash.color, "#ff0000");
289
290 let invalid_no_hash = LabelConfig::new("test".to_string(), "ff0000".to_string());
292 assert!(invalid_no_hash.is_err());
293
294 let invalid_color = LabelConfig::new("test".to_string(), "invalid".to_string());
296 assert!(invalid_color.is_err());
297
298 let invalid_hex_with_hash = LabelConfig::new("test".to_string(), "#invalid".to_string());
300 assert!(invalid_hex_with_hash.is_err());
301 }
302}