use crate::wip::{Estimate as WipEstimate, WipConfig, WipDiagnostic};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EstimateValue {
pub raw: Option<String>,
pub unit: Option<String>,
pub value: Option<u32>,
pub converted_value: Option<u32>,
pub diagnostics: Vec<WipDiagnostic>,
converted_in: Option<String>,
terms: Vec<WipEstimate>,
}
impl EstimateValue {
#[must_use]
pub fn unestimated() -> Self {
Self::default()
}
#[must_use]
pub fn parse(number: u32, raw: &str) -> Self {
match WipEstimate::parse_terms(raw.trim()) {
Ok(terms) => {
let (value, unit) = match terms.as_slice() {
[term] => (Some(term.value), term.unit.clone()),
_ => (None, None),
};
Self {
raw: Some(raw.to_string()),
unit,
value,
converted_value: None,
diagnostics: Vec::new(),
converted_in: None,
terms,
}
}
Err(error) => Self {
raw: Some(raw.to_string()),
unit: None,
value: None,
converted_value: None,
diagnostics: error
.diagnostics()
.iter()
.map(|finding| {
located(
number,
format!("states `{raw}`, which cannot be counted"),
finding,
)
})
.collect(),
converted_in: None,
terms: Vec::new(),
},
}
}
#[must_use]
pub fn converted_to(&self, number: u32, registry: &WipConfig, unit: &str) -> Self {
let mut value = self.clone();
let Some(raw) = self.raw.as_deref() else {
return value;
};
if self.terms.is_empty() {
return value;
}
match registry.convert_terms(&self.terms, unit) {
Ok(converted) => {
value.converted_value = Some(converted);
value.converted_in = Some(unit.to_string());
}
Err(error) => value
.diagnostics
.extend(error.diagnostics().iter().map(|finding| {
located(
number,
format!("states `{raw}`, which cannot be counted in `{unit}`"),
finding,
)
})),
}
value
}
#[must_use]
pub fn raw(&self) -> Option<&str> {
self.raw.as_deref()
}
#[must_use]
pub fn unit(&self) -> Option<&str> {
self.unit.as_deref()
}
#[must_use]
pub fn converted_in(&self) -> Option<&str> {
self.converted_in.as_deref()
}
#[must_use]
pub fn diagnostics(&self) -> &[WipDiagnostic] {
&self.diagnostics
}
#[must_use]
pub fn is_unestimated(&self) -> bool {
self.raw.is_none()
}
#[must_use]
pub fn is_valid(&self) -> bool {
!self.terms.is_empty()
}
#[must_use]
pub fn is_converted(&self) -> bool {
self.converted_value.is_some()
}
#[must_use]
pub fn render(&self) -> String {
self.raw
.clone()
.unwrap_or_else(|| "unestimated".to_string())
}
}
fn located(number: u32, context: String, finding: &WipDiagnostic) -> WipDiagnostic {
WipDiagnostic {
kind: finding.kind,
message: format!("issue #{number} {context}: {}", finding.message),
because: finding.because.clone(),
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::wip::WipDiagnosticKind;
use indexmap::IndexMap;
fn registry() -> WipConfig {
let mut units = IndexMap::new();
units.insert("h".to_string(), 1);
units.insert("d".to_string(), 8);
units.insert("w".to_string(), 40);
WipConfig {
units,
limits: IndexMap::new(),
}
}
#[test]
fn prose_units_fold_and_compound_terms_sum_through_the_board() {
let value = EstimateValue::parse(7, "3 weeks 2 hours");
assert!(value.is_valid(), "the compound statement is readable");
assert_eq!(value.raw(), Some("3 weeks 2 hours"));
assert!(value.value.is_none(), "no single stated number");
let converted = value.converted_to(7, ®istry(), "h");
assert_eq!(converted.converted_value, Some(3 * 5 * 8 + 2));
assert_eq!(converted.converted_in(), Some("h"));
assert_eq!(converted.diagnostics(), []);
let one = EstimateValue::parse(7, "1 day").converted_to(7, ®istry(), "h");
assert_eq!(one.unit(), Some("d"), "the prose word folds to the symbol");
assert_eq!(one.value, Some(1));
assert_eq!(one.converted_value, Some(8));
let annotated =
EstimateValue::parse(7, "~300 LOC, 0.5-1 day").converted_to(7, ®istry(), "d");
assert!(annotated.is_valid());
assert_eq!(annotated.raw(), Some("~300 LOC, 0.5-1 day"));
assert_eq!(
annotated.converted_value,
Some(1),
"the unreadable annotation is ignored and the range is pessimistic"
);
}
#[test]
fn unreadable_prose_is_diagnosed_and_never_counted() {
let value = EstimateValue::parse(7, "a couple of days");
assert!(!value.is_valid());
assert!(!value.is_unestimated(), "it stated something unreadable");
assert_eq!(
value.diagnostics()[0].kind,
WipDiagnosticKind::MalformedEstimate
);
let converted = value.clone().converted_to(7, ®istry(), "h");
assert!(converted.converted_value.is_none());
assert!(converted.converted_in().is_none());
}
}