pub mod extract;
use std::fmt;
pub const FORMULA_VERSION: &str = "pm-effort-1";
pub mod thresholds {
pub const BASE_SCORE: f64 = 1.0;
pub const MIN_SCORE: f64 = 1.0;
pub const MAX_SCORE: f64 = 50.0;
pub const CHILD_WEIGHT: f64 = 2.0;
pub const CHILD_COUNT_CAP: u32 = 10;
pub const BODY_WORDS_PER_POINT: f64 = 40.0;
pub const BODY_POINTS_CAP: f64 = 12.0;
pub const COMMENT_WEIGHT: f64 = 0.8;
pub const COMMENT_COUNT_CAP: u32 = 10;
pub const TRANSITION_WEIGHT: f64 = 0.5;
pub const TRANSITION_COUNT_CAP: u32 = 12;
pub const STORY_POINT_WEIGHT: f64 = 0.4;
pub const STORY_POINT_CAP: f64 = 3.0;
pub const STORY_POINTS_MIN: f64 = 0.5;
pub const STORY_POINTS_MAX: f64 = 40.0;
pub const BUCKET_MEDIUM_MIN: f64 = 15.0;
pub const BUCKET_HIGH_MIN: f64 = 30.0;
pub const RECENCY_FLOOR_DAYS: i64 = 7;
pub const AGE_FLOORED_ITEM_TYPES: &[&str] = &["epic", "feature", "initiative"];
pub const STORY_POINT_FIELD_IDS: &[&str] = &[
"customfield_10004",
"customfield_10016",
"customfield_13001",
"customfield_13737",
];
pub const STORY_POINT_FIELD_NAMES: &[&str] = &["storypoints", "storypointestimate", "estimate"];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EffortBucket {
Low,
Medium,
High,
}
impl EffortBucket {
#[must_use]
pub const fn as_wire_str(self) -> &'static str {
match self {
Self::Low => "LOW",
Self::Medium => "MEDIUM",
Self::High => "HIGH",
}
}
#[must_use]
pub fn from_wire_str(s: &str) -> Option<Self> {
match s {
"LOW" => Some(Self::Low),
"MEDIUM" => Some(Self::Medium),
"HIGH" => Some(Self::High),
_ => None,
}
}
#[must_use]
pub fn of_score(score: f64) -> Self {
if score >= thresholds::BUCKET_HIGH_MIN {
Self::High
} else if score >= thresholds::BUCKET_MEDIUM_MIN {
Self::Medium
} else {
Self::Low
}
}
}
impl fmt::Display for EffortBucket {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_wire_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScoreStatus {
Scored,
DeferredRecent,
}
impl ScoreStatus {
#[must_use]
pub const fn as_wire_str(self) -> &'static str {
match self {
Self::Scored => "SCORED",
Self::DeferredRecent => "DEFERRED_RECENT",
}
}
#[must_use]
pub fn from_wire_str(s: &str) -> Option<Self> {
match s {
"SCORED" => Some(Self::Scored),
"DEFERRED_RECENT" => Some(Self::DeferredRecent),
_ => None,
}
}
}
impl fmt::Display for ScoreStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_wire_str())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct EffortCounts {
pub epic_children: u32,
pub description_words: u32,
pub comments: u32,
pub transitions: u32,
pub story_points: Option<f64>,
}
#[derive(Debug, Clone, Copy)]
pub struct PmEffortInput<'a> {
pub item_type: &'a str,
pub age_days: Option<i64>,
pub counts: EffortCounts,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct InputsPresent {
pub children: bool,
pub description: bool,
pub comments: bool,
pub transitions: bool,
pub story_points: bool,
}
impl InputsPresent {
#[must_use]
pub fn to_wire_string(self) -> String {
let mut parts: Vec<&'static str> = Vec::with_capacity(5);
if self.children {
parts.push("CHILDREN");
}
if self.description {
parts.push("DESCRIPTION");
}
if self.comments {
parts.push("COMMENTS");
}
if self.transitions {
parts.push("TRANSITIONS");
}
if self.story_points {
parts.push("STORY_POINTS");
}
if parts.is_empty() {
return "NONE".to_string();
}
parts.join(",")
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PmEffortScore {
pub status: ScoreStatus,
pub effort_score: Option<f64>,
pub effort_bucket: Option<EffortBucket>,
pub inputs_present: InputsPresent,
}
#[must_use]
pub fn is_age_floored_type(item_type: &str) -> bool {
let lower = item_type.trim().to_lowercase();
thresholds::AGE_FLOORED_ITEM_TYPES.contains(&lower.as_str())
}
#[must_use]
pub fn plausible_story_points(points: f64) -> Option<f64> {
(points.is_finite()
&& (thresholds::STORY_POINTS_MIN..=thresholds::STORY_POINTS_MAX).contains(&points))
.then_some(points)
}
#[must_use]
pub fn score(input: &PmEffortInput<'_>) -> PmEffortScore {
if is_age_floored_type(input.item_type)
&& input
.age_days
.is_some_and(|days| days < thresholds::RECENCY_FLOOR_DAYS)
{
return PmEffortScore {
status: ScoreStatus::DeferredRecent,
effort_score: None,
effort_bucket: None,
inputs_present: InputsPresent::default(),
};
}
let counts = input.counts;
let children =
capped_count(counts.epic_children, thresholds::CHILD_COUNT_CAP) * thresholds::CHILD_WEIGHT;
let description = (f64::from(counts.description_words) / thresholds::BODY_WORDS_PER_POINT)
.min(thresholds::BODY_POINTS_CAP);
let comments =
capped_count(counts.comments, thresholds::COMMENT_COUNT_CAP) * thresholds::COMMENT_WEIGHT;
let transitions = capped_count(counts.transitions, thresholds::TRANSITION_COUNT_CAP)
* thresholds::TRANSITION_WEIGHT;
let story_points = counts
.story_points
.and_then(plausible_story_points)
.map_or(0.0, |p| {
(p * thresholds::STORY_POINT_WEIGHT).min(thresholds::STORY_POINT_CAP)
});
let raw =
thresholds::BASE_SCORE + children + description + comments + transitions + story_points;
let clamped = round2(raw.clamp(thresholds::MIN_SCORE, thresholds::MAX_SCORE));
PmEffortScore {
status: ScoreStatus::Scored,
effort_score: Some(clamped),
effort_bucket: Some(EffortBucket::of_score(clamped)),
inputs_present: InputsPresent {
children: children > 0.0,
description: description > 0.0,
comments: comments > 0.0,
transitions: transitions > 0.0,
story_points: story_points > 0.0,
},
}
}
fn capped_count(count: u32, cap: u32) -> f64 {
f64::from(count.min(cap))
}
fn round2(value: f64) -> f64 {
(value * 100.0).round() / 100.0
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;