| 1 | |
| 2 | |
| 3 | |
| 4 | use crate::error::{Error, Result}; |
| 5 | use crate::model::Id; |
| 6 | |
| 7 | |
| 8 | pub const MAX_TITLE: usize = 256; |
| 9 | |
| 10 | pub const MAX_BODY: usize = 65536; |
| 11 | |
| 12 | pub const MAX_LABEL_NAME: usize = 50; |
| 13 | |
| 14 | pub const MAX_ASSIGNEES: usize = 10; |
| 15 | |
| 16 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 17 | pub enum IssueState { |
| 18 | Open, |
| 19 | Closed, |
| 20 | } |
| 21 | |
| 22 | impl IssueState { |
| 23 | pub fn as_str(&self) -> &'static str { |
| 24 | match self { |
| 25 | IssueState::Open => "open", |
| 26 | IssueState::Closed => "closed", |
| 27 | } |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | impl std::str::FromStr for IssueState { |
| 32 | type Err = Error; |
| 33 | |
| 34 | fn from_str(s: &str) -> Result<Self> { |
| 35 | match s { |
| 36 | "open" => Ok(IssueState::Open), |
| 37 | "closed" => Ok(IssueState::Closed), |
| 38 | other => Err(Error::Invalid(format!("issue state {other:?}"))), |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 44 | pub enum StateReason { |
| 45 | Completed, |
| 46 | NotPlanned, |
| 47 | } |
| 48 | |
| 49 | impl StateReason { |
| 50 | pub fn as_str(&self) -> &'static str { |
| 51 | match self { |
| 52 | StateReason::Completed => "completed", |
| 53 | StateReason::NotPlanned => "not_planned", |
| 54 | } |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | impl std::str::FromStr for StateReason { |
| 59 | type Err = Error; |
| 60 | |
| 61 | fn from_str(s: &str) -> Result<Self> { |
| 62 | match s { |
| 63 | "completed" => Ok(StateReason::Completed), |
| 64 | "not_planned" => Ok(StateReason::NotPlanned), |
| 65 | other => Err(Error::Invalid(format!("state reason {other:?}"))), |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | #[derive(Debug, Clone, PartialEq)] |
| 71 | pub struct Issue { |
| 72 | pub id: Id, |
| 73 | pub repo_id: Id, |
| 74 | pub number: i64, |
| 75 | pub title: String, |
| 76 | pub body: String, |
| 77 | pub state: IssueState, |
| 78 | pub state_reason: Option<StateReason>, |
| 79 | pub author_id: Id, |
| 80 | pub milestone_id: Option<Id>, |
| 81 | pub comment_count: i64, |
| 82 | pub created_at: String, |
| 83 | pub updated_at: String, |
| 84 | pub closed_at: Option<String>, |
| 85 | |
| 86 | pub locked: bool, |
| 87 | |
| 88 | pub lock_reason: Option<String>, |
| 89 | } |
| 90 | |
| 91 | |
| 92 | pub const LOCK_REASONS: &[&str] = &["off_topic", "too_heated", "resolved", "spam"]; |
| 93 | |
| 94 | #[derive(Debug, Clone, PartialEq)] |
| 95 | pub struct Comment { |
| 96 | pub id: Id, |
| 97 | pub issue_id: Id, |
| 98 | pub author_id: Id, |
| 99 | pub body: String, |
| 100 | pub created_at: String, |
| 101 | pub updated_at: String, |
| 102 | pub edited: bool, |
| 103 | } |
| 104 | |
| 105 | #[derive(Debug, Clone, PartialEq)] |
| 106 | pub struct IssueEvent { |
| 107 | pub id: Id, |
| 108 | pub issue_id: Id, |
| 109 | |
| 110 | pub actor_id: Option<Id>, |
| 111 | pub kind: String, |
| 112 | pub data: serde_json::Value, |
| 113 | pub created_at: String, |
| 114 | } |
| 115 | |
| 116 | #[derive(Debug, Clone, PartialEq)] |
| 117 | pub struct Label { |
| 118 | pub id: Id, |
| 119 | pub repo_id: Id, |
| 120 | pub name: String, |
| 121 | |
| 122 | pub color: String, |
| 123 | pub description: String, |
| 124 | } |
| 125 | |
| 126 | #[derive(Debug, Clone, PartialEq)] |
| 127 | pub struct Milestone { |
| 128 | pub id: Id, |
| 129 | pub repo_id: Id, |
| 130 | pub title: String, |
| 131 | pub description: String, |
| 132 | |
| 133 | pub due_on: Option<String>, |
| 134 | pub state: IssueState, |
| 135 | pub created_at: String, |
| 136 | pub closed_at: Option<String>, |
| 137 | |
| 138 | pub open_issues: i64, |
| 139 | pub closed_issues: i64, |
| 140 | } |
| 141 | |
| 142 | #[derive(Debug, Clone, PartialEq)] |
| 143 | pub struct IssueFilter { |
| 144 | pub state: Option<IssueState>, |
| 145 | |
| 146 | pub label: Option<String>, |
| 147 | |
| 148 | pub milestone: Option<String>, |
| 149 | |
| 150 | pub author: Option<String>, |
| 151 | |
| 152 | pub assignee: Option<String>, |
| 153 | |
| 154 | pub query: Option<String>, |
| 155 | pub sort: IssueSort, |
| 156 | |
| 157 | pub page: u32, |
| 158 | pub per_page: u32, |
| 159 | } |
| 160 | |
| 161 | impl Default for IssueFilter { |
| 162 | fn default() -> Self { |
| 163 | IssueFilter { |
| 164 | state: Some(IssueState::Open), |
| 165 | label: None, |
| 166 | milestone: None, |
| 167 | author: None, |
| 168 | assignee: None, |
| 169 | query: None, |
| 170 | sort: IssueSort::Newest, |
| 171 | page: 1, |
| 172 | per_page: 25, |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 178 | pub enum IssueSort { |
| 179 | #[default] |
| 180 | Newest, |
| 181 | Oldest, |
| 182 | RecentlyUpdated, |
| 183 | } |
| 184 | |
| 185 | impl IssueSort { |
| 186 | |
| 187 | pub fn as_str(&self) -> &'static str { |
| 188 | match self { |
| 189 | IssueSort::Newest => "newest", |
| 190 | IssueSort::Oldest => "oldest", |
| 191 | IssueSort::RecentlyUpdated => "updated", |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | impl std::str::FromStr for IssueSort { |
| 197 | type Err = Error; |
| 198 | |
| 199 | fn from_str(s: &str) -> Result<Self> { |
| 200 | match s { |
| 201 | "newest" => Ok(IssueSort::Newest), |
| 202 | "oldest" => Ok(IssueSort::Oldest), |
| 203 | "updated" => Ok(IssueSort::RecentlyUpdated), |
| 204 | other => Err(Error::Invalid(format!("sort {other:?}"))), |
| 205 | } |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | #[derive(Debug, Clone, PartialEq)] |
| 210 | pub struct IssueListItem { |
| 211 | pub issue: Issue, |
| 212 | |
| 213 | pub author: String, |
| 214 | pub labels: Vec<Label>, |
| 215 | |
| 216 | pub assignees: Vec<String>, |
| 217 | } |
| 218 | |
| 219 | |
| 220 | pub fn validate_title(t: &str) -> Result<String> { |
| 221 | let title = t.trim(); |
| 222 | if title.is_empty() { |
| 223 | return Err(Error::Invalid("title is empty".into())); |
| 224 | } |
| 225 | if title.chars().count() > MAX_TITLE { |
| 226 | return Err(Error::Invalid(format!( |
| 227 | "title is longer than {MAX_TITLE} characters" |
| 228 | ))); |
| 229 | } |
| 230 | if title.chars().any(char::is_control) { |
| 231 | return Err(Error::Invalid("title contains control characters".into())); |
| 232 | } |
| 233 | Ok(title.to_string()) |
| 234 | } |
| 235 | |
| 236 | |
| 237 | pub fn validate_body(b: &str) -> Result<()> { |
| 238 | if b.len() > MAX_BODY { |
| 239 | return Err(Error::Invalid(format!( |
| 240 | "body is longer than {MAX_BODY} bytes" |
| 241 | ))); |
| 242 | } |
| 243 | Ok(()) |
| 244 | } |
| 245 | |
| 246 | |
| 247 | |
| 248 | pub fn validate_label(name: &str, color: &str) -> Result<(String, String)> { |
| 249 | let name = name.trim(); |
| 250 | let len = name.chars().count(); |
| 251 | if len == 0 || len > MAX_LABEL_NAME { |
| 252 | return Err(Error::Invalid(format!( |
| 253 | "label name must be 1 to {MAX_LABEL_NAME} characters" |
| 254 | ))); |
| 255 | } |
| 256 | if name.chars().any(char::is_control) { |
| 257 | return Err(Error::Invalid( |
| 258 | "label name contains control characters".into(), |
| 259 | )); |
| 260 | } |
| 261 | let color = normalize_color(color) |
| 262 | .ok_or_else(|| Error::Invalid(format!("label color {color:?} is not #rgb or #rrggbb")))?; |
| 263 | Ok((name.to_string(), color)) |
| 264 | } |
| 265 | |
| 266 | fn normalize_color(color: &str) -> Option<String> { |
| 267 | let hex = color.strip_prefix('#')?; |
| 268 | if !hex.chars().all(|c| c.is_ascii_hexdigit()) { |
| 269 | return None; |
| 270 | } |
| 271 | let lower = hex.to_ascii_lowercase(); |
| 272 | match lower.len() { |
| 273 | 3 => { |
| 274 | let mut out = String::with_capacity(7); |
| 275 | out.push('#'); |
| 276 | for c in lower.chars() { |
| 277 | out.push(c); |
| 278 | out.push(c); |
| 279 | } |
| 280 | Some(out) |
| 281 | } |
| 282 | 6 => Some(format!("#{lower}")), |
| 283 | _ => None, |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | #[cfg(test)] |
| 288 | mod tests { |
| 289 | use super::*; |
| 290 | |
| 291 | #[test] |
| 292 | fn title_is_trimmed_and_checked() { |
| 293 | assert_eq!(validate_title(" Fix it ").unwrap(), "Fix it"); |
| 294 | assert!(validate_title(" ").is_err()); |
| 295 | assert!(validate_title("").is_err()); |
| 296 | assert!(validate_title("a\tb").is_err()); |
| 297 | assert!(validate_title("a\nb").is_err()); |
| 298 | assert!(validate_title(&"x".repeat(MAX_TITLE)).is_ok()); |
| 299 | assert!(validate_title(&"x".repeat(MAX_TITLE + 1)).is_err()); |
| 300 | |
| 301 | assert!(validate_title(&"é".repeat(MAX_TITLE)).is_ok()); |
| 302 | assert!(validate_title(&"é".repeat(MAX_TITLE + 1)).is_err()); |
| 303 | } |
| 304 | |
| 305 | #[test] |
| 306 | fn body_is_checked_in_bytes() { |
| 307 | assert!(validate_body("").is_ok()); |
| 308 | assert!(validate_body(&"x".repeat(MAX_BODY)).is_ok()); |
| 309 | assert!(validate_body(&"x".repeat(MAX_BODY + 1)).is_err()); |
| 310 | |
| 311 | assert!(validate_body(&"é".repeat(MAX_BODY / 2)).is_ok()); |
| 312 | assert!(validate_body(&"é".repeat(MAX_BODY / 2 + 1)).is_err()); |
| 313 | } |
| 314 | |
| 315 | #[test] |
| 316 | fn label_name_and_color_are_normalized() { |
| 317 | assert_eq!( |
| 318 | validate_label(" bug ", "#FF0000").unwrap(), |
| 319 | ("bug".to_string(), "#ff0000".to_string()) |
| 320 | ); |
| 321 | assert_eq!( |
| 322 | validate_label("x", "#AbC").unwrap(), |
| 323 | ("x".to_string(), "#aabbcc".to_string()) |
| 324 | ); |
| 325 | assert!(validate_label("", "#fff").is_err()); |
| 326 | assert!(validate_label(" ", "#fff").is_err()); |
| 327 | assert!(validate_label(&"a".repeat(MAX_LABEL_NAME), "#fff").is_ok()); |
| 328 | assert!(validate_label(&"a".repeat(MAX_LABEL_NAME + 1), "#fff").is_err()); |
| 329 | assert!(validate_label("a\u{7}", "#fff").is_err()); |
| 330 | } |
| 331 | |
| 332 | #[test] |
| 333 | fn label_color_must_be_hex() { |
| 334 | for bad in [ |
| 335 | "", "fff", "#ff", "#ffff", "#fffffff", "#gggggg", "red", "# fff", "#ff00 0", |
| 336 | ] { |
| 337 | assert!(validate_label("x", bad).is_err(), "{bad:?} accepted"); |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | #[test] |
| 342 | fn enums_round_trip() { |
| 343 | for s in [IssueState::Open, IssueState::Closed] { |
| 344 | assert_eq!(s.as_str().parse::<IssueState>().ok(), Some(s)); |
| 345 | } |
| 346 | assert!("Open".parse::<IssueState>().is_err()); |
| 347 | for r in [StateReason::Completed, StateReason::NotPlanned] { |
| 348 | assert_eq!(r.as_str().parse::<StateReason>().ok(), Some(r)); |
| 349 | } |
| 350 | assert!("duplicate".parse::<StateReason>().is_err()); |
| 351 | for s in [ |
| 352 | IssueSort::Newest, |
| 353 | IssueSort::Oldest, |
| 354 | IssueSort::RecentlyUpdated, |
| 355 | ] { |
| 356 | assert_eq!(s.as_str().parse::<IssueSort>().ok(), Some(s)); |
| 357 | } |
| 358 | assert!("bogus".parse::<IssueSort>().is_err()); |
| 359 | } |
| 360 | |
| 361 | #[test] |
| 362 | fn default_filter_is_open_first_page() { |
| 363 | let f = IssueFilter::default(); |
| 364 | assert_eq!(f.state, Some(IssueState::Open)); |
| 365 | assert_eq!(f.page, 1); |
| 366 | assert_eq!(f.per_page, 25); |
| 367 | assert_eq!(f.sort, IssueSort::Newest); |
| 368 | } |
| 369 | } |