tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Explicit estimate conversion: one issue's estimate, in the unit a calculation
//! counts in (mission `cycle-capacity-planning-01M3C85C`, WP02).
//!
//! # What an `EstimateValue` is
//!
//! The data model's §3 entity, and nothing more: the text the issue wrote
//! ([`raw`](EstimateValue::raw), preserved verbatim for diagnostics/export), the unit
//! it named ([`unit`](EstimateValue::unit), folded from a human word like `day` to the
//! board symbol `d`, else verbatim), the number when it could be read
//! ([`value`](EstimateValue::value)), the number **after an explicit
//! conversion** when one exists ([`converted_value`](EstimateValue::converted_value)),
//! and the ordered [`diagnostics`](EstimateValue::diagnostics) that say why a step did
//! not happen.
//!
//! # The doors this module keeps shut
//!
//! * **A missing or incompatible conversion is a diagnostic, never a number.** The
//!   arithmetic is [`crate::wip::WipConfig::convert`]'s — the WIP mission's
//!   per-board/team unit/conversion registry, reused and never re-declared — so an
//!   undeclared unit is `missing_conversion`, a unit the board does not know is
//!   `unknown_unit`, and a conversion that does not divide exactly is
//!   `ambiguous_conversion`. None of the three becomes zero, and none becomes the
//!   issue's raw number standing in for the target unit (C-004).
//! * **An unestimated issue stays unestimated.** [`EstimateValue::unestimated`] is a
//!   distinct value — `raw` is `None` — with no diagnostic, because stating no
//!   estimate is not an error. It is never silently counted as zero: a caller that
//!   sums must decide what to do about it, and [`is_unestimated`](EstimateValue::is_unestimated)
//!   is how it asks.
//! * **A blank estimate is unestimated, not invalid.** `estimate: ""` states nothing,
//!   which is what the WIP aggregate already decides ("No estimate is zero estimate"),
//!   so it reads the same here.
//!
//! # Diagnostic wording
//!
//! [`crate::wip::WipConfig::convert`] reports a unit finding with the board's own
//! words but no issue identity, and a finding nobody can locate is not actionable.
//! Every diagnostic this module produces therefore wraps the registry's sentence with
//! the issue number and the raw text — the same shape
//! [`crate::wip::aggregate`] uses — and keeps the registry's `kind` verbatim so every
//! surface branches on one vocabulary.

use crate::wip::{Estimate as WipEstimate, WipConfig, WipDiagnostic};

/// One issue's estimate as the data model defines it (data-model §3).
///
/// Built by [`parse`](Self::parse) (the unit-native reading) and turned into a
/// per-unit reading by [`converted_to`](Self::converted_to). Both are cheap and total:
/// neither returns an error, because "the estimate could not be counted" is a
/// diagnostic on a value that still exists, not a refusal that loses the raw text.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EstimateValue {
    /// The text the issue wrote, verbatim. `None` means the issue states no estimate
    /// at all — which stays visible as unestimated and is never zero.
    pub raw: Option<String>,
    /// The unit the issue named, when it named one. Never inferred: a bare `3` has no
    /// unit here, and whether it can be counted in a target unit is the registry's
    /// answer (`WipConfig::convert` treats a bare estimate as already in the target).
    ///
    /// A compound duration whose terms name different units (`3 weeks 2 hours`) has no
    /// single unit, so this is `None` even though the estimate is readable; the terms
    /// are what [`converted_to`](Self::converted_to) converts.
    pub unit: Option<String>,
    /// The number the issue stated when the statement reduces to one term. `None` for
    /// an unestimated issue, a malformed one, and a compound duration with mixed
    /// units — [`is_valid`](Self::is_valid) is the parse predicate, not this field.
    pub value: Option<u32>,
    /// The number after an explicit conversion, when one exists. `None` when the
    /// estimate was never converted, is unestimated, or the conversion was refused
    /// (the [`diagnostics`](Self::diagnostics) say which).
    pub converted_value: Option<u32>,
    /// What was wrong, in the order it was found: the parse findings first, then the
    /// conversion findings. Empty for an estimate that read and converted cleanly, and
    /// empty for an unestimated issue.
    pub diagnostics: Vec<WipDiagnostic>,
    /// The unit [`converted_value`](Self::converted_value) counts in.
    ///
    /// `Some` **exactly when** `converted_value` is `Some`, so the number is never
    /// presented without the unit it is expressed in.
    converted_in: Option<String>,
    /// Every term the statement was parsed into, in the order written. Empty for an
    /// unestimated or malformed statement. Kept so a compound duration is converted
    /// term by term through the board's units (tix #1724).
    terms: Vec<WipEstimate>,
}

impl EstimateValue {
    /// An issue that states no estimate.
    ///
    /// The explicit "none" the data model asks for: nothing is zero, nothing is
    /// guessed, and no diagnostic is raised. A caller summing a scope must count this
    /// separately (C-005).
    #[must_use]
    pub fn unestimated() -> Self {
        Self::default()
    }

    /// Read the free-form `estimate:` text an issue states (`raw`, verbatim).
    ///
    /// The text is preserved exactly as written and *parsed* from its trimmed form, so
    /// a stray space cannot turn an estimate into an unreadable one and cannot change
    /// the text a diagnostic quotes. `number` is the diagnostic's context only — the
    /// value carries no issue identity, but a finding that cannot be located is not
    /// actionable, so the message names the issue.
    ///
    /// A failure is a diagnostic, not a refusal: the returned value still carries
    /// `raw`, and `value`/`converted_value` stay `None`.
    #[must_use]
    pub fn parse(number: u32, raw: &str) -> Self {
        match WipEstimate::parse_terms(raw.trim()) {
            Ok(terms) => {
                // One term is the simple case the public fields describe. A compound
                // statement has no single stated number; its terms are converted and
                // summed only after each is read through the board's units.
                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(),
            },
        }
    }

    /// This estimate converted into `unit`, through the board's own registry.
    ///
    /// The one place a conversion happens. `converted_value` is set only on success;
    /// a refusal appends the registry's finding (re-worded to name the issue) and
    /// leaves the number absent, so an unconvertible estimate can never be summed as
    /// if it were counted (C-004).
    ///
    /// An unestimated issue — and an estimate whose number could not be read — is
    /// returned unchanged: there is nothing to convert, and its own diagnostic already
    /// says why.
    #[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() {
            // Unreadable: [`parse`](Self::parse) already recorded why, and there is no
            // number to convert.
            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
    }

    /// The text the issue wrote, verbatim.
    #[must_use]
    pub fn raw(&self) -> Option<&str> {
        self.raw.as_deref()
    }

    /// The unit the issue named, when it named one.
    #[must_use]
    pub fn unit(&self) -> Option<&str> {
        self.unit.as_deref()
    }

    /// The unit [`converted_value`](Self::converted_value) counts in.
    #[must_use]
    pub fn converted_in(&self) -> Option<&str> {
        self.converted_in.as_deref()
    }

    /// The findings, in the order they were produced.
    #[must_use]
    pub fn diagnostics(&self) -> &[WipDiagnostic] {
        &self.diagnostics
    }

    /// Whether the issue states no estimate. It is visible as unestimated, never zero.
    #[must_use]
    pub fn is_unestimated(&self) -> bool {
        self.raw.is_none()
    }

    /// Whether the text could be read as one or more estimate terms (conversion
    /// aside). A compound duration is valid even though `value` alone is `None`.
    #[must_use]
    pub fn is_valid(&self) -> bool {
        !self.terms.is_empty()
    }

    /// Whether an explicit conversion produced a number — the one predicate a summing
    /// caller may count on.
    #[must_use]
    pub fn is_converted(&self) -> bool {
        self.converted_value.is_some()
    }

    /// The estimate as it reads in a message: the text as written, or `unestimated`.
    #[must_use]
    pub fn render(&self) -> String {
        self.raw
            .clone()
            .unwrap_or_else(|| "unestimated".to_string())
    }
}

/// Wrap the registry's finding so it names the issue it came from (the crate's style).
///
/// The `kind` is kept verbatim: the machine-readable half stays the WIP mission's, so
/// `missing_conversion`/`ambiguous_conversion`/`unknown_unit`/`malformed_estimate` mean
/// one thing in every surface.
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, &registry(), "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, &registry(), "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, &registry(), "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, &registry(), "h");
        assert!(converted.converted_value.is_none());
        assert!(converted.converted_in().is_none());
    }
}