use std::collections::{HashMap, HashSet};
use std::fmt;
use crate::error::{Result, TixError};
use crate::model::{
History, HistoryEntry, HistoryEvent, HistoryEventKind, TransitionDecision, validate_date,
};
use crate::planning::Planner;
use crate::workflow::WorkflowCategory;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ScopeKind {
Cycle,
Milestone,
}
impl ScopeKind {
pub const ALL: [ScopeKind; 2] = [ScopeKind::Cycle, ScopeKind::Milestone];
pub const fn as_str(self) -> &'static str {
match self {
ScopeKind::Cycle => "cycle",
ScopeKind::Milestone => "milestone",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"cycle" => Some(ScopeKind::Cycle),
"milestone" => Some(ScopeKind::Milestone),
_ => None,
}
}
}
impl fmt::Display for ScopeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Period {
pub start: Option<String>,
pub end: Option<String>,
}
impl Period {
pub fn new(start: Option<String>, end: Option<String>) -> Result<Self> {
let period = Self { start, end };
for (field, value) in [("start", &period.start), ("end", &period.end)] {
if let Some(date) = value
&& !validate_date(date)
{
return Err(TixError::Config(format!(
"invalid_period: `{field}` is `{date}`, not a `YYYY-MM-DD` calendar date"
)));
}
}
if let (Some(start), Some(end)) = (&period.start, &period.end)
&& start > end
{
return Err(TixError::Config(format!(
"invalid_period: start `{start}` is after end `{end}`"
)));
}
Ok(period)
}
pub fn unbounded() -> Self {
Self::default()
}
pub fn is_bounded(&self) -> bool {
self.start.is_some() || self.end.is_some()
}
pub fn contains(&self, date: &str) -> bool {
if !validate_date(date) {
return false;
}
if let Some(start) = self.start.as_deref()
&& date < start
{
return false;
}
if let Some(end) = self.end.as_deref()
&& date > end
{
return false;
}
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnalyticsScope {
pub kind: ScopeKind,
pub id: String,
pub period: Period,
pub snapshot_id: Option<String>,
}
impl AnalyticsScope {
pub fn new(kind: ScopeKind, id: impl Into<String>, period: Period) -> Result<Self> {
let id = id.into();
if id.trim().is_empty() {
return Err(TixError::Config(format!(
"invalid_scope: a {} scope needs a name",
kind.as_str()
)));
}
Ok(Self {
kind,
id,
period,
snapshot_id: None,
})
}
pub fn cycle(id: impl Into<String>, period: Period) -> Result<Self> {
Self::new(ScopeKind::Cycle, id, period)
}
pub fn milestone(id: impl Into<String>, period: Period) -> Result<Self> {
Self::new(ScopeKind::Milestone, id, period)
}
pub fn with_snapshot_id(mut self, snapshot_id: impl Into<String>) -> Self {
let snapshot_id = snapshot_id.into();
self.snapshot_id = (!snapshot_id.trim().is_empty()).then_some(snapshot_id);
self
}
pub fn period_start(&self) -> Option<&str> {
self.period.start.as_deref()
}
pub fn period_end(&self) -> Option<&str> {
self.period.end.as_deref()
}
pub fn is_closed(&self) -> bool {
self.snapshot_id.is_some() || self.period.end.is_some()
}
pub fn contains(&self, date: &str) -> bool {
self.period.contains(date)
}
}
impl fmt::Display for AnalyticsScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let start = self.period_start().unwrap_or("open");
let end = self.period_end().unwrap_or("open");
write!(f, "{} `{}` {start}..{end}", self.kind, self.id)?;
if let Some(snapshot_id) = self.snapshot_id.as_deref() {
write!(f, " snapshot {snapshot_id}")?;
}
Ok(())
}
}
pub fn cycle_scope(planner: &Planner, name: &str) -> Result<AnalyticsScope> {
let cycle = planner
.cycle(name)
.ok_or_else(|| TixError::NotFound(format!("cycle `{name}`")))?;
let period = Period::new(cycle.start_date.clone(), cycle.due_date.clone())?;
AnalyticsScope::cycle(name, period)
}
pub fn milestone_scope(planner: &Planner, name: &str) -> Result<AnalyticsScope> {
let milestone = planner.milestone(name)?;
let target = milestone.target_date.trim();
let end = (!target.is_empty()).then(|| target.to_string());
let period = Period::new(None, end)?;
AnalyticsScope::milestone(name, period)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EventClass {
ScopeAddition,
Completion,
EstimateChange,
Unclassified,
}
impl EventClass {
pub const ALL: [EventClass; 4] = [
EventClass::ScopeAddition,
EventClass::Completion,
EventClass::EstimateChange,
EventClass::Unclassified,
];
pub const fn as_str(self) -> &'static str {
match self {
EventClass::ScopeAddition => "scope_addition",
EventClass::Completion => "completion",
EventClass::EstimateChange => "estimate_change",
EventClass::Unclassified => "unclassified",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"scope_addition" => Some(EventClass::ScopeAddition),
"completion" => Some(EventClass::Completion),
"estimate_change" => Some(EventClass::EstimateChange),
"unclassified" => Some(EventClass::Unclassified),
_ => None,
}
}
pub const fn is_classified(self) -> bool {
!matches!(self, EventClass::Unclassified)
}
}
impl fmt::Display for EventClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EventSource {
History,
Snapshot,
Frontmatter,
}
impl EventSource {
pub const ALL: [EventSource; 3] = [
EventSource::History,
EventSource::Snapshot,
EventSource::Frontmatter,
];
pub const fn as_str(self) -> &'static str {
match self {
EventSource::History => "history",
EventSource::Snapshot => "snapshot",
EventSource::Frontmatter => "frontmatter",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"history" => Some(EventSource::History),
"snapshot" => Some(EventSource::Snapshot),
"frontmatter" => Some(EventSource::Frontmatter),
_ => None,
}
}
}
impl fmt::Display for EventSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnalyticsEvent {
pub event_id: String,
pub date: Option<String>,
pub class: EventClass,
pub issue: Option<u32>,
pub scope_id: Option<String>,
pub delta: Option<i64>,
pub unit: Option<String>,
pub source: EventSource,
pub snapshot_id: Option<String>,
pub detail: String,
}
impl AnalyticsEvent {
pub fn from_history(
source_ref: &str,
index: usize,
scope: &AnalyticsScope,
event: &HistoryEvent,
) -> Self {
let (class, delta) = classify(event);
Self {
event_id: format!("{source_ref}#{index}"),
date: calendar_date(&event.timestamp),
class,
issue: source_ref.trim().parse::<u32>().ok(),
scope_id: Some(scope.id.clone()),
delta,
unit: None,
source: EventSource::History,
snapshot_id: None,
detail: event.detail.clone(),
}
}
pub fn from_snapshot(
snapshot_id: &str,
date: Option<&str>,
class: EventClass,
scope_id: Option<&str>,
delta: Option<i64>,
) -> Self {
Self {
event_id: format!("snapshot:{}", snapshot_id.trim()),
date: date
.map(str::trim)
.filter(|d| validate_date(d))
.map(str::to_string),
class,
issue: None,
scope_id: scope_id.map(str::to_string),
delta,
unit: None,
source: EventSource::Snapshot,
snapshot_id: Some(snapshot_id.trim().to_string()),
detail: String::new(),
}
}
pub fn is_on(&self, date: &str) -> bool {
self.date.as_deref() == Some(date)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Contribution {
Added,
Duplicate,
OutsidePeriod,
}
impl Contribution {
pub const fn as_str(self) -> &'static str {
match self {
Contribution::Added => "added",
Contribution::Duplicate => "duplicate",
Contribution::OutsidePeriod => "outside_period",
}
}
pub const fn contributed(self) -> bool {
matches!(self, Contribution::Added)
}
}
#[derive(Debug, Clone)]
pub struct EventIndex {
scope: AnalyticsScope,
order: Vec<String>,
events: HashMap<String, AnalyticsEvent>,
outside_ids: HashSet<String>,
duplicates: usize,
outside_period: usize,
}
impl EventIndex {
pub fn new(scope: AnalyticsScope) -> Self {
Self {
scope,
order: Vec::new(),
events: HashMap::new(),
outside_ids: HashSet::new(),
duplicates: 0,
outside_period: 0,
}
}
pub fn scope(&self) -> &AnalyticsScope {
&self.scope
}
pub fn contribute(&mut self, event: AnalyticsEvent) -> Contribution {
if self.events.contains_key(&event.event_id) || self.outside_ids.contains(&event.event_id) {
self.duplicates += 1;
return Contribution::Duplicate;
}
if let Some(date) = event.date.as_deref()
&& !self.scope.contains(date)
{
self.outside_ids.insert(event.event_id);
self.outside_period += 1;
return Contribution::OutsidePeriod;
}
self.order.push(event.event_id.clone());
self.events.insert(event.event_id.clone(), event);
Contribution::Added
}
pub fn contribute_history(&mut self, source_ref: &str, history: &History) -> usize {
let scope = self.scope.clone();
let mut added = 0;
let mut index = 0;
for entry in &history.entries {
let HistoryEntry::Event(event) = entry else {
continue;
};
let event = AnalyticsEvent::from_history(source_ref, index, &scope, event);
index += 1;
if self.contribute(event).contributed() {
added += 1;
}
}
added
}
pub fn events(&self) -> impl Iterator<Item = &AnalyticsEvent> + '_ {
self.order.iter().filter_map(|id| self.events.get(id))
}
pub fn of_class(&self, class: EventClass) -> impl Iterator<Item = &AnalyticsEvent> + '_ {
self.events().filter(move |event| event.class == class)
}
pub fn count_of(&self, class: EventClass) -> usize {
self.of_class(class).count()
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
pub fn duplicates(&self) -> usize {
self.duplicates
}
pub fn outside_period(&self) -> usize {
self.outside_period
}
}
pub fn calendar_date(timestamp: &str) -> Option<String> {
let stamp = timestamp.trim();
let date = stamp.get(..10)?;
validate_date(date).then(|| date.to_string())
}
fn classify(event: &HistoryEvent) -> (EventClass, Option<i64>) {
match event.kind {
HistoryEventKind::ParentSet => (EventClass::ScopeAddition, Some(1)),
HistoryEventKind::ParentChanged => (EventClass::ScopeAddition, None),
HistoryEventKind::CycleMembershipChanged => membership_outcome(&event.detail),
HistoryEventKind::EstimateChanged => (EventClass::EstimateChange, None),
HistoryEventKind::TransitionAccepted => completion_outcome(event),
_ => (EventClass::Unclassified, None),
}
}
fn completion_outcome(event: &HistoryEvent) -> (EventClass, Option<i64>) {
let Ok(decision) = TransitionDecision::from_event(event) else {
return (EventClass::Unclassified, None);
};
match WorkflowCategory::from_key(&decision.to_category) {
Some(category) if category.is_complete() => (EventClass::Completion, Some(1)),
_ => (EventClass::Unclassified, None),
}
}
fn membership_outcome(detail: &str) -> (EventClass, Option<i64>) {
let Some((before, after)) = membership_lists(detail) else {
return (EventClass::Unclassified, None);
};
if after.iter().any(|name| !before.contains(name)) {
(EventClass::ScopeAddition, None)
} else {
(EventClass::Unclassified, None)
}
}
fn membership_lists(detail: &str) -> Option<(Vec<String>, Vec<String>)> {
let rest = detail.trim().strip_prefix("cycles:")?;
let (before, after) = rest.split_once("->")?;
Some((membership_list(before), membership_list(after)))
}
fn membership_list(side: &str) -> Vec<String> {
let side = side.trim();
if side.is_empty() || side == "none" {
return Vec::new();
}
side.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::utc_now_rfc3339;
fn scope() -> AnalyticsScope {
AnalyticsScope::cycle("Sprint 7", Period::new(None, None).unwrap()).unwrap()
}
fn history_event(kind: HistoryEventKind, detail: &str) -> HistoryEvent {
HistoryEvent::new("2026-09-25T16:00:00Z", "alice", kind, detail)
}
#[test]
fn t005_the_period_bounds_are_validated_and_inclusive() {
let period = Period::new(Some("2026-09-01".into()), Some("2026-09-30".into())).unwrap();
assert!(period.contains("2026-09-01"), "start is inclusive");
assert!(period.contains("2026-09-30"), "end is inclusive");
assert!(!period.contains("2026-08-31"), "the day before is out");
assert!(!period.contains("2026-10-01"), "the day after is out");
assert!(
!period.contains("2026-09-1"),
"a malformed day inside the bounds is still not placed"
);
assert!(
!period.contains("2026-09-01T00:00:00Z"),
"a stamp is not a day either"
);
assert!(
!period.contains("not-a-date"),
"an unreadable day is not placed"
);
assert!(Period::unbounded().contains("1999-01-01"), "open is open");
let backwards = Period::new(Some("2026-09-30".into()), Some("2026-09-01".into()));
assert!(backwards.is_err(), "a backwards period is refused");
let malformed = Period::new(Some("2026-9-1".into()), None);
assert!(malformed.is_err(), "a non-canonical bound is refused");
}
#[test]
fn t006_a_blank_scope_name_is_refused() {
assert!(AnalyticsScope::cycle(" ", Period::unbounded()).is_err());
}
#[test]
fn t005_a_duplicate_event_contributes_once() {
let mut index = EventIndex::new(scope());
let event = history_event(HistoryEventKind::EstimateChanged, "estimate: none -> 3");
let classified = AnalyticsEvent::from_history("7", 0, index.scope(), &event);
assert_eq!(index.contribute(classified.clone()), Contribution::Added);
assert_eq!(
index.contribute(classified.clone()),
Contribution::Duplicate
);
assert_eq!(index.len(), 1, "the same event contributed once");
assert_eq!(index.duplicates(), 1);
assert_eq!(index.of_class(EventClass::EstimateChange).count(), 1);
}
#[test]
fn t001_the_scope_classifies_only_the_event_it_was_given() {
let index = EventIndex::new(scope());
let event = history_event(HistoryEventKind::ParentSet, "parent none -> #1");
let classified = AnalyticsEvent::from_history("7", 3, index.scope(), &event);
assert_eq!(classified.event_id, "7#3");
assert_eq!(classified.issue, Some(7));
assert_eq!(classified.scope_id.as_deref(), Some("Sprint 7"));
assert_eq!(classified.date.as_deref(), Some("2026-09-25"));
assert_eq!(classified.class, EventClass::ScopeAddition);
assert_eq!(classified.delta, Some(1));
assert_eq!(classified.source, EventSource::History);
}
#[test]
fn t005_an_unreadable_timestamp_is_no_day_rather_than_a_guess() {
assert_eq!(
calendar_date("2026-09-25T16:00:00Z").as_deref(),
Some("2026-09-25")
);
assert_eq!(
calendar_date("2026-13-45T00:00:00Z"),
None,
"not a real date"
);
assert_eq!(calendar_date("2026-09"), None, "truncated");
assert_eq!(calendar_date(""), None);
}
#[test]
fn t005_a_snapshot_contribution_is_identified_by_its_snapshot() {
let mut index = EventIndex::new(scope());
let event = AnalyticsEvent::from_snapshot(
"cs-1:000001@2026-09-30T00:00:00Z",
Some("2026-09-30"),
EventClass::Completion,
Some("cs-1:000001"),
Some(2),
);
assert_eq!(index.contribute(event.clone()), Contribution::Added);
assert_eq!(index.contribute(event), Contribution::Duplicate);
assert_eq!(index.len(), 1);
let stored = index.events().next().unwrap();
assert_eq!(stored.event_id, "snapshot:cs-1:000001@2026-09-30T00:00:00Z");
assert_eq!(stored.source, EventSource::Snapshot);
assert_eq!(
stored.snapshot_id.as_deref(),
Some("cs-1:000001@2026-09-30T00:00:00Z"),
"the closed snapshot's identity is retained verbatim"
);
}
#[test]
fn t005_a_time_mutation_kind_is_never_progress() {
for kind in [
HistoryEventKind::TimeEntryCreated,
HistoryEventKind::TimeEntryUpdated,
HistoryEventKind::TimeEntryDeleted,
] {
let event = history_event(kind, "entry t-1; created 30m on 2026-09-25 by alice");
let classified = AnalyticsEvent::from_history("7", 0, &scope(), &event);
assert_eq!(
classified.class,
EventClass::Unclassified,
"{kind} must not be read as progress"
);
assert_eq!(classified.delta, None);
}
}
#[test]
fn t003_a_parent_cleared_event_is_not_growth() {
let event = history_event(HistoryEventKind::ParentCleared, "parent #1 -> none");
let classified = AnalyticsEvent::from_history("7", 0, &scope(), &event);
assert_eq!(classified.class, EventClass::Unclassified);
assert_eq!(classified.delta, None, "a removal is never counted as +1");
}
#[test]
fn t003_a_membership_change_is_read_by_direction() {
for (detail, class) in [
("cycles: none -> Default", EventClass::ScopeAddition),
(
"cycles: Default -> Default, Sprint 7",
EventClass::ScopeAddition,
),
("cycles: Default -> none", EventClass::Unclassified),
(
"cycles: Default, Sprint 7 -> Default",
EventClass::Unclassified,
),
(
"cycles: Default, Sprint 7 -> Sprint 7, Default",
EventClass::Unclassified,
),
("mangled", EventClass::Unclassified),
("cycles: Default ->", EventClass::Unclassified),
] {
let event = history_event(HistoryEventKind::CycleMembershipChanged, detail);
let classified = AnalyticsEvent::from_history("7", 0, &scope(), &event);
assert_eq!(classified.class, class, "{detail}");
assert_eq!(classified.delta, None, "{detail}");
assert_eq!(classified.detail, detail, "the record is kept verbatim");
}
}
#[test]
fn t005_a_repeated_out_of_period_event_is_a_duplicate_not_a_second_exclusion() {
let period = Period::new(Some("2026-09-01".into()), Some("2026-09-30".into())).unwrap();
let bounded = AnalyticsScope::cycle("Sprint 7", period).unwrap();
let mut index = EventIndex::new(bounded);
let event = HistoryEvent::new(
"2026-10-05T00:00:00Z",
"alice",
HistoryEventKind::ParentSet,
"parent none -> #2",
);
let classified = AnalyticsEvent::from_history("1", 0, index.scope(), &event);
assert_eq!(
index.contribute(classified.clone()),
Contribution::OutsidePeriod
);
assert_eq!(
index.contribute(classified),
Contribution::Duplicate,
"one recorded event, however often it is classified"
);
assert_eq!(
index.outside_period(),
1,
"an event count, not a count of classifications"
);
assert_eq!(index.duplicates(), 1);
assert_eq!(index.len(), 0);
}
#[test]
fn t004_a_malformed_transition_record_is_not_a_completion() {
let event = history_event(HistoryEventKind::TransitionAccepted, "mangled");
let classified = AnalyticsEvent::from_history("7", 0, &scope(), &event);
assert_eq!(classified.class, EventClass::Unclassified);
assert_eq!(classified.detail, "mangled", "the raw record is kept");
}
#[test]
fn t005_the_round_trip_of_a_recorded_file_is_idempotent() {
let mut history = History::new();
history.append(HistoryEvent::new(
utc_now_rfc3339(),
"alice",
HistoryEventKind::EstimateChanged,
"estimate: none -> 3",
));
let mut index = EventIndex::new(scope());
assert_eq!(index.contribute_history("7", &history), 1);
assert_eq!(index.contribute_history("7", &history), 0, "already seen");
assert_eq!(index.len(), 1, "the same file contributes once");
}
}