Skip to main content

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}