gitee_cli_rs/api/
labels.rs1use 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
22pub 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
34fn 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 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 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}