kranz_engine/migrate_state.rs
1//! The one-time fold of `.status` sidecars into committed frontmatter
2//! `state:` keys (design: ticket-state-frontmatter, rule 4) — `kranz ticket
3//! migrate-state`.
4//!
5//! [`fold_sidecar_states`] walks `.kranz/tickets/` and, for every ticket whose
6//! gitignored `.status` sidecar records the terminal pipeline state `done`,
7//! folds that verdict into the committed .md as `state: done` (carrying the
8//! sidecar note into `state-note:`) via [`Ticket::write_lifecycle`] — the one
9//! write path that also refreshes the sidecar cache. Dry-run by default: the
10//! same function with `apply: false` reports what it WOULD do without writing
11//! a byte, so the operator reviews the fold before applying it.
12//!
13//! Three classes of ticket are never rewritten:
14//! - a ticket whose .md is DIRTY in git (uncommitted modification, or
15//! untracked): an in-flight editor or agent may have the file open, and
16//! rewriting it is the house-rule violation this command exists once to
17//! perform — the skip is reported by name so the operator can fold the
18//! ticket after that work lands. The dirty check is fail-closed: no git,
19//! no fold.
20//! - a ticket that already carries a `state:` key — this is what makes a
21//! re-run idempotent (the frontmatter is the source of truth; the fold
22//! only fills ABSENT keys, it never overwrites an operator's verdict);
23//! - a ticket with no sidecar, or whose sidecar holds a non-terminal
24//! PIPELINE state (drafting/review/queued/failed/…): the frontmatter
25//! domain has no value for pipeline states — they stay sidecar-owned by
26//! design, and a fresh-clone re-read of an in-flight ticket as NEW is the
27//! same behavior the pipeline always had.
28
29use crate::error::{EngineError, Result};
30use crate::git_ops::GitRepo;
31use crate::ticket::{Ticket, TicketLifecycle, TicketState};
32use std::collections::HashSet;
33use std::path::Path;
34
35/// One ticket's fold decision.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum FoldAction {
38 /// Sidecar `done` → frontmatter `state: done`, carrying the sidecar note
39 /// into `state-note:` when present (the note is carried, never parsed —
40 /// "Superseded by …" prose stays prose).
41 Fold { slug: String, note: Option<String> },
42 /// Already carries a frontmatter `state:` key — untouched (this is what
43 /// makes a re-run idempotent).
44 AlreadyMigrated { slug: String },
45 /// The .md is dirty in git: never rewrite a file an in-flight editor or
46 /// agent has open. Named loudly in the report; the operator folds it by
47 /// re-running once the in-flight work lands.
48 SkipDirty { slug: String },
49 /// Nothing terminal to fold: no sidecar at all (`None`), or a
50 /// non-terminal pipeline sidecar — pipeline states are not operator
51 /// lifecycle and remain sidecar-owned by design.
52 NoTerminalSidecar {
53 slug: String,
54 sidecar: Option<TicketState>,
55 },
56}
57
58impl FoldAction {
59 fn slug(&self) -> &str {
60 match self {
61 FoldAction::Fold { slug, .. }
62 | FoldAction::AlreadyMigrated { slug }
63 | FoldAction::SkipDirty { slug }
64 | FoldAction::NoTerminalSidecar { slug, .. } => slug,
65 }
66 }
67}
68
69/// The fold plan (dry-run) or record (applied) over one repo's tickets.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct MigrationReport {
72 /// `false` = dry-run: the actions are what WOULD happen; no byte was
73 /// written. `true` = the [`FoldAction::Fold`] entries were applied.
74 pub applied: bool,
75 /// Per-ticket decisions, sorted by slug for a deterministic report.
76 pub actions: Vec<FoldAction>,
77}
78
79impl MigrationReport {
80 /// Tickets the fold rewrote (or would rewrite), counted.
81 pub fn folds(&self) -> usize {
82 self.actions
83 .iter()
84 .filter(|a| matches!(a, FoldAction::Fold { .. }))
85 .count()
86 }
87
88 /// Tickets skipped because their .md has uncommitted changes.
89 pub fn dirty_skips(&self) -> usize {
90 self.actions
91 .iter()
92 .filter(|a| matches!(a, FoldAction::SkipDirty { .. }))
93 .count()
94 }
95
96 /// Tickets that already carried a frontmatter `state:` key.
97 pub fn already_migrated(&self) -> usize {
98 self.actions
99 .iter()
100 .filter(|a| matches!(a, FoldAction::AlreadyMigrated { .. }))
101 .count()
102 }
103
104 /// Tickets with nothing terminal to fold (no sidecar / pipeline sidecar).
105 pub fn left_alone(&self) -> usize {
106 self.actions
107 .iter()
108 .filter(|a| matches!(a, FoldAction::NoTerminalSidecar { .. }))
109 .count()
110 }
111}
112
113/// Plan (and with `apply: true`, perform) the fold of terminal `.status`
114/// sidecars into frontmatter `state:` keys. See the module docs for the skip
115/// rules. Fail-closed on the git dirty-check: when uncommitted edits cannot
116/// be detected, NOTHING is planned or written — the check is the only thing
117/// standing between the fold and an in-flight edit.
118pub fn fold_sidecar_states(repo_root: &Path, apply: bool) -> Result<MigrationReport> {
119 let git = GitRepo::open(repo_root).map_err(|e| {
120 EngineError::Git(format!(
121 "migrate-state needs git to detect uncommitted ticket edits before \
122 rewriting them (fail-closed): {e}"
123 ))
124 })?;
125 let dirty: HashSet<String> = git
126 .dirty_paths()?
127 .iter()
128 // Porcelain paths are repo-relative with forward slashes on every
129 // platform; normalize anyway so a Windows `\` never misses a match.
130 .map(|p| p.to_string_lossy().replace('\\', "/"))
131 .collect();
132
133 let mut actions = Vec::new();
134 for ticket in Ticket::list(repo_root) {
135 let slug = ticket.slug;
136 if dirty.contains(&format!(".kranz/tickets/{slug}.md")) {
137 actions.push(FoldAction::SkipDirty { slug });
138 continue;
139 }
140 if ticket.lifecycle.is_some() {
141 actions.push(FoldAction::AlreadyMigrated { slug });
142 continue;
143 }
144 match Ticket::sidecar_record(repo_root, &slug) {
145 Some((TicketState::Done, note)) => {
146 if apply {
147 Ticket::write_lifecycle(repo_root, &slug, TicketLifecycle::Done, note.clone())?;
148 }
149 actions.push(FoldAction::Fold { slug, note });
150 }
151 other => actions.push(FoldAction::NoTerminalSidecar {
152 slug,
153 sidecar: other.map(|(state, _)| state),
154 }),
155 }
156 }
157 actions.sort_by(|a, b| a.slug().cmp(b.slug()));
158 Ok(MigrationReport {
159 applied: apply,
160 actions,
161 })
162}