use crate::config::MilestoneConfig;
use crate::error::{GwmError, Result};
use chrono::{DateTime, NaiveDate, Utc};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MilestoneSpec {
pub title: String,
pub description: Option<String>,
pub due_on: Option<String>,
pub state: MilestoneState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteMilestone {
pub number: u64,
pub title: String,
pub description: Option<String>,
pub due_on: Option<String>,
pub state: MilestoneState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MilestoneState {
Open,
Closed,
}
impl MilestoneState {
pub fn as_str(&self) -> &'static str {
match self {
Self::Open => "open",
Self::Closed => "closed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MilestoneAction {
Create,
Update,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MilestoneUpdate {
pub action: MilestoneAction,
pub spec: MilestoneSpec,
pub number: u64,
pub previous_due_on: Option<String>,
pub previous_description: Option<String>,
pub previous_state: Option<MilestoneState>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MilestoneDiff {
pub to_create: Vec<MilestoneSpec>,
pub to_update: Vec<MilestoneUpdate>,
pub matching: Vec<MilestoneSpec>,
pub extra_on_remote: Vec<RemoteMilestone>,
}
impl MilestoneDiff {
pub fn counts(&self) -> (usize, usize, usize, usize) {
(
self.to_create.len(),
self.to_update.len(),
self.matching.len(),
self.extra_on_remote.len(),
)
}
}
pub fn normalize_due_on(s: &str) -> Result<String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(GwmError::Config("invalid due_on: empty string".into()));
}
if trimmed.len() == 10 {
let date = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")
.map_err(|e| GwmError::Config(format!("invalid due_on '{}': {}", trimmed, e)))?;
return Ok(format!("{}T23:59:59Z", date.format("%Y-%m-%d")));
}
let dt = DateTime::parse_from_rfc3339(trimmed)
.map_err(|e| GwmError::Config(format!("invalid due_on '{}': {}", trimmed, e)))?;
Ok(dt.with_timezone(&Utc).format("%Y-%m-%dT%H:%M:%SZ").to_string())
}
pub fn parse_state(s: &str) -> Result<MilestoneState> {
match s {
"open" => Ok(MilestoneState::Open),
"closed" => Ok(MilestoneState::Closed),
other => Err(GwmError::Config(format!(
"invalid milestone state '{}': expected 'open' or 'closed'",
other
))),
}
}
pub fn resolve_milestones(declared: &[MilestoneConfig]) -> Result<Vec<MilestoneSpec>> {
declared
.iter()
.map(|m| {
let state = match &m.state {
Some(s) => {
parse_state(s).map_err(|e| GwmError::Config(format!("milestone '{}' has invalid state: {}", m.title, e)))?
}
None => MilestoneState::Open,
};
let due_on = match &m.due_on {
Some(s) => Some(
normalize_due_on(s)
.map_err(|e| GwmError::Config(format!("milestone '{}' has invalid due_on: {}", m.title, e)))?,
),
None => None,
};
Ok(MilestoneSpec {
title: m.title.clone(),
description: m.description.clone().filter(|s| !s.is_empty()),
due_on,
state,
})
})
.collect()
}
pub fn diff_milestones(declared: &[MilestoneSpec], remote: &[RemoteMilestone]) -> MilestoneDiff {
let remote_by_title: HashMap<&str, &RemoteMilestone> = remote.iter().map(|r| (r.title.as_str(), r)).collect();
let declared_titles: HashSet<&str> = declared.iter().map(|s| s.title.as_str()).collect();
let mut to_create = Vec::new();
let mut to_update = Vec::new();
let mut matching = Vec::new();
for spec in declared {
match remote_by_title.get(spec.title.as_str()) {
None => to_create.push(spec.clone()),
Some(r) => {
let due_match = norm_due(&spec.due_on) == norm_due(&r.due_on);
let desc_match = norm_desc(&spec.description) == norm_desc(&r.description);
let state_match = spec.state == r.state;
if due_match && desc_match && state_match {
matching.push(spec.clone());
} else {
to_update.push(MilestoneUpdate {
action: MilestoneAction::Update,
spec: spec.clone(),
number: r.number,
previous_due_on: if due_match { None } else { r.due_on.clone() },
previous_description: if desc_match { None } else { r.description.clone() },
previous_state: if state_match { None } else { Some(r.state) },
});
}
}
}
}
let extra_on_remote: Vec<RemoteMilestone> = remote
.iter()
.filter(|r| !declared_titles.contains(r.title.as_str()))
.cloned()
.collect();
MilestoneDiff {
to_create,
to_update,
matching,
extra_on_remote,
}
}
fn norm_desc(d: &Option<String>) -> Option<String> {
d.as_ref().filter(|s| !s.is_empty()).cloned()
}
fn norm_due(d: &Option<String>) -> Option<String> {
d.as_ref().map(|s| normalize_due_on(s).unwrap_or_else(|_| s.clone()))
}