Skip to main content

imp_core/
mana_review.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use mana_core::unit::{Status, Unit, UnitType};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum ManaReviewState {
9    NoChange,
10    Changed,
11    NeedsDecision,
12}
13
14impl ManaReviewState {
15    pub fn as_str(&self) -> &'static str {
16        match self {
17            Self::NoChange => "no_change",
18            Self::Changed => "changed",
19            Self::NeedsDecision => "needs_decision",
20        }
21    }
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ManaReviewScopeKind {
27    None,
28    Project,
29    Root,
30    ExplicitPath,
31    Mixed,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct ManaReviewScope {
36    pub kind: ManaReviewScopeKind,
37    pub display: String,
38}
39
40impl Default for ManaReviewScope {
41    fn default() -> Self {
42        Self {
43            kind: ManaReviewScopeKind::None,
44            display: "none".to_string(),
45        }
46    }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
50pub struct ManaUnitRef {
51    pub id: String,
52    pub title: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub kind: Option<String>,
55}
56
57impl ManaUnitRef {
58    pub fn from_snapshot(unit: &ManaUnitSnapshot) -> Self {
59        Self {
60            id: unit.id.clone(),
61            title: unit.title.clone(),
62            kind: Some(unit.kind.as_str().to_string()),
63        }
64    }
65
66    pub fn new(id: impl Into<String>, title: impl Into<String>, kind: Option<String>) -> Self {
67        Self {
68            id: id.into(),
69            title: title.into(),
70            kind,
71        }
72    }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct ManaUnitSnapshot {
77    pub id: String,
78    pub title: String,
79    pub kind: ManaReviewUnitKind,
80    pub status: String,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub parent: Option<String>,
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub dependencies: Vec<String>,
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub labels: Vec<String>,
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub decisions: Vec<String>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub description: Option<String>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub acceptance: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub design: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub assignee: Option<String>,
97    pub priority: u8,
98    pub is_archived: bool,
99}
100
101impl ManaUnitSnapshot {
102    pub fn unit_ref(&self) -> ManaUnitRef {
103        ManaUnitRef::from_snapshot(self)
104    }
105}
106
107impl From<&Unit> for ManaUnitSnapshot {
108    fn from(unit: &Unit) -> Self {
109        Self {
110            id: unit.id.clone(),
111            title: unit.title.clone(),
112            kind: ManaReviewUnitKind::from_unit(unit),
113            status: unit.status.to_string(),
114            parent: unit.parent.clone(),
115            dependencies: unit.dependencies.clone(),
116            labels: unit.labels.clone(),
117            decisions: unit.decisions.clone(),
118            description: unit.description.clone(),
119            acceptance: unit.acceptance.clone(),
120            design: unit.design.clone(),
121            assignee: unit.assignee.clone(),
122            priority: unit.priority,
123            is_archived: unit.is_archived,
124        }
125    }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum ManaReviewUnitKind {
131    Epic,
132    Job,
133    Fact,
134}
135
136impl ManaReviewUnitKind {
137    pub fn as_str(&self) -> &'static str {
138        match self {
139            Self::Epic => "epic",
140            Self::Job => "job",
141            Self::Fact => "fact",
142        }
143    }
144
145    pub fn from_unit(unit: &Unit) -> Self {
146        match unit.kind {
147            UnitType::Epic => Self::Epic,
148            UnitType::Task => Self::Job,
149            UnitType::Fact => Self::Fact,
150        }
151    }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ManaMutationAction {
157    Create,
158    Close,
159    Update,
160    NotesAppend,
161    DecisionAdd,
162    DecisionResolve,
163    Reopen,
164    Fail,
165    Delete,
166    DepAdd,
167    DepRemove,
168    FactCreate,
169}
170
171impl ManaMutationAction {
172    pub fn as_str(&self) -> &'static str {
173        match self {
174            Self::Create => "create",
175            Self::Close => "close",
176            Self::Update => "update",
177            Self::NotesAppend => "notes_append",
178            Self::DecisionAdd => "decision_add",
179            Self::DecisionResolve => "decision_resolve",
180            Self::Reopen => "reopen",
181            Self::Fail => "fail",
182            Self::Delete => "delete",
183            Self::DepAdd => "dep_add",
184            Self::DepRemove => "dep_remove",
185            Self::FactCreate => "fact_create",
186        }
187    }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum ManaTouchKind {
193    Created,
194    Updated,
195    Closed,
196    Reopened,
197    Failed,
198    Deleted,
199    FactCreated,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum ManaUnitOrigin {
205    Preexisting,
206    CreatedInTurn,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum ManaUnitRole {
212    Anchor,
213    Child,
214    Fact,
215    DirectTarget,
216    Related,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum ManaAnchorKind {
222    ReusedExisting,
223    CreatedInTurn,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum ManaAnchorReason {
229    AttachedParent,
230    CreatedParent,
231    PrimaryTarget,
232    PrimaryFact,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236pub struct TurnManaAnchorUnit {
237    pub unit: ManaUnitRef,
238    pub anchor_kind: ManaAnchorKind,
239    pub reason: ManaAnchorReason,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct TurnManaTouchedUnit {
244    pub unit: ManaUnitRef,
245    pub touch_kind: ManaTouchKind,
246    pub unit_origin: ManaUnitOrigin,
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub roles: Vec<ManaUnitRole>,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct TurnManaProposedChild {
253    pub unit: ManaUnitRef,
254    pub parent: ManaUnitRef,
255    pub child_kind: ManaReviewUnitKind,
256    pub child_origin: ManaUnitOrigin,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "snake_case")]
261pub enum ManaFieldChangeKind {
262    Set,
263    Added,
264    Removed,
265    Replaced,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct TurnManaFieldChange {
270    pub unit: ManaUnitRef,
271    pub field: String,
272    pub change_kind: ManaFieldChangeKind,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub before: Option<String>,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub after: Option<String>,
277    pub source_action: String,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281pub struct TurnManaNoteAppend {
282    pub unit: ManaUnitRef,
283    pub appended_text: String,
284    pub source_action: String,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "snake_case")]
289pub enum ManaDecisionEventKind {
290    Added,
291    Resolved,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct TurnManaDecisionEvent {
296    pub unit: ManaUnitRef,
297    pub event_kind: ManaDecisionEventKind,
298    pub decision_text: String,
299    pub source_action: String,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304pub enum ManaConsequentialChoiceCategory {
305    OwnershipBoundary,
306    Architecture,
307    ExecutionLaunch,
308    ScopeChange,
309    PruneOrDelete,
310    Other,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct TurnManaConsequentialChoice {
315    pub unit: ManaUnitRef,
316    pub decision_text: String,
317    pub category: ManaConsequentialChoiceCategory,
318    pub why_consequential: String,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub suggested_question: Option<String>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct TurnManaReview {
325    pub turn_index: u32,
326    pub state: ManaReviewState,
327    pub scope: ManaReviewScope,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub anchor_unit: Option<TurnManaAnchorUnit>,
330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
331    pub touched_units: Vec<TurnManaTouchedUnit>,
332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
333    pub proposed_children: Vec<TurnManaProposedChild>,
334    #[serde(default, skip_serializing_if = "Vec::is_empty")]
335    pub material_field_changes: Vec<TurnManaFieldChange>,
336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
337    pub notes_appended: Vec<TurnManaNoteAppend>,
338    #[serde(default, skip_serializing_if = "Vec::is_empty")]
339    pub decision_events: Vec<TurnManaDecisionEvent>,
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub unresolved_consequential_choices: Vec<TurnManaConsequentialChoice>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub next_question: Option<String>,
344}
345
346impl TurnManaReview {
347    pub fn no_change(turn_index: u32) -> Self {
348        Self {
349            turn_index,
350            state: ManaReviewState::NoChange,
351            scope: ManaReviewScope::default(),
352            anchor_unit: None,
353            touched_units: Vec::new(),
354            proposed_children: Vec::new(),
355            material_field_changes: Vec::new(),
356            notes_appended: Vec::new(),
357            decision_events: Vec::new(),
358            unresolved_consequential_choices: Vec::new(),
359            next_question: None,
360        }
361    }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365pub struct ManaMutationRecord {
366    pub action: ManaMutationAction,
367    pub scope: ManaReviewScope,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub before_unit: Option<ManaUnitSnapshot>,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub after_unit: Option<ManaUnitSnapshot>,
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub deleted_unit: Option<ManaUnitRef>,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub parent_unit: Option<ManaUnitRef>,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub related_unit: Option<ManaUnitRef>,
378    #[serde(default, skip_serializing_if = "Vec::is_empty")]
379    pub field_changes: Vec<TurnManaFieldChange>,
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    pub notes_appended: Vec<TurnManaNoteAppend>,
382    #[serde(default, skip_serializing_if = "Vec::is_empty")]
383    pub decision_events: Vec<TurnManaDecisionEvent>,
384}
385
386#[derive(Debug, Default)]
387pub struct TurnManaReviewAccumulator {
388    turn_index: u32,
389    mutations: Vec<ManaMutationRecord>,
390}
391
392impl TurnManaReviewAccumulator {
393    pub fn begin_turn(&mut self, turn_index: u32) {
394        self.turn_index = turn_index;
395        self.mutations.clear();
396    }
397
398    pub fn push(&mut self, record: ManaMutationRecord) {
399        self.mutations.push(record);
400    }
401
402    pub fn finalize(&self) -> TurnManaReview {
403        if self.mutations.is_empty() {
404            return TurnManaReview::no_change(self.turn_index);
405        }
406
407        let scope = summarize_scope(&self.mutations);
408        let mut aggregates: BTreeMap<String, UnitAggregate> = BTreeMap::new();
409
410        for mutation in &self.mutations {
411            match mutation.action {
412                ManaMutationAction::Delete => {
413                    if let Some(unit) = &mutation.deleted_unit {
414                        let aggregate = aggregates
415                            .entry(unit.id.clone())
416                            .or_insert_with(|| UnitAggregate::new(unit.clone()));
417                        aggregate.deleted_in_turn = true;
418                        aggregate.touch_actions.insert(ManaTouchKind::Deleted);
419                        aggregate.roles.insert(ManaUnitRole::DirectTarget);
420                    }
421                }
422                _ => {
423                    let Some(unit) = mutation
424                        .after_unit
425                        .as_ref()
426                        .or(mutation.before_unit.as_ref())
427                    else {
428                        continue;
429                    };
430                    let unit_ref = unit.unit_ref();
431                    let aggregate = aggregates
432                        .entry(unit.id.clone())
433                        .or_insert_with(|| UnitAggregate::new(unit_ref));
434
435                    if aggregate.first_before.is_none() {
436                        aggregate.first_before = mutation.before_unit.clone();
437                    }
438                    if mutation.before_unit.is_some() && aggregate.first_before.is_none() {
439                        aggregate.first_before = mutation.before_unit.clone();
440                    }
441                    if let Some(before) = &mutation.before_unit {
442                        aggregate.first_before.get_or_insert_with(|| before.clone());
443                    }
444                    if let Some(after) = &mutation.after_unit {
445                        aggregate.latest_after = Some(after.clone());
446                    }
447                    aggregate.roles.insert(ManaUnitRole::DirectTarget);
448                    if matches!(unit.kind, ManaReviewUnitKind::Fact) {
449                        aggregate.roles.insert(ManaUnitRole::Fact);
450                    }
451                    if let Some(parent) = &mutation.parent_unit {
452                        aggregate.parent_unit = Some(parent.clone());
453                        aggregate.roles.insert(ManaUnitRole::Child);
454                    } else if let Some(parent_id) = unit.parent.as_ref() {
455                        aggregate.parent_unit.get_or_insert_with(|| {
456                            ManaUnitRef::new(
457                                parent_id.clone(),
458                                parent_id.clone(),
459                                Some("epic".into()),
460                            )
461                        });
462                        aggregate.roles.insert(ManaUnitRole::Child);
463                    }
464
465                    match mutation.action {
466                        ManaMutationAction::Create => {
467                            aggregate.created_in_turn = true;
468                            aggregate.touch_actions.insert(ManaTouchKind::Created);
469                        }
470                        ManaMutationAction::FactCreate => {
471                            aggregate.created_in_turn = true;
472                            aggregate.touch_actions.insert(ManaTouchKind::FactCreated);
473                            aggregate.roles.insert(ManaUnitRole::Fact);
474                        }
475                        ManaMutationAction::Close => {
476                            aggregate.touch_actions.insert(ManaTouchKind::Closed);
477                        }
478                        ManaMutationAction::Reopen => {
479                            aggregate.touch_actions.insert(ManaTouchKind::Reopened);
480                        }
481                        ManaMutationAction::Fail => {
482                            aggregate.touch_actions.insert(ManaTouchKind::Failed);
483                        }
484                        _ => {
485                            aggregate.touch_actions.insert(ManaTouchKind::Updated);
486                        }
487                    }
488                }
489            }
490
491            if let Some(related) = &mutation.related_unit {
492                let aggregate = aggregates
493                    .entry(related.id.clone())
494                    .or_insert_with(|| UnitAggregate::new(related.clone()));
495                aggregate.roles.insert(ManaUnitRole::Related);
496            }
497
498            for field_change in &mutation.field_changes {
499                if let Some(aggregate) = aggregates.get_mut(&field_change.unit.id) {
500                    aggregate.record_field_change(field_change.clone());
501                }
502            }
503            for note in &mutation.notes_appended {
504                if let Some(aggregate) = aggregates.get_mut(&note.unit.id) {
505                    aggregate.notes.push(note.clone());
506                }
507            }
508            for event in &mutation.decision_events {
509                if let Some(aggregate) = aggregates.get_mut(&event.unit.id) {
510                    aggregate.record_decision_event(event.clone());
511                }
512            }
513        }
514
515        let created_and_deleted: BTreeSet<String> = aggregates
516            .iter()
517            .filter_map(|(id, aggregate)| {
518                (aggregate.created_in_turn && aggregate.deleted_in_turn).then_some(id.clone())
519            })
520            .collect();
521
522        let mut touched_units = Vec::new();
523        let mut proposed_children = Vec::new();
524        let mut material_field_changes = Vec::new();
525        let mut notes_appended = Vec::new();
526        let mut decision_events = Vec::new();
527        let mut unresolved_consequential_choices = Vec::new();
528
529        for (id, aggregate) in &aggregates {
530            if created_and_deleted.contains(id) {
531                continue;
532            }
533
534            let surviving_field_changes = aggregate.surviving_field_changes();
535            let surviving_decision_events = aggregate.surviving_decision_events();
536            let final_unit = aggregate.latest_after.as_ref();
537            let unresolved_choices = final_unit
538                .map(classify_unresolved_choices)
539                .unwrap_or_default();
540
541            let has_surviving_material = aggregate.created_in_turn
542                || aggregate.deleted_in_turn
543                || !surviving_field_changes.is_empty()
544                || !aggregate.notes.is_empty()
545                || !surviving_decision_events.is_empty();
546
547            if has_surviving_material {
548                let unit_ref = aggregate.display_unit_ref();
549                touched_units.push(TurnManaTouchedUnit {
550                    unit: unit_ref.clone(),
551                    touch_kind: aggregate.touch_kind(),
552                    unit_origin: if aggregate.created_in_turn {
553                        ManaUnitOrigin::CreatedInTurn
554                    } else {
555                        ManaUnitOrigin::Preexisting
556                    },
557                    roles: aggregate.roles.iter().copied().collect(),
558                });
559
560                if aggregate.created_in_turn {
561                    if let (Some(parent), Some(after)) = (&aggregate.parent_unit, final_unit) {
562                        if matches!(
563                            after.kind,
564                            ManaReviewUnitKind::Epic | ManaReviewUnitKind::Job
565                        ) {
566                            proposed_children.push(TurnManaProposedChild {
567                                unit: unit_ref,
568                                parent: parent.clone(),
569                                child_kind: after.kind,
570                                child_origin: ManaUnitOrigin::CreatedInTurn,
571                            });
572                        }
573                    }
574                }
575            }
576
577            material_field_changes.extend(surviving_field_changes);
578            notes_appended.extend(aggregate.notes.clone());
579            decision_events.extend(surviving_decision_events);
580            unresolved_consequential_choices.extend(unresolved_choices);
581        }
582
583        touched_units.sort_by(|a, b| a.unit.id.cmp(&b.unit.id));
584        proposed_children.sort_by(|a, b| a.unit.id.cmp(&b.unit.id));
585        material_field_changes.sort_by(|a, b| {
586            a.unit
587                .id
588                .cmp(&b.unit.id)
589                .then(a.field.cmp(&b.field))
590                .then(a.source_action.cmp(&b.source_action))
591        });
592        notes_appended.sort_by(|a, b| a.unit.id.cmp(&b.unit.id));
593        decision_events.sort_by(|a, b| {
594            a.unit
595                .id
596                .cmp(&b.unit.id)
597                .then(a.decision_text.cmp(&b.decision_text))
598        });
599        unresolved_consequential_choices.sort_by(|a, b| {
600            a.unit
601                .id
602                .cmp(&b.unit.id)
603                .then(a.decision_text.cmp(&b.decision_text))
604        });
605
606        let anchor_unit = derive_anchor_unit(
607            &aggregates,
608            &created_and_deleted,
609            &proposed_children,
610            &touched_units,
611        );
612        let next_question = unresolved_consequential_choices
613            .first()
614            .and_then(|choice| choice.suggested_question.clone())
615            .or_else(|| {
616                unresolved_consequential_choices
617                    .first()
618                    .map(|choice| format!("{} · {}", choice.unit.id, choice.decision_text))
619            });
620
621        let state = if touched_units.is_empty()
622            && proposed_children.is_empty()
623            && material_field_changes.is_empty()
624            && notes_appended.is_empty()
625            && decision_events.is_empty()
626            && unresolved_consequential_choices.is_empty()
627        {
628            ManaReviewState::NoChange
629        } else if unresolved_consequential_choices.is_empty() {
630            ManaReviewState::Changed
631        } else {
632            ManaReviewState::NeedsDecision
633        };
634
635        TurnManaReview {
636            turn_index: self.turn_index,
637            state,
638            scope,
639            anchor_unit,
640            touched_units,
641            proposed_children,
642            material_field_changes,
643            notes_appended,
644            decision_events,
645            unresolved_consequential_choices,
646            next_question,
647        }
648    }
649}
650
651#[derive(Debug, Clone)]
652struct FieldAggregate {
653    unit: ManaUnitRef,
654    field: String,
655    change_kind: ManaFieldChangeKind,
656    before: Option<String>,
657    after: Option<String>,
658    source_action: String,
659}
660
661#[derive(Debug, Clone)]
662struct UnitAggregate {
663    display_unit: ManaUnitRef,
664    first_before: Option<ManaUnitSnapshot>,
665    latest_after: Option<ManaUnitSnapshot>,
666    parent_unit: Option<ManaUnitRef>,
667    created_in_turn: bool,
668    deleted_in_turn: bool,
669    touch_actions: BTreeSet<ManaTouchKind>,
670    roles: BTreeSet<ManaUnitRole>,
671    singular_field_changes: BTreeMap<String, FieldAggregate>,
672    label_changes: BTreeMap<String, i32>,
673    dependency_changes: BTreeMap<String, i32>,
674    notes: Vec<TurnManaNoteAppend>,
675    decision_added: HashMap<String, usize>,
676    decision_resolved: HashMap<String, usize>,
677    decision_event_order: Vec<TurnManaDecisionEvent>,
678}
679
680impl UnitAggregate {
681    fn new(display_unit: ManaUnitRef) -> Self {
682        Self {
683            display_unit,
684            first_before: None,
685            latest_after: None,
686            parent_unit: None,
687            created_in_turn: false,
688            deleted_in_turn: false,
689            touch_actions: BTreeSet::new(),
690            roles: BTreeSet::new(),
691            singular_field_changes: BTreeMap::new(),
692            label_changes: BTreeMap::new(),
693            dependency_changes: BTreeMap::new(),
694            notes: Vec::new(),
695            decision_added: HashMap::new(),
696            decision_resolved: HashMap::new(),
697            decision_event_order: Vec::new(),
698        }
699    }
700
701    fn display_unit_ref(&self) -> ManaUnitRef {
702        self.latest_after
703            .as_ref()
704            .map(ManaUnitSnapshot::unit_ref)
705            .unwrap_or_else(|| self.display_unit.clone())
706    }
707
708    fn record_field_change(&mut self, change: TurnManaFieldChange) {
709        match change.field.as_str() {
710            "labels" => {
711                if let Some(label) = change.after.clone().or(change.before.clone()) {
712                    let delta = match change.change_kind {
713                        ManaFieldChangeKind::Added => 1,
714                        ManaFieldChangeKind::Removed => -1,
715                        _ => 0,
716                    };
717                    *self.label_changes.entry(label).or_insert(0) += delta;
718                }
719            }
720            "dependencies" => {
721                if let Some(dep) = change.after.clone().or(change.before.clone()) {
722                    let delta = match change.change_kind {
723                        ManaFieldChangeKind::Added => 1,
724                        ManaFieldChangeKind::Removed => -1,
725                        _ => 0,
726                    };
727                    *self.dependency_changes.entry(dep).or_insert(0) += delta;
728                }
729            }
730            _ => {
731                let key = change.field.clone();
732                self.singular_field_changes
733                    .entry(key)
734                    .and_modify(|entry| {
735                        entry.after = change.after.clone();
736                        entry.change_kind = change.change_kind;
737                        entry.source_action = change.source_action.clone();
738                    })
739                    .or_insert(FieldAggregate {
740                        unit: change.unit,
741                        field: change.field,
742                        change_kind: change.change_kind,
743                        before: change.before,
744                        after: change.after,
745                        source_action: change.source_action,
746                    });
747            }
748        }
749    }
750
751    fn record_decision_event(&mut self, event: TurnManaDecisionEvent) {
752        match event.event_kind {
753            ManaDecisionEventKind::Added => {
754                *self
755                    .decision_added
756                    .entry(event.decision_text.clone())
757                    .or_insert(0) += 1;
758            }
759            ManaDecisionEventKind::Resolved => {
760                *self
761                    .decision_resolved
762                    .entry(event.decision_text.clone())
763                    .or_insert(0) += 1;
764            }
765        }
766        self.decision_event_order.push(event);
767    }
768
769    fn surviving_decision_events(&self) -> Vec<TurnManaDecisionEvent> {
770        let mut remaining_added = self.decision_added.clone();
771        let mut remaining_resolved = self.decision_resolved.clone();
772
773        for (decision, added_count) in &self.decision_added {
774            if let Some(resolved_count) = remaining_resolved.get_mut(decision) {
775                let cancelled = (*added_count).min(*resolved_count);
776                if let Some(added_remaining) = remaining_added.get_mut(decision) {
777                    *added_remaining -= cancelled;
778                }
779                *resolved_count -= cancelled;
780            }
781        }
782
783        self.decision_event_order
784            .iter()
785            .filter(|event| match event.event_kind {
786                ManaDecisionEventKind::Added => {
787                    remaining_added
788                        .get(&event.decision_text)
789                        .copied()
790                        .unwrap_or_default()
791                        > 0
792                }
793                ManaDecisionEventKind::Resolved => {
794                    remaining_resolved
795                        .get(&event.decision_text)
796                        .copied()
797                        .unwrap_or_default()
798                        > 0
799                }
800            })
801            .cloned()
802            .collect()
803    }
804
805    fn surviving_field_changes(&self) -> Vec<TurnManaFieldChange> {
806        let mut out = Vec::new();
807
808        for aggregate in self.singular_field_changes.values() {
809            if aggregate.before == aggregate.after {
810                continue;
811            }
812            out.push(TurnManaFieldChange {
813                unit: aggregate.unit.clone(),
814                field: aggregate.field.clone(),
815                change_kind: aggregate.change_kind,
816                before: aggregate.before.clone(),
817                after: aggregate.after.clone(),
818                source_action: aggregate.source_action.clone(),
819            });
820        }
821
822        for (label, delta) in &self.label_changes {
823            if *delta > 0 {
824                out.push(TurnManaFieldChange {
825                    unit: self.display_unit_ref(),
826                    field: "labels".to_string(),
827                    change_kind: ManaFieldChangeKind::Added,
828                    before: None,
829                    after: Some(label.clone()),
830                    source_action: ManaMutationAction::Update.as_str().to_string(),
831                });
832            } else if *delta < 0 {
833                out.push(TurnManaFieldChange {
834                    unit: self.display_unit_ref(),
835                    field: "labels".to_string(),
836                    change_kind: ManaFieldChangeKind::Removed,
837                    before: Some(label.clone()),
838                    after: None,
839                    source_action: ManaMutationAction::Update.as_str().to_string(),
840                });
841            }
842        }
843
844        for (dep, delta) in &self.dependency_changes {
845            if *delta > 0 {
846                out.push(TurnManaFieldChange {
847                    unit: self.display_unit_ref(),
848                    field: "dependencies".to_string(),
849                    change_kind: ManaFieldChangeKind::Added,
850                    before: None,
851                    after: Some(dep.clone()),
852                    source_action: ManaMutationAction::DepAdd.as_str().to_string(),
853                });
854            } else if *delta < 0 {
855                out.push(TurnManaFieldChange {
856                    unit: self.display_unit_ref(),
857                    field: "dependencies".to_string(),
858                    change_kind: ManaFieldChangeKind::Removed,
859                    before: Some(dep.clone()),
860                    after: None,
861                    source_action: ManaMutationAction::DepRemove.as_str().to_string(),
862                });
863            }
864        }
865
866        out
867    }
868
869    fn touch_kind(&self) -> ManaTouchKind {
870        if self.deleted_in_turn {
871            ManaTouchKind::Deleted
872        } else if self.created_in_turn {
873            match self.latest_after.as_ref().map(|unit| unit.kind) {
874                Some(ManaReviewUnitKind::Fact) => ManaTouchKind::FactCreated,
875                _ => ManaTouchKind::Created,
876            }
877        } else if self.touch_actions.contains(&ManaTouchKind::Failed) {
878            ManaTouchKind::Failed
879        } else if self.touch_actions.contains(&ManaTouchKind::Reopened) {
880            ManaTouchKind::Reopened
881        } else if self.touch_actions.contains(&ManaTouchKind::Closed) {
882            ManaTouchKind::Closed
883        } else {
884            ManaTouchKind::Updated
885        }
886    }
887}
888
889fn summarize_scope(mutations: &[ManaMutationRecord]) -> ManaReviewScope {
890    let unique: BTreeSet<(ManaReviewScopeKind, String)> = mutations
891        .iter()
892        .map(|mutation| (mutation.scope.kind.clone(), mutation.scope.display.clone()))
893        .collect();
894
895    if unique.is_empty() {
896        return ManaReviewScope::default();
897    }
898    if unique.len() == 1 {
899        let (kind, display) = unique.into_iter().next().unwrap();
900        return ManaReviewScope { kind, display };
901    }
902
903    ManaReviewScope {
904        kind: ManaReviewScopeKind::Mixed,
905        display: "mixed".to_string(),
906    }
907}
908
909fn derive_anchor_unit(
910    aggregates: &BTreeMap<String, UnitAggregate>,
911    created_and_deleted: &BTreeSet<String>,
912    proposed_children: &[TurnManaProposedChild],
913    touched_units: &[TurnManaTouchedUnit],
914) -> Option<TurnManaAnchorUnit> {
915    if !proposed_children.is_empty() {
916        let mut parent_counts: BTreeMap<String, (&ManaUnitRef, usize)> = BTreeMap::new();
917        for child in proposed_children {
918            parent_counts
919                .entry(child.parent.id.clone())
920                .and_modify(|(_, count)| *count += 1)
921                .or_insert((&child.parent, 1));
922        }
923        if let Some((parent_id, (parent_ref, _))) = parent_counts
924            .into_iter()
925            .max_by_key(|(_, (_, count))| *count)
926        {
927            let created_in_turn = aggregates
928                .get(&parent_id)
929                .map(|aggregate| {
930                    aggregate.created_in_turn && !created_and_deleted.contains(&parent_id)
931                })
932                .unwrap_or(false);
933            return Some(TurnManaAnchorUnit {
934                unit: parent_ref.clone(),
935                anchor_kind: if created_in_turn {
936                    ManaAnchorKind::CreatedInTurn
937                } else {
938                    ManaAnchorKind::ReusedExisting
939                },
940                reason: if created_in_turn {
941                    ManaAnchorReason::CreatedParent
942                } else {
943                    ManaAnchorReason::AttachedParent
944                },
945            });
946        }
947    }
948
949    if touched_units.len() == 1 {
950        let touched = touched_units.first().unwrap();
951        let reason = if touched.roles.contains(&ManaUnitRole::Fact) {
952            ManaAnchorReason::PrimaryFact
953        } else {
954            ManaAnchorReason::PrimaryTarget
955        };
956        let anchor_kind = if matches!(touched.unit_origin, ManaUnitOrigin::CreatedInTurn) {
957            ManaAnchorKind::CreatedInTurn
958        } else {
959            ManaAnchorKind::ReusedExisting
960        };
961        return Some(TurnManaAnchorUnit {
962            unit: touched.unit.clone(),
963            anchor_kind,
964            reason,
965        });
966    }
967
968    None
969}
970
971fn classify_unresolved_choices(unit: &ManaUnitSnapshot) -> Vec<TurnManaConsequentialChoice> {
972    unit.decisions
973        .iter()
974        .filter_map(|decision| classify_consequential_choice(&unit.unit_ref(), decision))
975        .collect()
976}
977
978fn classify_consequential_choice(
979    unit: &ManaUnitRef,
980    decision: &str,
981) -> Option<TurnManaConsequentialChoice> {
982    let lower = decision.to_ascii_lowercase();
983
984    let (category, why) = if contains_any(
985        &lower,
986        &["mana", "imp", "root", "project", "ownership", "boundary"],
987    ) {
988        (
989            ManaConsequentialChoiceCategory::OwnershipBoundary,
990            "changes ownership or storage boundary".to_string(),
991        )
992    } else if contains_any(
993        &lower,
994        &["architecture", "design", "contract", "interface", "model"],
995    ) {
996        (
997            ManaConsequentialChoiceCategory::Architecture,
998            "changes architecture direction".to_string(),
999        )
1000    } else if contains_any(
1001        &lower,
1002        &["launch", "execute", "run", "implement", "start", "ship"],
1003    ) {
1004        (
1005            ManaConsequentialChoiceCategory::ExecutionLaunch,
1006            "changes whether or how execution should start".to_string(),
1007        )
1008    } else if contains_any(&lower, &["scope", "split", "phase", "defer", "cut"]) {
1009        (
1010            ManaConsequentialChoiceCategory::ScopeChange,
1011            "changes preserved scope or decomposition".to_string(),
1012        )
1013    } else if contains_any(&lower, &["delete", "prune", "remove", "archive"]) {
1014        (
1015            ManaConsequentialChoiceCategory::PruneOrDelete,
1016            "changes whether captured structure should be removed".to_string(),
1017        )
1018    } else if decision.trim_end().ends_with('?')
1019        || lower.starts_with("choose ")
1020        || lower.starts_with("decide ")
1021    {
1022        (
1023            ManaConsequentialChoiceCategory::Other,
1024            "is phrased as an unresolved decision".to_string(),
1025        )
1026    } else {
1027        return None;
1028    };
1029
1030    Some(TurnManaConsequentialChoice {
1031        unit: unit.clone(),
1032        decision_text: decision.to_string(),
1033        category,
1034        why_consequential: why,
1035        suggested_question: Some(format!("{} · {}", unit.id, decision)),
1036    })
1037}
1038
1039fn contains_any(haystack: &str, needles: &[&str]) -> bool {
1040    needles.iter().any(|needle| haystack.contains(needle))
1041}
1042
1043fn _status_string(status: Status) -> String {
1044    status.to_string()
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050
1051    fn scope() -> ManaReviewScope {
1052        ManaReviewScope {
1053            kind: ManaReviewScopeKind::Project,
1054            display: "project".to_string(),
1055        }
1056    }
1057
1058    fn unit(id: &str, title: &str, kind: ManaReviewUnitKind) -> ManaUnitSnapshot {
1059        ManaUnitSnapshot {
1060            id: id.to_string(),
1061            title: title.to_string(),
1062            kind,
1063            status: "open".to_string(),
1064            parent: None,
1065            dependencies: Vec::new(),
1066            labels: Vec::new(),
1067            decisions: Vec::new(),
1068            description: None,
1069            acceptance: None,
1070            design: None,
1071            assignee: None,
1072            priority: 2,
1073            is_archived: false,
1074        }
1075    }
1076
1077    #[test]
1078    fn no_mutations_becomes_no_change() {
1079        let mut acc = TurnManaReviewAccumulator::default();
1080        acc.begin_turn(3);
1081        let review = acc.finalize();
1082        assert_eq!(review.state, ManaReviewState::NoChange);
1083        assert_eq!(review.state.as_str(), "no_change");
1084    }
1085
1086    #[test]
1087    fn create_then_delete_same_unit_is_net_zero() {
1088        let mut acc = TurnManaReviewAccumulator::default();
1089        acc.begin_turn(1);
1090        let created = unit("28.5", "child", ManaReviewUnitKind::Job);
1091        acc.push(ManaMutationRecord {
1092            action: ManaMutationAction::Create,
1093            scope: scope(),
1094            before_unit: None,
1095            after_unit: Some(created.clone()),
1096            deleted_unit: None,
1097            parent_unit: Some(ManaUnitRef::new("28", "parent", Some("epic".into()))),
1098            related_unit: None,
1099            field_changes: Vec::new(),
1100            notes_appended: Vec::new(),
1101            decision_events: Vec::new(),
1102        });
1103        acc.push(ManaMutationRecord {
1104            action: ManaMutationAction::Delete,
1105            scope: scope(),
1106            before_unit: Some(created.clone()),
1107            after_unit: None,
1108            deleted_unit: Some(created.unit_ref()),
1109            parent_unit: None,
1110            related_unit: None,
1111            field_changes: vec![TurnManaFieldChange {
1112                unit: created.unit_ref(),
1113                field: "lifecycle.deleted".into(),
1114                change_kind: ManaFieldChangeKind::Set,
1115                before: Some("false".into()),
1116                after: Some("true".into()),
1117                source_action: "delete".into(),
1118            }],
1119            notes_appended: Vec::new(),
1120            decision_events: Vec::new(),
1121        });
1122        let review = acc.finalize();
1123        assert_eq!(review.state, ManaReviewState::NoChange);
1124        assert!(review.touched_units.is_empty());
1125    }
1126
1127    #[test]
1128    fn unresolved_architecture_decision_requires_decision() {
1129        let mut acc = TurnManaReviewAccumulator::default();
1130        acc.begin_turn(2);
1131        let mut after = unit("28", "boundary work", ManaReviewUnitKind::Epic);
1132        after.decisions = vec!["Choose architecture boundary between mana and imp".into()];
1133        acc.push(ManaMutationRecord {
1134            action: ManaMutationAction::DecisionAdd,
1135            scope: scope(),
1136            before_unit: Some(unit("28", "boundary work", ManaReviewUnitKind::Epic)),
1137            after_unit: Some(after.clone()),
1138            deleted_unit: None,
1139            parent_unit: None,
1140            related_unit: None,
1141            field_changes: Vec::new(),
1142            notes_appended: Vec::new(),
1143            decision_events: vec![TurnManaDecisionEvent {
1144                unit: after.unit_ref(),
1145                event_kind: ManaDecisionEventKind::Added,
1146                decision_text: "Choose architecture boundary between mana and imp".into(),
1147                source_action: "decision_add".into(),
1148            }],
1149        });
1150        let review = acc.finalize();
1151        assert_eq!(review.state, ManaReviewState::NeedsDecision);
1152        assert_eq!(review.unresolved_consequential_choices.len(), 1);
1153        assert!(review.next_question.is_some());
1154    }
1155}