use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::HashMap;
pub const DEFAULT_CLAIM_HOURS: i64 = 48;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClaimDuration(pub Duration);
impl ClaimDuration {
pub fn default_duration() -> Self {
ClaimDuration(Duration::hours(DEFAULT_CLAIM_HOURS))
}
}
impl std::str::FromStr for ClaimDuration {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
if s.is_empty() {
anyhow::bail!("Empty claim duration");
}
let (num_part, unit) = match s.chars().last() {
Some(c) if c.is_ascii_alphabetic() => (&s[..s.len() - 1], c.to_ascii_lowercase()),
_ => (s, 'h'), };
let value: i64 = num_part.trim().parse().map_err(|_| {
anyhow::anyhow!(
"Invalid claim duration: '{}'. Use forms like '48h', '2d', '90m', or a bare number of hours.",
s
)
})?;
if value <= 0 {
anyhow::bail!("Claim duration must be positive, got '{}'", s);
}
let duration = match unit {
'm' => Duration::minutes(value),
'h' => Duration::hours(value),
'd' => Duration::days(value),
other => anyhow::bail!(
"Invalid claim duration unit '{}' in '{}'. Valid units: m (minutes), h (hours), d (days).",
other,
s
),
};
Ok(ClaimDuration(duration))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Open,
InProgress,
Blocked,
Closed,
}
impl Status {
pub fn as_str(&self) -> &'static str {
match self {
Status::Open => "open",
Status::InProgress => "in_progress",
Status::Blocked => "blocked",
Status::Closed => "closed",
}
}
}
impl std::fmt::Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for Status {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"open" => Ok(Status::Open),
"in_progress" => Ok(Status::InProgress),
"blocked" => Ok(Status::Blocked),
"closed" => Ok(Status::Closed),
_ => Err(anyhow::anyhow!(
"Invalid status: '{}'. Valid values are: open, in_progress, blocked, closed",
s
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IssueType {
Bug,
Feature,
Task,
Epic,
Chore,
}
impl IssueType {
pub fn as_str(&self) -> &'static str {
match self {
IssueType::Bug => "bug",
IssueType::Feature => "feature",
IssueType::Task => "task",
IssueType::Epic => "epic",
IssueType::Chore => "chore",
}
}
}
impl std::fmt::Display for IssueType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for IssueType {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"bug" => Ok(IssueType::Bug),
"feature" => Ok(IssueType::Feature),
"task" => Ok(IssueType::Task),
"epic" => Ok(IssueType::Epic),
"chore" => Ok(IssueType::Chore),
_ => Err(anyhow::anyhow!(
"Invalid issue type: '{}'. Valid values are: bug, feature, task, epic, chore",
s
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditField {
Title,
Description,
Design,
Notes,
Acceptance,
}
impl EditField {
pub fn as_str(&self) -> &'static str {
match self {
EditField::Title => "title",
EditField::Description => "description",
EditField::Design => "design",
EditField::Notes => "notes",
EditField::Acceptance => "acceptance",
}
}
}
impl std::fmt::Display for EditField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for EditField {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"title" => Ok(EditField::Title),
"description" | "desc" => Ok(EditField::Description),
"design" => Ok(EditField::Design),
"notes" => Ok(EditField::Notes),
"acceptance" | "acceptance_criteria" => Ok(EditField::Acceptance),
_ => Err(anyhow::anyhow!(
"Invalid field: '{}'. Valid values are: title, description, design, notes, acceptance",
s
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DependencyType {
Blocks,
Related,
ParentChild,
DiscoveredFrom,
}
impl DependencyType {
pub fn as_str(&self) -> &'static str {
match self {
DependencyType::Blocks => "blocks",
DependencyType::Related => "related",
DependencyType::ParentChild => "parent-child",
DependencyType::DiscoveredFrom => "discovered-from",
}
}
}
impl std::fmt::Display for DependencyType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for DependencyType {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"blocks" => Ok(DependencyType::Blocks),
"related" => Ok(DependencyType::Related),
"parent-child" => Ok(DependencyType::ParentChild),
"discovered-from" => Ok(DependencyType::DiscoveredFrom),
_ => Err(anyhow::anyhow!(
"Invalid dependency type: '{}'. Valid values are: blocks, related, parent-child, discovered-from",
s
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dependency {
pub id: String,
#[serde(rename = "type")]
pub dep_type: String,
}
fn serialize_dependencies<S>(
map: &HashMap<String, DependencyType>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let deps: Vec<Dependency> = map
.iter()
.map(|(id, dep_type)| Dependency {
id: id.clone(),
dep_type: dep_type.to_string(),
})
.collect();
deps.serialize(serializer)
}
#[derive(Deserialize)]
#[serde(untagged)]
enum DependenciesFormat {
Array(Vec<Dependency>),
Map(HashMap<String, DependencyType>),
}
fn deserialize_dependencies<'de, D>(
deserializer: D,
) -> Result<HashMap<String, DependencyType>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
match DependenciesFormat::deserialize(deserializer)? {
DependenciesFormat::Array(deps) => {
let mut map = HashMap::new();
for dep in deps {
let dep_type = dep.dep_type.parse::<DependencyType>().map_err(|_| {
Error::custom(format!("Invalid dependency type: {}", dep.dep_type))
})?;
map.insert(dep.id, dep_type);
}
Ok(map)
}
DependenciesFormat::Map(map) => Ok(map),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub id: String,
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub design: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub acceptance_criteria: String,
pub status: Status,
pub priority: i32,
pub issue_type: IssueType,
#[serde(default)]
pub assignee: String,
pub external_ref: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(
default,
rename = "dependencies",
serialize_with = "serialize_dependencies",
deserialize_with = "deserialize_dependencies"
)]
pub depends_on: HashMap<String, DependencyType>,
#[serde(default, skip_deserializing)]
pub dependents: Vec<Dependency>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub closed_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claimed_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claimed_until: Option<DateTime<Utc>>,
}
impl Issue {
pub fn new(id: String, title: String, priority: i32, issue_type: IssueType) -> Self {
let now = Utc::now();
Self {
id,
title,
description: String::new(),
design: String::new(),
notes: String::new(),
acceptance_criteria: String::new(),
status: Status::Open,
priority,
issue_type,
assignee: String::new(),
external_ref: None,
labels: Vec::new(),
depends_on: HashMap::new(),
dependents: Vec::new(),
created_at: now,
updated_at: now,
closed_at: None,
claimed_at: None,
claimed_until: None,
}
}
pub fn is_actively_claimed(&self, now: DateTime<Utc>) -> bool {
if self.assignee.is_empty() {
return false;
}
match self.claimed_until {
Some(until) => until > now,
None => true,
}
}
pub fn text_field_mut(&mut self, field: EditField) -> &mut String {
match field {
EditField::Title => &mut self.title,
EditField::Description => &mut self.description,
EditField::Design => &mut self.design,
EditField::Notes => &mut self.notes,
EditField::Acceptance => &mut self.acceptance_criteria,
}
}
pub fn get_blocking_dependencies(&self) -> impl Iterator<Item = &String> + '_ {
self.depends_on
.iter()
.filter(|(_, dep_type)| **dep_type == DependencyType::Blocks)
.map(|(id, _)| id)
}
pub fn has_blocking_dependencies(&self) -> bool {
self.depends_on
.values()
.any(|dep_type| *dep_type == DependencyType::Blocks)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Stats {
pub total_issues: usize,
pub open_issues: usize,
pub in_progress_issues: usize,
pub blocked_issues: usize,
pub closed_issues: usize,
pub ready_issues: usize,
pub average_lead_time_hours: f64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BlockedIssue {
#[serde(flatten)]
pub issue: Issue,
pub blocked_by: Vec<String>,
pub blocked_by_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeNode {
pub id: String,
pub title: String,
pub status: Status,
pub priority: i32,
pub dep_type: Option<String>,
pub children: Vec<TreeNode>,
pub is_cycle: bool,
pub depth_exceeded: bool,
}
#[cfg(test)]
mod claim_type_tests {
use super::*;
use std::str::FromStr;
#[test]
fn parses_duration_units() {
assert_eq!(
ClaimDuration::from_str("90m").unwrap().0,
Duration::minutes(90)
);
assert_eq!(
ClaimDuration::from_str("48h").unwrap().0,
Duration::hours(48)
);
assert_eq!(ClaimDuration::from_str("2d").unwrap().0, Duration::days(2));
assert_eq!(
ClaimDuration::from_str("12").unwrap().0,
Duration::hours(12)
);
assert_eq!(ClaimDuration::from_str("3H").unwrap().0, Duration::hours(3));
}
#[test]
fn rejects_bad_durations() {
assert!(ClaimDuration::from_str("").is_err());
assert!(ClaimDuration::from_str("0h").is_err());
assert!(ClaimDuration::from_str("-5h").is_err());
assert!(ClaimDuration::from_str("abc").is_err());
assert!(ClaimDuration::from_str("10y").is_err());
}
#[test]
fn default_duration_is_48h() {
assert_eq!(
ClaimDuration::default_duration().0,
Duration::hours(DEFAULT_CLAIM_HOURS)
);
}
#[test]
fn active_claim_detection() {
let now = Utc::now();
let mut issue = Issue::new("demo-1".to_string(), "t".to_string(), 2, IssueType::Task);
assert!(!issue.is_actively_claimed(now));
issue.assignee = "host-a".to_string();
issue.claimed_until = Some(now + Duration::hours(1));
assert!(issue.is_actively_claimed(now));
issue.claimed_until = Some(now - Duration::hours(1));
assert!(!issue.is_actively_claimed(now));
issue.claimed_until = None;
assert!(issue.is_actively_claimed(now));
}
}