1use indexmap::IndexMap;
51use serde::{Deserialize, Serialize};
52
53use crate::error::TixError;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum LimitPolicy {
63 Warn,
65 Block,
67}
68
69impl LimitPolicy {
70 pub const fn as_str(self) -> &'static str {
72 match self {
73 Self::Warn => "warn",
74 Self::Block => "block",
75 }
76 }
77}
78
79const fn enabled_default() -> bool {
81 true
82}
83
84#[allow(clippy::trivially_copy_pass_by_ref)]
87fn is_enabled_default(enabled: &bool) -> bool {
88 *enabled
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(default, deny_unknown_fields)]
99pub struct ColumnLimit {
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub max_items: Option<u32>,
103 #[serde(skip_serializing_if = "Option::is_none")]
107 pub max_estimate: Option<u32>,
108 #[serde(skip_serializing_if = "Option::is_none")]
111 pub unit: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub policy: Option<LimitPolicy>,
115 #[serde(
118 default = "enabled_default",
119 skip_serializing_if = "is_enabled_default"
120 )]
121 pub enabled: bool,
122}
123
124impl Default for ColumnLimit {
125 fn default() -> Self {
126 Self {
127 max_items: None,
128 max_estimate: None,
129 unit: None,
130 policy: None,
131 enabled: true,
132 }
133 }
134}
135
136impl ColumnLimit {
137 pub fn items(max_items: u32, policy: LimitPolicy) -> Self {
139 Self {
140 max_items: Some(max_items),
141 policy: Some(policy),
142 ..Self::default()
143 }
144 }
145
146 pub fn estimate(max_estimate: u32, unit: impl Into<String>, policy: LimitPolicy) -> Self {
148 Self {
149 max_estimate: Some(max_estimate),
150 unit: Some(unit.into()),
151 policy: Some(policy),
152 ..Self::default()
153 }
154 }
155
156 pub fn is_empty(&self) -> bool {
158 self.max_items.is_none()
159 && self.max_estimate.is_none()
160 && self.unit.is_none()
161 && self.policy.is_none()
162 && self.enabled == enabled_default()
163 }
164
165 pub fn resolved_policy(&self) -> Option<LimitPolicy> {
171 self.enabled.then_some(self.policy).flatten()
172 }
173}
174
175#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(default, deny_unknown_fields)]
182pub struct WipConfig {
183 #[serde(skip_serializing_if = "IndexMap::is_empty")]
186 pub units: IndexMap<String, u32>,
187 #[serde(skip_serializing_if = "IndexMap::is_empty")]
189 pub limits: IndexMap<String, ColumnLimit>,
190}
191
192impl WipConfig {
193 pub fn limits(&self) -> impl Iterator<Item = (&str, &ColumnLimit)> {
195 self.limits
196 .iter()
197 .map(|(column, limit)| (column.as_str(), limit))
198 }
199
200 pub fn is_empty(&self) -> bool {
202 self.units.is_empty() && self.limits.is_empty()
203 }
204
205 pub fn has_enabled_limits(&self) -> bool {
207 self.limits.values().any(|limit| limit.enabled)
208 }
209
210 pub fn effective_limit(&self, column: &str) -> Option<&ColumnLimit> {
217 self.limits
218 .get(column)
219 .filter(|limit| limit.enabled && !limit.is_empty())
220 }
221
222 pub fn factor(&self, unit: &str) -> Option<u32> {
224 self.units.get(unit).copied()
225 }
226
227 pub fn convert(&self, estimate: &Estimate, target: &str) -> std::result::Result<u32, WipError> {
235 let target_factor = self.factor(target).ok_or_else(|| {
236 WipError::single(WipDiagnostic::new(
237 WipDiagnosticKind::UnknownUnit,
238 format!("`{target}` is not a declared estimate unit"),
239 ))
240 .because("declare the unit under `[kanban.<board>.wip.units]`")
241 })?;
242 let Some(unit) = estimate.unit.as_deref() else {
243 return Ok(estimate.value);
245 };
246 if unit == target {
247 return Ok(estimate.value);
248 }
249 let from_factor = self.factor(unit).ok_or_else(|| {
250 WipError::single(WipDiagnostic::new(
251 WipDiagnosticKind::MissingConversion,
252 format!(
253 "`{}` uses estimate unit `{unit}`, which this board does not declare",
254 estimate.render()
255 ),
256 ))
257 .because("a missing conversion is an error, never a zero or a guess (C-003)")
258 })?;
259 let scaled = (estimate.value as u64) * (from_factor as u64);
260 let target_factor = target_factor as u64;
261 if scaled % target_factor != 0 {
262 return Err(WipError::single(WipDiagnostic::new(
263 WipDiagnosticKind::AmbiguousConversion,
264 format!(
265 "`{}` is {scaled} base points, which is not a whole number of `{target}` \
266 (a `{target}` is {target_factor})",
267 estimate.render()
268 ),
269 ))
270 .because(
271 "declare a unit the arithmetic divides evenly into, or rewrite the estimate",
272 ));
273 }
274 Ok((scaled / target_factor) as u32)
275 }
276
277 pub fn convert_terms(
284 &self,
285 estimates: &[Estimate],
286 target: &str,
287 ) -> std::result::Result<u32, WipError> {
288 if estimates.is_empty() {
289 return Err(Estimate::malformed(
290 "an estimate must state at least one term".to_string(),
291 ));
292 }
293 let mut total: u64 = 0;
294 for estimate in estimates {
295 total += u64::from(self.convert(estimate, target)?);
296 if total > u64::from(u32::MAX) {
297 return Err(Estimate::malformed(format!(
298 "the estimate terms sum past what this board can count in `{target}`"
299 )));
300 }
301 }
302 Ok(total as u32)
303 }
304
305 pub fn validate(&self, board: &str, columns: &[String]) -> std::result::Result<(), TixError> {
312 for (name, factor) in &self.units {
313 if name.trim().is_empty() {
314 return Err(TixError::Config(format!(
315 "`kanban.{board}.wip.units` must not contain a blank unit name"
316 )));
317 }
318 if *factor == 0 {
319 return Err(TixError::Config(format!(
320 "`kanban.{board}.wip.units.{name}` must be at least 1"
321 )));
322 }
323 }
324 for (column, limit) in &self.limits {
325 let key = format!("kanban.{board}.wip.limits.`{column}`");
326 if !columns.contains(column) {
327 return Err(TixError::Config(format!(
328 "`{key}` names a column that is not in `kanban.{board}.columns`"
329 )));
330 }
331 if limit.max_items == Some(0) {
332 return Err(TixError::Config(format!(
333 "`{key}.max_items` must be at least 1"
334 )));
335 }
336 if limit.max_estimate == Some(0) {
337 return Err(TixError::Config(format!(
338 "`{key}.max_estimate` must be at least 1"
339 )));
340 }
341 if let Some(unit) = &limit.unit
342 && !self.units.contains_key(unit)
343 {
344 return Err(TixError::Config(format!(
345 "`{key}.unit` (`{unit}`) is not declared in \
346 `kanban.{board}.wip.units`"
347 )));
348 }
349 if limit.max_estimate.is_some() && limit.unit.is_none() {
350 return Err(TixError::Config(format!(
351 "`{key}.unit` is required with `max_estimate`: it says what the \
352 ceiling counts"
353 )));
354 }
355 if limit.max_estimate.is_none() && limit.unit.is_some() {
356 return Err(TixError::Config(format!(
357 "`{key}.unit` is only meaningful with `max_estimate`"
358 )));
359 }
360 if limit.enabled {
361 if limit.max_items.is_none() && limit.max_estimate.is_none() {
362 return Err(TixError::Config(format!(
363 "`{key}` is enabled but declares neither `max_items` nor \
364 `max_estimate`"
365 )));
366 }
367 if limit.policy.is_none() {
368 return Err(TixError::Config(format!(
369 "`{key}.policy` is required: say whether the limit `warn`s or \
370 `block`s (C-004)"
371 )));
372 }
373 }
374 }
375 Ok(())
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct Estimate {
382 pub value: u32,
384 pub unit: Option<String>,
386}
387
388impl Estimate {
389 pub fn parse(raw: &str) -> std::result::Result<Self, WipError> {
401 let text = raw.trim();
402 let mut terms = Self::parse_terms(text)?;
403 if terms.len() != 1 {
404 return Err(Self::malformed(format!(
405 "`{text}` states more than one estimate term"
406 )));
407 }
408 Ok(terms.remove(0))
409 }
410
411 pub fn parse_terms(raw: &str) -> std::result::Result<Vec<Self>, WipError> {
440 let text = raw.trim();
441 match Self::parse_terms_strict(text) {
442 Ok(terms) => Ok(terms),
443 Err(whole_error) => {
444 if !text.contains(',') {
449 return Err(whole_error);
450 }
451 let mut terms = Vec::new();
452 for segment in text.split(',') {
453 if let Ok(mut parsed) = Self::parse_terms_strict(segment.trim()) {
454 terms.append(&mut parsed);
455 }
456 }
457 if terms.is_empty() {
458 return Err(whole_error);
459 }
460 if terms.len() > 1 && terms.iter().any(|term| term.unit.is_none()) {
461 return Err(Self::malformed(format!(
462 "`{text}` mixes a bare number with unit terms"
463 )));
464 }
465 Ok(terms)
466 }
467 }
468 }
469
470 fn parse_terms_strict(text: &str) -> std::result::Result<Vec<Self>, WipError> {
475 let mut rest = text;
476 let mut terms = Vec::new();
477 while !rest.is_empty() {
478 rest = rest.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
479 if rest.is_empty() {
480 break;
481 }
482 let (term, tail) = Self::parse_term(text, rest)?;
483 terms.push(term);
484 rest = tail;
485 }
486 if terms.is_empty() {
487 return Err(Self::malformed(format!(
488 "`{text}` is not `<number>` or `<number><unit>`"
489 )));
490 }
491 if terms.len() > 1 && terms.iter().any(|term| term.unit.is_none()) {
492 return Err(Self::malformed(format!(
493 "`{text}` mixes a bare number with unit terms"
494 )));
495 }
496 Ok(terms)
497 }
498
499 fn parse_term<'a>(text: &str, rest: &'a str) -> std::result::Result<(Self, &'a str), WipError> {
501 let (low, mut tail) = Self::parse_decimal(rest).ok_or_else(|| {
502 Self::malformed(format!(
503 "`{text}` is not a duration such as `<number><unit>` or `<low>-<high><unit>`"
504 ))
505 })?;
506 tail = tail.trim_start_matches([' ', '\t']);
507 let mut high = None;
508 if let Some(after_dash) = tail.strip_prefix('-') {
509 let after_dash = after_dash.trim_start_matches([' ', '\t']);
510 let (value, after_high) = Self::parse_decimal(after_dash).ok_or_else(|| {
511 Self::malformed(format!("`{text}` starts a range but has no upper bound"))
512 })?;
513 high = Some(value);
514 tail = after_high.trim_start_matches([' ', '\t']);
515 }
516 let unit: String = tail
517 .chars()
518 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
519 .collect();
520 if unit.is_empty() {
521 if tail.is_empty() || tail.starts_with(',') {
525 let value = Self::pessimistic(low, high, text)?;
526 return Ok((Self { value, unit: None }, tail));
527 }
528 return Err(Self::malformed(format!(
529 "`{text}` is not `<number>` or `<number><unit>`"
530 )));
531 }
532 if !is_unit_name(&unit) {
533 return Err(Self::malformed(format!(
534 "`{text}` has an invalid unit `{unit}`"
535 )));
536 }
537 let value = Self::pessimistic(low, high, text)?;
538 Ok((
539 Self {
540 value,
541 unit: Some(canonical_unit_word(&unit)),
542 },
543 &tail[unit.len()..],
544 ))
545 }
546
547 fn parse_decimal(rest: &str) -> Option<(f64, &str)> {
549 let bytes = rest.as_bytes();
550 let mut end = 0;
551 while end < bytes.len() && bytes[end].is_ascii_digit() {
552 end += 1;
553 }
554 if end == 0 {
555 return None;
556 }
557 if end < bytes.len() && bytes[end] == b'.' {
558 let fraction_start = end + 1;
559 let mut fraction_end = fraction_start;
560 while fraction_end < bytes.len() && bytes[fraction_end].is_ascii_digit() {
561 fraction_end += 1;
562 }
563 if fraction_end == fraction_start {
564 return None;
565 }
566 end = fraction_end;
567 }
568 let value = rest.get(..end)?.parse::<f64>().ok()?;
569 value.is_finite().then_some((value, &rest[end..]))
570 }
571
572 fn pessimistic(low: f64, high: Option<f64>, text: &str) -> std::result::Result<u32, WipError> {
575 let value = high.map_or(low, |upper| low.max(upper)).ceil();
576 if value > f64::from(u32::MAX) {
577 return Err(Self::malformed(format!(
578 "`{text}` is not a number this board can count"
579 )));
580 }
581 Ok(value as u32)
582 }
583
584 fn malformed(message: String) -> WipError {
585 WipError::single(WipDiagnostic::new(
586 WipDiagnosticKind::MalformedEstimate,
587 message,
588 ))
589 }
590
591 pub fn render(&self) -> String {
593 match &self.unit {
594 Some(unit) => format!("{}{unit}", self.value),
595 None => self.value.to_string(),
596 }
597 }
598}
599
600fn canonical_unit_word(unit: &str) -> String {
605 if unit.chars().count() == 1 {
606 return unit.to_string();
607 }
608 match unit.to_ascii_lowercase().as_str() {
609 "day" | "days" => "d",
610 "week" | "weeks" | "wk" | "wks" => "w",
611 "hour" | "hours" | "hr" | "hrs" => "h",
612 "minute" | "minutes" | "min" | "mins" => "m",
613 "second" | "seconds" | "sec" | "secs" => "s",
614 _ => unit,
615 }
616 .to_string()
617}
618
619fn is_unit_name(text: &str) -> bool {
621 let mut chars = text.chars();
622 let Some(first) = chars.next() else {
623 return false;
624 };
625 first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
633pub enum WipDiagnosticKind {
634 MalformedEstimate,
636 MissingConversion,
638 AmbiguousConversion,
640 UnknownUnit,
642 OverLimit,
644 OverrideApplied,
646 OverrideRequired,
648 UnconfirmedOverride,
650 MissingOverrideField,
652 StaleOverrideLimit,
654 RejectedTransition,
656}
657
658impl WipDiagnosticKind {
659 pub const fn as_str(self) -> &'static str {
661 match self {
662 Self::MalformedEstimate => "malformed_estimate",
663 Self::MissingConversion => "missing_conversion",
664 Self::AmbiguousConversion => "ambiguous_conversion",
665 Self::UnknownUnit => "unknown_unit",
666 Self::OverLimit => "over_limit",
667 Self::OverrideApplied => "override_applied",
668 Self::OverrideRequired => "override_required",
669 Self::UnconfirmedOverride => "unconfirmed_override",
670 Self::MissingOverrideField => "missing_override_field",
671 Self::StaleOverrideLimit => "stale_override_limit",
672 Self::RejectedTransition => "rejected_transition",
673 }
674 }
675}
676
677#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct WipDiagnostic {
680 pub kind: WipDiagnosticKind,
682 pub message: String,
684 pub because: Option<String>,
686}
687
688impl WipDiagnostic {
689 pub fn new(kind: WipDiagnosticKind, message: impl Into<String>) -> Self {
691 Self {
692 kind,
693 message: message.into(),
694 because: None,
695 }
696 }
697
698 pub fn because(mut self, because: impl Into<String>) -> Self {
700 self.because = Some(because.into());
701 self
702 }
703
704 pub fn render(&self) -> String {
706 let mut pairs = vec![(self.kind.as_str(), self.message.as_str())];
707 if let Some(because) = self.because.as_deref() {
708 pairs.push((self.kind.as_str(), because));
709 }
710 crate::error::render_diagnostics("E-tix-wip", pairs)
711 }
712}
713
714#[derive(Debug, Clone, PartialEq, Eq)]
716pub struct WipError {
717 diagnostics: Vec<WipDiagnostic>,
718}
719
720impl WipError {
721 pub fn new(diagnostics: Vec<WipDiagnostic>) -> Self {
723 Self { diagnostics }
724 }
725
726 pub fn single(diagnostic: WipDiagnostic) -> Self {
728 Self {
729 diagnostics: vec![diagnostic],
730 }
731 }
732
733 pub fn diagnostics(&self) -> &[WipDiagnostic] {
735 &self.diagnostics
736 }
737
738 pub fn first(&self) -> &WipDiagnostic {
740 self.diagnostics
741 .first()
742 .expect("a WipError carries at least one diagnostic")
743 }
744
745 pub fn because(self, because: impl Into<String>) -> Self {
747 let mut diagnostics = self.diagnostics;
748 if let Some(first) = diagnostics.first_mut() {
749 first.because = Some(because.into());
750 }
751 Self { diagnostics }
752 }
753
754 pub fn render(&self) -> String {
756 self.diagnostics
757 .iter()
758 .map(WipDiagnostic::render)
759 .collect::<Vec<_>>()
760 .join("\n")
761 }
762}
763
764impl std::fmt::Display for WipError {
765 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
766 f.write_str(&self.render())
767 }
768}
769
770impl std::error::Error for WipError {}
771
772#[cfg(test)]
773mod tests {
774 #[allow(clippy::wildcard_imports)]
775 use super::*;
776
777 fn board_columns() -> Vec<String> {
778 ["todo", "in progress", "in review", "done"]
779 .into_iter()
780 .map(String::from)
781 .collect()
782 }
783
784 fn units(pairs: &[(&str, u32)]) -> IndexMap<String, u32> {
785 pairs
786 .iter()
787 .map(|(name, factor)| ((*name).to_string(), *factor))
788 .collect()
789 }
790
791 fn configured() -> WipConfig {
793 WipConfig {
794 units: units(&[("d", 1), ("w", 5)]),
795 limits: IndexMap::from([(
796 "in progress".to_string(),
797 ColumnLimit {
798 max_items: Some(3),
799 max_estimate: Some(10),
800 unit: Some("d".to_string()),
801 policy: Some(LimitPolicy::Block),
802 enabled: true,
803 },
804 )]),
805 }
806 }
807
808 #[test]
809 fn wip_config_absent_limits_are_absent() {
810 let empty = WipConfig::default();
811 assert!(empty.is_empty());
812 assert!(!empty.has_enabled_limits());
813 assert!(empty.effective_limit("in progress").is_none());
814 let toml = toml::to_string(&empty).expect("an empty section serializes");
815 assert_eq!(toml.trim(), "", "an empty section writes no keys: {toml:?}");
816 assert!(ColumnLimit::default().is_empty());
817 }
818
819 #[test]
820 fn wip_config_round_trips_through_toml() {
821 let config = configured();
822 config.validate("default", &board_columns()).expect("valid");
823 let text = toml::to_string(&config).expect("serializes");
824 let back: WipConfig = toml::from_str(&text).expect("deserializes");
825 assert_eq!(back, config, "round trip: {text}");
826 assert!(back.has_enabled_limits());
827 }
828
829 #[test]
830 fn wip_config_omits_defaults_on_save() {
831 let text = "[limits.\"in progress\"]\nmax_items = 3\npolicy = \"warn\"\n";
834 let config: WipConfig = toml::from_str(text).expect("loads");
835 assert!(config.limits["in progress"].enabled, "default on");
836 assert_eq!(toml::to_string(&config).expect("saves"), text);
837 }
838
839 #[test]
840 fn wip_config_rejects_an_unknown_column() {
841 let mut config = configured();
842 config.limits.insert(
843 "shipped".to_string(),
844 ColumnLimit::items(1, LimitPolicy::Warn),
845 );
846 let error = config
847 .validate("default", &board_columns())
848 .expect_err("an unknown column is refused")
849 .to_string();
850 assert!(
851 error.contains("kanban.default.wip.limits.`shipped`"),
852 "{error}"
853 );
854 assert!(error.contains("columns"), "{error}");
855 }
856
857 #[test]
858 fn wip_config_rejects_an_enabled_limit_that_limits_nothing() {
859 let mut config = configured();
860 config.limits.insert(
861 "in review".to_string(),
862 ColumnLimit {
863 policy: Some(LimitPolicy::Warn),
864 ..ColumnLimit::default()
865 },
866 );
867 let error = config
868 .validate("default", &board_columns())
869 .expect_err("an enabled empty limit is refused")
870 .to_string();
871 assert!(
872 error.contains("neither `max_items` nor `max_estimate`"),
873 "{error}"
874 );
875 }
876
877 #[test]
878 fn wip_config_requires_a_policy_and_a_unit() {
879 let mut missing_policy = configured();
880 missing_policy.limits["in progress"].policy = None;
881 let error = missing_policy
882 .validate("default", &board_columns())
883 .expect_err("a policy is required")
884 .to_string();
885 assert!(error.contains("policy` is required"), "{error}");
886
887 let mut missing_unit = configured();
888 missing_unit.limits["in progress"].unit = None;
889 let error = missing_unit
890 .validate("default", &board_columns())
891 .expect_err("a unit is required with max_estimate")
892 .to_string();
893 assert!(error.contains("is required with `max_estimate`"), "{error}");
894
895 let mut unit_without_estimate = configured();
896 unit_without_estimate.limits["in progress"].max_estimate = None;
897 let error = unit_without_estimate
898 .validate("default", &board_columns())
899 .expect_err("a unit without an estimate is refused")
900 .to_string();
901 assert!(
902 error.contains("only meaningful with `max_estimate`"),
903 "{error}"
904 );
905 }
906
907 #[test]
908 fn wip_config_rejects_undeclared_units_and_zero_factors() {
909 let mut undeclared = configured();
910 undeclared.limits["in progress"].unit = Some("q".to_string());
911 let error = undeclared
912 .validate("default", &board_columns())
913 .expect_err("an undeclared unit is refused")
914 .to_string();
915 assert!(error.contains("`q`"), "{error}");
916 assert!(error.contains("wip.units"), "{error}");
917
918 let mut zero = configured();
919 zero.units.insert("z".to_string(), 0);
920 let error = zero
921 .validate("default", &board_columns())
922 .expect_err("a zero factor is refused")
923 .to_string();
924 assert!(error.contains("wip.units.z"), "{error}");
925 }
926
927 #[test]
928 fn wip_config_zero_ceilings_are_refused() {
929 for key in ["max_items", "max_estimate"] {
930 let mut config = configured();
931 let limit = config.limits.get_mut("in progress").expect("the limit");
932 match key {
933 "max_items" => limit.max_items = Some(0),
934 _ => limit.max_estimate = Some(0),
935 }
936 let error = config
937 .validate("default", &board_columns())
938 .expect_err("a zero ceiling is refused")
939 .to_string();
940 assert!(error.contains(key), "{error}");
941 assert!(error.contains("at least 1"), "{error}");
942 }
943 }
944
945 #[test]
946 fn wip_config_disabled_limit_is_validated_but_not_effective() {
947 let mut config = configured();
948 config.limits["in progress"].enabled = false;
949 config.limits["in progress"].policy = None;
950 config
951 .validate("default", &board_columns())
952 .expect("a disabled limit may state no policy");
953 assert!(config.effective_limit("in progress").is_none());
954 assert!(!config.has_enabled_limits());
955 assert!(
956 config.limits.contains_key("in progress"),
957 "the numbers stay for later"
958 );
959 }
960
961 #[test]
962 fn wip_config_estimate_parsing_is_strict_but_total() {
963 assert_eq!(
964 Estimate::parse(" 3d ").expect("a unit"),
965 Estimate {
966 value: 3,
967 unit: Some("d".to_string())
968 }
969 );
970 assert_eq!(
971 Estimate::parse("12").expect("a bare number"),
972 Estimate {
973 value: 12,
974 unit: None
975 }
976 );
977 assert_eq!(
978 Estimate::parse("5story-points").expect("a hyphenated unit"),
979 Estimate {
980 value: 5,
981 unit: Some("story-points".to_string())
982 }
983 );
984 assert_eq!(
985 Estimate::parse("1 day").expect("a prose unit"),
986 Estimate {
987 value: 1,
988 unit: Some("d".to_string())
989 }
990 );
991 assert_eq!(
992 Estimate::parse("2 DAYS").expect("case-insensitive prose"),
993 Estimate {
994 value: 2,
995 unit: Some("d".to_string())
996 }
997 );
998 assert_eq!(
999 Estimate::parse("3S").expect("a one-letter unit keeps its case"),
1000 Estimate {
1001 value: 3,
1002 unit: Some("S".to_string())
1003 }
1004 );
1005 assert_eq!(
1006 Estimate::parse("3.5").expect("a fraction rounds up"),
1007 Estimate {
1008 value: 4,
1009 unit: None
1010 }
1011 );
1012 for bad in [
1013 "",
1014 "twenty",
1015 "-2",
1016 "d3",
1017 "3d!",
1018 "3 2h",
1019 "1 day 2",
1020 "99999999999999",
1021 ] {
1022 let error = Estimate::parse(bad).expect_err("refused: {bad}");
1023 assert_eq!(error.first().kind, WipDiagnosticKind::MalformedEstimate);
1024 }
1025 }
1026
1027 #[test]
1028 fn wip_config_estimate_parses_compound_durations_term_by_term() {
1029 let terms = Estimate::parse_terms("3 weeks 2 hours").expect("two terms");
1030 assert_eq!(
1031 terms,
1032 vec![
1033 Estimate {
1034 value: 3,
1035 unit: Some("w".to_string())
1036 },
1037 Estimate {
1038 value: 2,
1039 unit: Some("h".to_string())
1040 },
1041 ]
1042 );
1043 assert_eq!(
1044 Estimate::parse_terms("1d, 30 min").expect("comma-separated"),
1045 vec![
1046 Estimate {
1047 value: 1,
1048 unit: Some("d".to_string())
1049 },
1050 Estimate {
1051 value: 30,
1052 unit: Some("m".to_string())
1053 },
1054 ]
1055 );
1056 assert!(
1057 Estimate::parse("3 weeks 2 hours").is_err(),
1058 "the single-term entry point still names one term"
1059 );
1060 }
1061
1062 #[test]
1063 fn wip_config_estimate_ranges_are_pessimistic() {
1064 assert_eq!(
1065 Estimate::parse("0.5-1 day").expect("a range"),
1066 Estimate {
1067 value: 1,
1068 unit: Some("d".to_string())
1069 }
1070 );
1071 assert_eq!(
1072 Estimate::parse("0.5 day").expect("a fraction rounds up"),
1073 Estimate {
1074 value: 1,
1075 unit: Some("d".to_string())
1076 }
1077 );
1078 assert_eq!(
1079 Estimate::parse("2.1-2.2d").expect("the upper bound, rounded up"),
1080 Estimate {
1081 value: 3,
1082 unit: Some("d".to_string())
1083 }
1084 );
1085 assert_eq!(
1086 Estimate::parse("2-1 day").expect("the larger bound wins"),
1087 Estimate {
1088 value: 2,
1089 unit: Some("d".to_string())
1090 }
1091 );
1092 }
1093
1094 #[test]
1095 fn wip_config_estimate_ignores_unreadable_comma_annotations() {
1096 assert_eq!(
1097 Estimate::parse_terms("~300 LOC, 0.5-1 day").expect("the estimate segment"),
1098 vec![Estimate {
1099 value: 1,
1100 unit: Some("d".to_string())
1101 }]
1102 );
1103 assert_eq!(
1104 Estimate::parse_terms("3d, best guess").expect("the first segment"),
1105 vec![Estimate {
1106 value: 3,
1107 unit: Some("d".to_string())
1108 }]
1109 );
1110 assert!(
1111 Estimate::parse_terms("~300 LOC").is_err(),
1112 "no readable segment means malformed"
1113 );
1114 }
1115
1116 #[test]
1117 fn wip_config_sums_compound_estimates_through_the_board_units() {
1118 let config = WipConfig {
1119 units: units(&[("h", 1), ("d", 8), ("w", 40)]),
1120 limits: IndexMap::new(),
1121 };
1122 let terms = Estimate::parse_terms("3 weeks 2 hours").expect("parses");
1123 assert_eq!(
1124 config.convert_terms(&terms, "h").expect("exact"),
1125 3 * 5 * 8 + 2,
1126 "a prose estimate is board-relative, never a clock-ratio guess"
1127 );
1128 let terms = Estimate::parse_terms("1 day 30 fortnights").expect("parses");
1130 let error = config
1131 .convert_terms(&terms, "h")
1132 .expect_err("the second unit is undeclared");
1133 assert_eq!(error.first().kind, WipDiagnosticKind::MissingConversion);
1134 }
1135
1136 #[test]
1137 fn wip_config_converts_between_declared_units() {
1138 let config = configured();
1139 let weeks = Estimate {
1140 value: 1,
1141 unit: Some("w".to_string()),
1142 };
1143 assert_eq!(config.convert(&weeks, "d").expect("1w is 5d"), 5);
1144 assert_eq!(
1145 config
1146 .convert(&Estimate::parse("10d").expect("parses"), "d")
1147 .expect("same unit"),
1148 10
1149 );
1150 assert_eq!(
1151 config
1152 .convert(&Estimate::parse("7").expect("parses"), "w")
1153 .expect("bare means the target unit"),
1154 7
1155 );
1156 let half_weeks = Estimate {
1157 value: 5,
1158 unit: Some("w".to_string()),
1159 };
1160 assert_eq!(
1161 config.convert(&half_weeks, "w").expect("same unit"),
1162 5,
1163 "5w is 5w"
1164 );
1165 assert_eq!(config.convert(&half_weeks, "d").expect("25 points"), 25);
1166 }
1167
1168 #[test]
1169 fn wip_config_refuses_a_missing_conversion() {
1170 let config = configured();
1171 let points = Estimate {
1172 value: 3,
1173 unit: Some("p".to_string()),
1174 };
1175 let error = config.convert(&points, "d").expect_err("undeclared unit");
1176 assert_eq!(error.first().kind, WipDiagnosticKind::MissingConversion);
1177 let rendered = error.render();
1178 assert!(rendered.contains("missing_conversion"), "{rendered}");
1179 assert!(rendered.contains("`3p`"), "{rendered}");
1180
1181 let error = config
1182 .convert(&Estimate::parse("3").expect("parses"), "q")
1183 .expect_err("an undeclared target unit");
1184 assert_eq!(error.first().kind, WipDiagnosticKind::UnknownUnit);
1185 }
1186
1187 #[test]
1188 fn wip_config_refuses_a_fractional_conversion() {
1189 let config = WipConfig {
1192 units: units(&[("h", 1), ("d", 8), ("w", 40)]),
1193 limits: IndexMap::new(),
1194 };
1195 let error = config
1196 .convert(&Estimate::parse("3d").expect("parses"), "w")
1197 .expect_err("24 is not a whole number of 5");
1198 assert_eq!(error.first().kind, WipDiagnosticKind::AmbiguousConversion);
1199 let rendered = error.render();
1200 assert!(rendered.contains("ambiguous_conversion"), "{rendered}");
1201 assert!(rendered.contains("24 base points"), "{rendered}");
1202 assert_eq!(
1204 config
1205 .convert(&Estimate::parse("3d").expect("parses"), "h")
1206 .expect("24h"),
1207 24
1208 );
1209 }
1210
1211 #[test]
1212 fn wip_config_policy_spellings_are_stable() {
1213 assert_eq!(LimitPolicy::Warn.as_str(), "warn");
1214 assert_eq!(LimitPolicy::Block.as_str(), "block");
1215 let limit = configured().limits["in progress"].clone();
1216 assert_eq!(limit.resolved_policy(), Some(LimitPolicy::Block));
1217 let disabled = ColumnLimit {
1218 enabled: false,
1219 ..limit
1220 };
1221 assert_eq!(disabled.resolved_policy(), None);
1222 }
1223}