use std::fmt;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Serialize};
use crate::{
Classification, ItemKind, NativeId, Project, Repository, StatusCategory, Task, TaskRef,
};
pub const ISSUE_REF_PATTERN: &str = "^[A-Za-z0-9-]+/[A-Za-z0-9._-]+#[1-9][0-9]{0,18}$";
const MAX_NUMBER_DIGITS: usize = 19;
#[derive(Debug, Clone, Eq)]
pub struct IssueRef {
pub owner: String, pub name: String, pub number: u64, }
impl IssueRef {
fn valid_owner(owner: &str) -> bool {
!owner.is_empty()
&& owner
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
}
fn valid_name(name: &str) -> bool {
!name.is_empty()
&& name.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
})
}
}
impl PartialEq for IssueRef {
fn eq(&self, other: &Self) -> bool {
self.number == other.number
&& self.owner.eq_ignore_ascii_case(&other.owner)
&& self.name.eq_ignore_ascii_case(&other.name)
}
}
impl Hash for IssueRef {
fn hash<H: Hasher>(&self, state: &mut H) {
self.owner.to_ascii_lowercase().hash(state);
self.name.to_ascii_lowercase().hash(state);
self.number.hash(state);
}
}
impl FromStr for IssueRef {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let refused = || {
format!(
"{value:?} is not an issue reference; spell one owner/name#N, as a landing's \
`closes` does"
)
};
let (repository, number) = value.rsplit_once('#').ok_or_else(refused)?;
let (owner, name) = repository.split_once('/').ok_or_else(refused)?;
if !Self::valid_owner(owner)
|| !Self::valid_name(name)
|| number.is_empty()
|| number.len() > MAX_NUMBER_DIGITS
|| number.starts_with('0')
|| !number.chars().all(|digit| digit.is_ascii_digit())
{
return Err(refused());
}
Ok(Self {
owner: owner.to_owned(),
name: name.to_owned(),
number: number.parse().map_err(|_| refused())?,
})
}
}
impl fmt::Display for IssueRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}#{}", self.owner, self.name, self.number)
}
}
impl Serialize for IssueRef {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for IssueRef {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
impl JsonSchema for IssueRef {
fn schema_name() -> std::borrow::Cow<'static, str> {
"IssueRef".into()
}
fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
json_schema!({
"type": "string",
"pattern": ISSUE_REF_PATTERN,
"description": "One issue of one repository, as `owner/name#N`.",
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct IssueHolding {
pub issue: IssueRef,
#[serde(flatten)]
pub completion: Completion,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct HeldTask {
pub id: NativeId,
#[serde(default = "task_kind", skip_serializing_if = "is_task")]
#[schemars(!skip_serializing_if)]
pub kind: ItemKind,
#[serde(default)]
pub option: Option<String>,
#[serde(default)]
pub classification: Classification,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repositories: Vec<Repository>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delivers: Vec<TaskRef>,
}
const fn task_kind() -> ItemKind {
ItemKind::Task
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_task(kind: &ItemKind) -> bool {
*kind == ItemKind::Task
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "completion", rename_all = "kebab-case")]
pub enum Completion {
Absent,
Skipped {
task: HeldTask,
reason: String,
},
Unchanged {
task: HeldTask,
},
Due {
task: HeldTask,
to: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ClosedAs {
Done,
Cancelled,
}
impl ClosedAs {
#[must_use]
pub const fn category(self) -> StatusCategory {
match self {
Self::Done => StatusCategory::Done,
Self::Cancelled => StatusCategory::Cancelled,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct StatusContradiction {
#[serde(flatten)]
pub item: ContradictedItem,
#[serde(default)]
pub option: Option<String>,
pub closed_as: ClosedAs,
pub to: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ContradictedItem {
Task(Task),
Project(Project),
}
impl ContradictedItem {
#[must_use]
pub const fn kind(&self) -> ItemKind {
match self {
Self::Task(_) => ItemKind::Task,
Self::Project(_) => ItemKind::Project,
}
}
#[must_use]
pub const fn id(&self) -> &NativeId {
match self {
Self::Task(task) => &task.id,
Self::Project(project) => &project.id,
}
}
#[must_use]
pub const fn classification(&self) -> Classification {
match self {
Self::Task(task) => task.classification,
Self::Project(project) => project.classification,
}
}
#[must_use]
pub fn repositories(&self) -> &[Repository] {
match self {
Self::Task(task) => &task.repositories,
Self::Project(project) => &project.repositories,
}
}
}