Skip to main content

gitee_cli_rs/api/
labels.rs

1use super::client::Client;
2use crate::api::StateChange;
3use crate::error::{GiteeError, Result};
4use crate::models::Label;
5use crate::repo::Repo;
6
7pub struct Labels<'a> {
8    client: &'a Client,
9    repo: &'a Repo,
10}
11
12pub struct CreateLabel<'a> {
13    pub name: &'a str,
14    pub color: &'a str,
15}
16
17pub struct EditLabel<'a> {
18    pub name: Option<&'a str>,
19    pub color: Option<&'a str>,
20}
21
22/// Strip one leading '#', require exactly 6 hex chars, lowercase.
23pub fn normalize_color(s: &str) -> Result<String> {
24    let trimmed = s.trim();
25    let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
26    if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
27        return Err(GiteeError::Usage(format!(
28            "invalid color '{s}': must be exactly 6 hex digits (with optional leading #)"
29        )));
30    }
31    Ok(hex.to_ascii_lowercase())
32}
33
34/// Compare two colors, treating missing colors as matching the requested
35/// one (Gitee sometimes omits `color` on existing labels).
36fn colors_match(existing: Option<&str>, requested: &str) -> bool {
37    match existing {
38        Some(c) => c.eq_ignore_ascii_case(requested),
39        None => true,
40    }
41}
42
43impl Labels<'_> {
44    pub(crate) fn new<'a>(client: &'a Client, repo: &'a Repo) -> Labels<'a> {
45        Labels { client, repo }
46    }
47
48    /// GET /repos/{owner}/{repo}/labels returns a plain array. The swagger
49    /// documents no `page`/`per_page` params, so we fetch the full list with
50    /// `get` and truncate to `limit` client-side.
51    pub fn list(&self, limit: usize) -> Result<Vec<Label>> {
52        let o = self.repo.owner.as_str();
53        let r = self.repo.name.as_str();
54        let path = format!("/repos/{o}/{r}/labels");
55        let mut items: Vec<Label> = self.client.get(&path, &[])?;
56        if items.len() > limit {
57            items.truncate(limit);
58        }
59        Ok(items)
60    }
61
62    pub fn create(&self, req: &CreateLabel<'_>) -> Result<Label> {
63        let o = self.repo.owner.as_str();
64        let r = self.repo.name.as_str();
65        let color = normalize_color(req.color)?;
66        let form = [("name", req.name), ("color", color.as_str())];
67        self.client
68            .post(&format!("/repos/{o}/{r}/labels"), &form)
69    }
70
71    /// Idempotent create: list existing labels first. If a label with the
72    /// same name already exists with the requested color, return
73    /// `StateChange::Already(label)` and exit 0. If it exists with a
74    /// different color, return a Usage error suggesting `label edit`. If it
75    /// doesn't exist, create it and return `StateChange::Changed(label)`.
76    pub fn create_idempotent(&self, req: &CreateLabel<'_>) -> Result<StateChange<Label>> {
77        let o = self.repo.owner.as_str();
78        let r = self.repo.name.as_str();
79        let requested = normalize_color(req.color)?;
80        let existing: Vec<Label> = self.client.get(&format!("/repos/{o}/{r}/labels"), &[])?;
81        if let Some(found) = existing.iter().find(|l| l.name == req.name) {
82            if colors_match(found.color.as_deref(), &requested) {
83                return Ok(StateChange::Already(found.clone()));
84            }
85            return Err(GiteeError::Usage(format!(
86                "label '{}' already exists with color '{}'. Use `gitee label edit {} --color {}` to change it.",
87                req.name,
88                found.color.as_deref().unwrap_or("(none)"),
89                req.name,
90                requested,
91            )));
92        }
93        let form = [("name", req.name), ("color", requested.as_str())];
94        let label: Label = self
95            .client
96            .post(&format!("/repos/{o}/{r}/labels"), &form)?;
97        Ok(StateChange::Changed(label))
98    }
99
100    pub fn edit(&self, original_name: &str, req: &EditLabel<'_>) -> Result<Label> {
101        let o = self.repo.owner.as_str();
102        let r = self.repo.name.as_str();
103        let mut f: Vec<(&str, String)> = Vec::new();
104        if let Some(name) = req.name {
105            f.push(("name", name.to_string()));
106        }
107        if let Some(color) = req.color {
108            f.push(("color", normalize_color(color)?));
109        }
110        let form = Client::str_refs(&f);
111        self.client
112            .patch(&format!("/repos/{o}/{r}/labels/{original_name}"), &form)
113    }
114
115    pub fn delete(&self, name: &str) -> Result<()> {
116        let o = self.repo.owner.as_str();
117        let r = self.repo.name.as_str();
118        self.client
119            .delete_ok(&format!("/repos/{o}/{r}/labels/{name}"))
120    }
121}
122
123#[cfg(test)]
124mod color_tests {
125    use super::normalize_color;
126
127    #[test]
128    fn accepts_six_hex_with_or_without_hash() {
129        assert_eq!(normalize_color("ff0000").unwrap(), "ff0000");
130        assert_eq!(normalize_color("#FF0000").unwrap(), "ff0000");
131        assert_eq!(normalize_color("#aabbcc").unwrap(), "aabbcc");
132    }
133
134    #[test]
135    fn rejects_bad_length_and_non_hex() {
136        assert!(normalize_color("fff").is_err());
137        assert!(normalize_color("ff000").is_err());
138        assert!(normalize_color("ff00000").is_err());
139        assert!(normalize_color("gggggg").is_err());
140        assert!(normalize_color("#12g456").is_err());
141    }
142}