use crate::models; pub const DEFAULT_PAGE_SIZE: i32 = 30; #[derive(Clone, Debug, Eq, PartialEq)] pub struct RepositoryId { pub owner: String, pub repository: String, } impl RepositoryId { pub fn new(owner: impl Into, repository: impl Into) -> crate::Result { let result = Self { owner: owner.into(), repository: repository.into(), }; if result.owner.is_empty() || result.repository.is_empty() || result.owner.contains('/') || result.repository.contains('/') { return Err(crate::Error::InvalidInput( "repository must be OWNER/REPOSITORY".into(), )); } Ok(result) } pub fn parse(value: &str) -> crate::Result { let (owner, repository) = value.split_once('/').ok_or_else(|| { crate::Error::InvalidInput("repository must be OWNER/REPOSITORY".into()) })?; Self::new(owner, repository) } } #[derive(Clone, Debug)] pub struct Page { pub items: Vec, pub has_more: bool, } impl Page { pub fn from_items(items: Vec, limit: i32) -> Self { Self { has_more: limit > 0 && items.len() == limit as usize, items, } } } #[derive(Clone, Debug)] pub struct IssueQuery { pub state: String, pub labels: Option, pub keyword: Option, pub kind: String, pub milestones: Option, pub from: Option, pub until: Option, pub author: Option, pub assignee: Option, pub mentions: Option, pub page: i32, pub limit: i32, } impl Default for IssueQuery { fn default() -> Self { Self { state: "open".into(), labels: None, keyword: None, kind: "issues".into(), milestones: None, from: None, until: None, author: None, assignee: None, mentions: None, page: 1, limit: DEFAULT_PAGE_SIZE, } } } #[derive(Clone, Debug)] pub struct CreateIssue { pub option: models::CreateIssueOption, pub label_names: Vec, pub milestone_name: Option, } #[derive(Clone, Debug)] pub struct EditIssue { pub option: models::EditIssueOption, pub replace_labels: Option>, pub add_labels: Vec, pub remove_labels: Vec, pub add_assignees: Vec, pub milestone_name: Option, } #[derive(Clone, Debug)] pub struct IssueDraft { pub title: String, pub body: String, pub label_ids: Vec, pub milestone_id: Option, pub due_date: Option, pub closed: bool, } #[derive(Clone, Debug)] pub struct IssueDetails { pub issue: models::Issue, pub comments: Vec, pub viewer_id: Option, pub has_more: bool, } #[derive(Clone, Debug)] pub struct IssueEditorData { pub issue: Option, pub labels: Vec, pub milestones: Vec, } #[derive(Clone, Debug)] pub struct MilestoneDraft { pub title: String, pub description: String, pub due_on: Option, pub state: String, } #[derive(Clone, Debug)] pub struct MilestoneDetails { pub milestone: models::Milestone, pub issues: Vec, pub pulls: Vec, pub has_more: bool, } #[derive(Clone, Debug)] pub struct PullDetails { pub pull: models::PullRequest, pub comments: Vec, pub files: Vec, pub has_more: bool, } #[derive(Clone, Debug)] pub struct HistoryCommit { pub commit: models::Commit, pub top_lanes: Vec, pub bottom_lanes: Vec, pub node_lane: Option, pub top_connections: Vec, pub bottom_connections: Vec, pub refs: Vec, pub branch_starts: Vec, } #[derive(Clone, Debug)] pub struct HomeData { pub activities: Vec, pub heatmap: Vec, pub next_page: Option, } pub fn api_date(timestamp: i64) -> String { let (year, month, day) = civil_from_days(timestamp.div_euclid(86_400)); format!("{year:04}-{month:02}-{day:02}T00:00:00Z") } pub fn api_timestamp(timestamp: i64) -> String { let days = timestamp.div_euclid(86_400); let seconds = timestamp.rem_euclid(86_400); let (year, month, day) = civil_from_days(days); let hour = seconds / 3_600; let minute = seconds % 3_600 / 60; let second = seconds % 60; format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") } pub fn parse_api_date(value: &str) -> Option { let date = value.get(..10)?; let mut parts = date.split('-'); let year = parts.next()?.parse().ok()?; let month = parts.next()?.parse().ok()?; let day = parts.next()?.parse().ok()?; if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) { return None; } let days = days_from_civil(year, month, day); (civil_from_days(days) == (year, month, day)).then_some(days * 86_400) } pub fn civil_from_days(days: i64) -> (i64, i64, i64) { let days = days + 719_468; let era = days.div_euclid(146_097); let day_of_era = days - era * 146_097; let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; let mut year = year_of_era + era * 400; let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); let month_prime = (5 * day_of_year + 2) / 153; let day = day_of_year - (153 * month_prime + 2) / 5 + 1; let month = month_prime + if month_prime < 10 { 3 } else { -9 }; year += i64::from(month <= 2); (year, month, day) } pub fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 { year -= i64::from(month <= 2); let era = year.div_euclid(400); let year_of_era = year - era * 400; let month_prime = month + if month > 2 { -3 } else { 9 }; let day_of_year = (153 * month_prime + 2) / 5 + day - 1; let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; era * 146_097 + day_of_era - 719_468 } #[cfg(test)] mod tests { use super::*; #[test] fn validates_repositories_pages_and_dates() { assert_eq!( RepositoryId::parse("alice/project").unwrap(), RepositoryId { owner: "alice".into(), repository: "project".into(), } ); assert!(RepositoryId::parse("project").is_err()); assert!(Page::from_items(vec![(); 30], 30).has_more); assert!(!Page::from_items(vec![(); 29], 30).has_more); let leap_day = parse_api_date("2024-02-29T12:34:56Z").unwrap(); assert_eq!(api_date(leap_day), "2024-02-29T00:00:00Z"); assert_eq!(api_timestamp(leap_day + 45_296), "2024-02-29T12:34:56Z"); assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None); } }