gh_labeler/
config.rs

1//! Configuration Management
2//!
3//! Label configuration and application settings management
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::{Error, Result};
8
9/// Label Configuration
10///
11/// Represents a GitHub label definition
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct LabelConfig {
14    /// Label name
15    pub name: String,
16
17    /// Label color (6-digit hex code with # prefix required)
18    pub color: String,
19
20    /// Label description (optional)
21    pub description: Option<String>,
22
23    /// Aliases for this label
24    #[serde(default)]
25    pub aliases: Vec<String>,
26
27    /// Deletion flag (if true, delete this label)
28    #[serde(default)]
29    pub delete: bool,
30}
31
32impl LabelConfig {
33    /// Create a new label configuration
34    ///
35    /// # Arguments
36    /// - `name`: Label name
37    /// - `color`: Label color (6-digit hex code with # prefix required)
38    ///
39    /// # Errors
40    /// Returns an error if the color format is invalid
41    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    /// Validate label configuration
55    ///
56    /// # Errors
57    /// - If the name is empty
58    /// - If the color format is invalid
59    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    /// Normalize color (remove # and convert to lowercase)
80    pub fn normalize_color(color: &str) -> String {
81        color.trim_start_matches('#').to_lowercase()
82    }
83}
84
85/// Sync Configuration
86///
87/// gh-labeler execution configuration
88#[derive(Debug, Clone)]
89pub struct SyncConfig {
90    /// GitHub access token
91    pub access_token: String,
92
93    /// Target repository (owner/repo format)
94    pub repository: String,
95
96    /// Dry-run mode (don't make actual changes)
97    pub dry_run: bool,
98
99    /// Allow additional labels (preserve labels not in configuration)
100    pub allow_added_labels: bool,
101
102    /// Label configuration (use default labels if None)
103    pub labels: Option<Vec<LabelConfig>>,
104}
105
106impl SyncConfig {
107    /// Validate configuration
108    ///
109    /// # Errors
110    /// - If repository format is invalid
111    /// - If access token is empty
112    /// - If there are issues with label configuration
113    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    /// Get repository owner and name
132    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
142/// Generate default label configuration
143///
144/// Returns GitHub's standard label set
145pub 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
192/// Load label configuration from JSON file
193///
194/// # Arguments
195/// - `path`: Path to the configuration file
196///
197/// # Errors
198/// If file reading or parsing fails
199pub 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    // Validate all labels
204    for label in &labels {
205        label.validate()?;
206    }
207
208    Ok(labels)
209}
210
211/// Load label configuration from YAML file
212///
213/// # Arguments
214/// - `path`: Path to the configuration file
215///
216/// # Errors
217/// If file reading or parsing fails
218pub 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    // Validate all labels
223    for label in &labels {
224        label.validate()?;
225    }
226
227    Ok(labels)
228}
229
230/// Validate hex color code
231///
232/// # Arguments
233/// - `color`: Color code (6-digit hex without #)
234///
235/// # Returns
236/// True if valid
237fn 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
245/// Validate repository format
246///
247/// # Arguments
248/// - `repo`: Repository name (owner/repo format)
249///
250/// # Returns
251/// True if valid
252fn 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")); // Too short
268        assert!(!is_valid_hex_color("ff0000x")); // Invalid character
269        assert!(!is_valid_hex_color("#ff0000")); // With #
270    }
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")); // No slash
278        assert!(!is_valid_repository_format("/repo")); // No owner
279        assert!(!is_valid_repository_format("owner/")); // No repo name
280        assert!(!is_valid_repository_format("owner/repo/sub")); // Too many parts
281    }
282
283    #[test]
284    fn test_label_config_validation() {
285        // # prefix is now required
286        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        // Without # should fail
291        let invalid_no_hash = LabelConfig::new("test".to_string(), "ff0000".to_string());
292        assert!(invalid_no_hash.is_err());
293
294        // Invalid color
295        let invalid_color = LabelConfig::new("test".to_string(), "invalid".to_string());
296        assert!(invalid_color.is_err());
297
298        // Invalid hex with # should also fail
299        let invalid_hex_with_hash = LabelConfig::new("test".to_string(), "#invalid".to_string());
300        assert!(invalid_hex_with_hash.is_err());
301    }
302}