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(¬e.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}