Skip to main content

humanize_cli_core/
state.rs

1//! State management for Humanize loops.
2//!
3//! This module provides parsing and serialization for the state.md files
4//! used to track RLCR and PR loop progress.
5//!
6//! IMPORTANT: This schema must remain compatible with the historical shell-era
7//! state contract so existing loop directories continue to parse correctly.
8
9use serde::{Deserialize, Serialize, Serializer};
10use std::path::{Path, PathBuf};
11
12use crate::constants::{YAML_FRONTMATTER_END, YAML_FRONTMATTER_START};
13
14/// Serialize Option<String> as empty string when None (not null).
15/// This matches the shell behavior: `session_id:` (empty, not `session_id: null`).
16fn serialize_optional_empty<S>(value: &Option<String>, serializer: S) -> Result<S::Ok, S::Error>
17where
18    S: Serializer,
19{
20    match value {
21        Some(s) => serializer.serialize_str(s),
22        None => serializer.serialize_str(""),
23    }
24}
25
26fn normalize_empty_session_id(raw: &str) -> Option<String> {
27    let trimmed = raw.trim();
28    if trimmed.is_empty() || matches!(trimmed, "''" | "\"\"" | "~" | "null") {
29        None
30    } else {
31        Some(trimmed.to_string())
32    }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37pub enum PlanMode {
38    Snapshot,
39    SourceClean,
40    SourceImmutable,
41}
42
43impl Default for PlanMode {
44    fn default() -> Self {
45        Self::Snapshot
46    }
47}
48
49/// Represents the state of an RLCR or PR loop.
50///
51/// Schema matches setup-rlcr-loop.sh exactly:
52/// All field names use snake_case as per YAML convention.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct State {
55    /// Current round number (0-indexed).
56    #[serde(default)]
57    pub current_round: u32,
58
59    /// Maximum number of iterations allowed.
60    #[serde(default = "default_max_iterations")]
61    pub max_iterations: u32,
62
63    /// Codex model name (e.g., "gpt-5.4").
64    #[serde(default = "default_codex_model")]
65    pub codex_model: String,
66
67    /// Codex reasoning effort (e.g., "high", "xhigh").
68    #[serde(default = "default_codex_effort")]
69    pub codex_effort: String,
70
71    /// Codex timeout in seconds.
72    #[serde(default = "default_codex_timeout")]
73    pub codex_timeout: u64,
74
75    /// Whether to push after each round.
76    #[serde(default)]
77    pub push_every_round: bool,
78
79    /// Interval for full alignment checks (round N-1 for N, 2N-1, etc.).
80    #[serde(default = "default_full_review_round")]
81    pub full_review_round: u32,
82
83    /// Path to the plan file (relative to project root).
84    #[serde(default)]
85    pub plan_file: String,
86
87    /// Whether the plan file is tracked in git.
88    #[serde(default)]
89    pub plan_tracked: bool,
90
91    /// How the source plan should be treated during the loop.
92    #[serde(default)]
93    pub plan_mode: PlanMode,
94
95    /// Original source plan path captured when the loop started.
96    #[serde(default)]
97    pub plan_source_path: String,
98
99    /// Whether the source plan existed when the loop started.
100    #[serde(default)]
101    pub plan_source_exists_at_start: bool,
102
103    /// Whether the source plan was tracked when the loop started.
104    #[serde(default)]
105    pub plan_source_tracked_at_start: bool,
106
107    /// SHA-256 of the source plan when the loop started.
108    #[serde(default)]
109    pub plan_source_sha256: String,
110
111    /// Snapshot plan path stored inside the loop directory.
112    #[serde(default)]
113    pub plan_snapshot_path: String,
114
115    /// Optional git blob oid of the source plan at start.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub plan_source_git_oid: Option<String>,
118
119    /// Internal planning artifact id used to start this RLCR session.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub source_plan_id: Option<String>,
122
123    /// Revision of the internal planning artifact used to start this RLCR session.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub source_plan_revision: Option<u32>,
126
127    /// Branch where the loop started.
128    #[serde(default)]
129    pub start_branch: String,
130
131    /// Base branch for code review.
132    #[serde(default)]
133    pub base_branch: String,
134
135    /// Base commit SHA.
136    #[serde(default)]
137    pub base_commit: String,
138
139    /// Whether review phase has started.
140    #[serde(default)]
141    pub review_started: bool,
142
143    /// Whether to ask Codex for clarification.
144    #[serde(default = "default_ask_codex_question")]
145    pub ask_codex_question: bool,
146
147    /// Session identifier for this loop.
148    /// Always serialized as empty string when None (shell contract).
149    #[serde(default, serialize_with = "serialize_optional_empty")]
150    pub session_id: Option<String>,
151
152    /// Whether agent teams mode is enabled.
153    #[serde(default)]
154    pub agent_teams: bool,
155
156    /// Timestamp when the loop was created (ISO 8601).
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub started_at: Option<String>,
159
160    // PR loop specific fields
161    /// PR number for PR loops.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub pr_number: Option<u32>,
164
165    /// List of configured bots for PR review.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub configured_bots: Option<Vec<String>>,
168
169    /// List of active bots for PR review.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub active_bots: Option<Vec<String>>,
172
173    /// Polling interval for PR state checks (seconds).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub poll_interval: Option<u64>,
176
177    /// Timeout for PR polling (seconds).
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub poll_timeout: Option<u64>,
180
181    /// Startup case for PR loop (e.g., "new_pr", "existing_pr").
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub startup_case: Option<String>,
184
185    /// Latest commit SHA for PR.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub latest_commit_sha: Option<String>,
188
189    /// Timestamp of latest commit.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub latest_commit_at: Option<String>,
192
193    /// Timestamp of last trigger.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub last_trigger_at: Option<String>,
196
197    /// ID of trigger comment.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub trigger_comment_id: Option<String>,
200}
201
202// Default functions for serde
203fn default_max_iterations() -> u32 {
204    42
205}
206
207fn default_codex_model() -> String {
208    "gpt-5.4".to_string()
209}
210
211fn default_codex_effort() -> String {
212    "xhigh".to_string()
213}
214
215fn default_codex_timeout() -> u64 {
216    5400
217}
218
219fn default_full_review_round() -> u32 {
220    5
221}
222
223fn default_ask_codex_question() -> bool {
224    true
225}
226
227impl Default for State {
228    fn default() -> Self {
229        Self {
230            current_round: 0,
231            max_iterations: default_max_iterations(),
232            codex_model: default_codex_model(),
233            codex_effort: default_codex_effort(),
234            codex_timeout: default_codex_timeout(),
235            push_every_round: false,
236            full_review_round: default_full_review_round(),
237            plan_file: String::new(),
238            plan_tracked: false,
239            plan_mode: PlanMode::Snapshot,
240            plan_source_path: String::new(),
241            plan_source_exists_at_start: false,
242            plan_source_tracked_at_start: false,
243            plan_source_sha256: String::new(),
244            plan_snapshot_path: String::new(),
245            plan_source_git_oid: None,
246            source_plan_id: None,
247            source_plan_revision: None,
248            start_branch: String::new(),
249            base_branch: String::new(),
250            base_commit: String::new(),
251            review_started: false,
252            ask_codex_question: default_ask_codex_question(),
253            session_id: None,
254            agent_teams: false,
255            started_at: None,
256            // PR loop fields
257            pr_number: None,
258            configured_bots: None,
259            active_bots: None,
260            poll_interval: None,
261            poll_timeout: None,
262            startup_case: None,
263            latest_commit_sha: None,
264            latest_commit_at: None,
265            last_trigger_at: None,
266            trigger_comment_id: None,
267        }
268    }
269}
270
271impl State {
272    /// Parse state from a file containing YAML frontmatter.
273    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, StateError> {
274        let content = std::fs::read_to_string(path.as_ref())
275            .map_err(|e| StateError::IoError(e.to_string()))?;
276        Self::from_markdown(&content)
277    }
278
279    /// Parse state from markdown content with YAML frontmatter.
280    pub fn from_markdown(content: &str) -> Result<Self, StateError> {
281        let content = content.trim();
282
283        // Check for YAML frontmatter start
284        if !content.starts_with(YAML_FRONTMATTER_START) {
285            return Err(StateError::MissingFrontmatter);
286        }
287
288        // Find the closing delimiter (must be on its own line)
289        let rest = &content[YAML_FRONTMATTER_START.len()..];
290        let end_pos = rest
291            .find("\n---")
292            .ok_or(StateError::MissingFrontmatterEnd)?;
293
294        let yaml_content = &rest[..end_pos];
295
296        // Parse YAML
297        let state: State = serde_yaml::from_str(yaml_content)
298            .map_err(|e| StateError::YamlParseError(e.to_string()))?;
299
300        // Ensure defaults are applied for missing fields
301        // (serde's default attribute handles this for Option types and defaults)
302
303        Ok(state)
304    }
305
306    /// Parse state from markdown with strict validation of required fields.
307    ///
308    /// This matches the shell behavior in loop-common.sh parse_state_file_strict()
309    /// which rejects missing required fields: current_round, max_iterations,
310    /// review_started, and base_branch.
311    pub fn from_markdown_strict(content: &str) -> Result<Self, StateError> {
312        let content = content.trim();
313
314        // Check for YAML frontmatter start
315        if !content.starts_with(YAML_FRONTMATTER_START) {
316            return Err(StateError::MissingFrontmatter);
317        }
318
319        // Find the closing delimiter
320        let rest = &content[YAML_FRONTMATTER_START.len()..];
321        let end_pos = rest
322            .find("\n---")
323            .ok_or(StateError::MissingFrontmatterEnd)?;
324
325        let yaml_content = &rest[..end_pos];
326
327        // First parse as generic YAML to check for required fields
328        let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_content)
329            .map_err(|e| StateError::YamlParseError(e.to_string()))?;
330
331        let mapping = yaml_value.as_mapping().ok_or_else(|| {
332            StateError::MissingRequiredField("YAML must be a mapping".to_string())
333        })?;
334
335        // Validate required fields per loop-common.sh parse_state_file_strict
336        let required_fields = [
337            "current_round",
338            "max_iterations",
339            "review_started",
340            "base_branch",
341        ];
342        for field in &required_fields {
343            if !mapping.contains_key(serde_yaml::Value::String(field.to_string())) {
344                return Err(StateError::MissingRequiredField(field.to_string()));
345            }
346        }
347
348        // Now parse into State struct
349        let state: State = serde_yaml::from_str(yaml_content)
350            .map_err(|e| StateError::YamlParseError(e.to_string()))?;
351
352        Ok(state)
353    }
354
355    /// Serialize state to markdown with YAML frontmatter.
356    pub fn to_markdown(&self) -> Result<String, StateError> {
357        let mut yaml = serde_yaml::to_string(self)
358            .map_err(|e| StateError::YamlSerializeError(e.to_string()))?;
359        yaml = yaml.replace("session_id: ''\n", "session_id:\n");
360        yaml = yaml.replace("session_id: \"\"\n", "session_id:\n");
361
362        // Format: ---\n<yaml>\n---\n\n
363        // This matches the Bash implementation's format
364        Ok(format!(
365            "{}\n{}\n{}\n\n",
366            YAML_FRONTMATTER_START,
367            yaml.trim_end(),
368            YAML_FRONTMATTER_END
369        ))
370    }
371
372    /// Save state to a file.
373    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), StateError> {
374        let content = self.to_markdown()?;
375        std::fs::write(path.as_ref(), content).map_err(|e| StateError::IoError(e.to_string()))?;
376        Ok(())
377    }
378
379    /// Create a new RLCR state with the given parameters.
380    pub fn new_rlcr(
381        plan_file: String,
382        plan_tracked: bool,
383        plan_mode: PlanMode,
384        plan_source_path: String,
385        plan_source_exists_at_start: bool,
386        plan_source_tracked_at_start: bool,
387        plan_source_sha256: String,
388        plan_snapshot_path: String,
389        plan_source_git_oid: Option<String>,
390        source_plan_id: Option<String>,
391        source_plan_revision: Option<u32>,
392        start_branch: String,
393        base_branch: String,
394        base_commit: String,
395        max_iterations: Option<u32>,
396        codex_model: Option<String>,
397        codex_effort: Option<String>,
398        codex_timeout: Option<u64>,
399        push_every_round: bool,
400        full_review_round: Option<u32>,
401        ask_codex_question: bool,
402        agent_teams: bool,
403        review_started: bool,
404    ) -> Self {
405        let now = chrono_lite_now();
406        Self {
407            current_round: 0,
408            max_iterations: max_iterations.unwrap_or_else(default_max_iterations),
409            codex_model: codex_model.unwrap_or_else(default_codex_model),
410            codex_effort: codex_effort.unwrap_or_else(default_codex_effort),
411            codex_timeout: codex_timeout.unwrap_or_else(default_codex_timeout),
412            push_every_round,
413            full_review_round: full_review_round.unwrap_or_else(default_full_review_round),
414            plan_file,
415            plan_tracked,
416            plan_mode,
417            plan_source_path,
418            plan_source_exists_at_start,
419            plan_source_tracked_at_start,
420            plan_source_sha256,
421            plan_snapshot_path,
422            plan_source_git_oid,
423            source_plan_id,
424            source_plan_revision,
425            start_branch,
426            base_branch,
427            base_commit,
428            review_started,
429            ask_codex_question,
430            session_id: None, // Empty initially, filled by PostToolUse hook
431            agent_teams,
432            started_at: Some(now),
433            // PR loop fields (all None for RLCR)
434            pr_number: None,
435            configured_bots: None,
436            active_bots: None,
437            poll_interval: None,
438            poll_timeout: None,
439            startup_case: None,
440            latest_commit_sha: None,
441            latest_commit_at: None,
442            last_trigger_at: None,
443            trigger_comment_id: None,
444        }
445    }
446
447    /// Increment the round counter.
448    pub fn increment_round(&mut self) {
449        self.current_round += 1;
450    }
451
452    /// Check if max iterations have been reached.
453    pub fn is_max_iterations_reached(&self) -> bool {
454        self.current_round >= self.max_iterations
455    }
456
457    /// Check if this is a terminal state filename.
458    pub fn is_terminal_state_file(filename: &str) -> bool {
459        let terminal_states = [
460            "complete-state.md",
461            "cancel-state.md",
462            "maxiter-state.md",
463            "stop-state.md",
464            "unexpected-state.md",
465            "approve-state.md",
466            "merged-state.md",
467            "closed-state.md",
468        ];
469        terminal_states.contains(&filename)
470    }
471
472    /// Check if a reason is a valid terminal state reason.
473    pub fn is_valid_terminal_reason(reason: &str) -> bool {
474        matches!(
475            reason,
476            "complete"
477                | "cancel"
478                | "maxiter"
479                | "stop"
480                | "unexpected"
481                | "approve"
482                | "merged"
483                | "closed"
484        )
485    }
486
487    /// Get the terminal state filename for a given exit reason.
488    ///
489    /// Returns None if the reason is not a valid terminal reason.
490    /// Shell contract: invalid reasons should error, not silently map to unexpected.
491    pub fn terminal_state_filename(reason: &str) -> Option<&'static str> {
492        match reason {
493            "complete" => Some("complete-state.md"),
494            "cancel" => Some("cancel-state.md"),
495            "maxiter" => Some("maxiter-state.md"),
496            "stop" => Some("stop-state.md"),
497            "unexpected" => Some("unexpected-state.md"),
498            "approve" => Some("approve-state.md"),
499            "merged" => Some("merged-state.md"),
500            "closed" => Some("closed-state.md"),
501            _ => None,
502        }
503    }
504
505    /// Rename state file to terminal state file.
506    ///
507    /// This implements the end-loop rename behavior from loop-common.sh:
508    /// After determining the exit reason, rename state.md to <reason>-state.md
509    ///
510    /// Returns error if reason is not valid (matching shell end_loop behavior).
511    pub fn rename_to_terminal<P: AsRef<Path>>(
512        state_path: P,
513        reason: &str,
514    ) -> Result<PathBuf, StateError> {
515        let terminal_name = Self::terminal_state_filename(reason)
516            .ok_or_else(|| StateError::InvalidTerminalReason(reason.to_string()))?;
517
518        let state_path = state_path.as_ref();
519        let dir = state_path
520            .parent()
521            .ok_or_else(|| StateError::IoError("Cannot determine parent directory".to_string()))?;
522
523        let terminal_path = dir.join(terminal_name);
524
525        std::fs::rename(state_path, &terminal_path)
526            .map_err(|e| StateError::IoError(e.to_string()))?;
527
528        Ok(terminal_path)
529    }
530}
531
532/// Find the active RLCR loop directory.
533///
534/// Matches loop-common.sh find_active_loop behavior exactly:
535/// - Without session filter: only check the single newest directory (zombie-loop protection)
536/// - With session filter: iterate newest-to-oldest, find first matching session
537/// - Empty stored session_id matches any filter (backward compatibility)
538/// - Only return if still active (has active state file, not terminal)
539pub fn find_active_loop(base_dir: &Path, session_id: Option<&str>) -> Option<PathBuf> {
540    if !base_dir.exists() {
541        return None;
542    }
543
544    let entries = match std::fs::read_dir(base_dir) {
545        Ok(e) => e,
546        Err(_) => return None,
547    };
548
549    let mut dirs = entries
550        .flatten()
551        .map(|entry| entry.path())
552        .filter(|path| path.is_dir())
553        .collect::<Vec<_>>();
554    dirs.sort();
555    dirs.reverse();
556
557    if session_id.is_none() {
558        // No filter: only check the single newest directory (zombie-loop protection)
559        if let Some(newest_dir) = dirs.first() {
560            // Only return if it has an active state file
561            if resolve_active_state_file(newest_dir).is_some() {
562                return Some(newest_dir.clone());
563            }
564        }
565        return None;
566    }
567
568    // Session filter: iterate newest-to-oldest
569    let filter_sid = session_id.unwrap();
570
571    for loop_dir in dirs {
572        // Check if this directory has any state file (active or terminal)
573        let any_state = resolve_any_state_file(&loop_dir);
574        if any_state.is_none() {
575            continue;
576        }
577
578        // Read session_id from the state file
579        let stored_session_id = any_state
580            .and_then(|path| std::fs::read_to_string(path).ok())
581            .and_then(|content| extract_session_id_from_frontmatter(&content));
582
583        // Empty stored session_id matches any session (backward compatibility)
584        let matches_session = match stored_session_id {
585            None => true, // No stored session_id, matches any
586            Some(ref stored) => stored == filter_sid,
587        };
588
589        if matches_session {
590            // This is the newest dir for this session -- only return if active
591            if resolve_active_state_file(&loop_dir).is_some() {
592                return Some(loop_dir);
593            }
594            // The session's newest loop is terminal; do not fall through to older dirs.
595            return None;
596        }
597    }
598
599    None
600}
601
602fn extract_session_id_from_frontmatter(content: &str) -> Option<String> {
603    let mut lines = content.lines();
604    if lines.next()? != "---" {
605        return None;
606    }
607
608    for line in lines {
609        if line == "---" {
610            break;
611        }
612
613        if let Some(value) = line.strip_prefix("session_id:") {
614            return normalize_empty_session_id(value.trim());
615        }
616    }
617
618    None
619}
620
621/// Resolve the active state file in a loop directory.
622///
623/// Checks finalize-state.md FIRST (loop in finalize phase), then state.md.
624/// Does NOT return terminal states - only active states.
625/// Matches loop-common.sh resolve_active_state_file behavior exactly.
626pub fn resolve_active_state_file(loop_dir: &Path) -> Option<PathBuf> {
627    // First check for finalize-state.md (active but in finalize phase)
628    let finalize_file = loop_dir.join("finalize-state.md");
629    if finalize_file.exists() {
630        return Some(finalize_file);
631    }
632
633    // Then check for state.md (normal active state)
634    let state_file = loop_dir.join("state.md");
635    if state_file.exists() {
636        return Some(state_file);
637    }
638
639    None
640}
641
642/// Resolve any state file (active or terminal) in a loop directory.
643///
644/// Prefers active states (finalize-state.md, state.md), then falls back
645/// to any terminal state file (*-state.md).
646/// Matches loop-common.sh resolve_any_state_file behavior exactly.
647pub fn resolve_any_state_file(loop_dir: &Path) -> Option<PathBuf> {
648    // Prefer active states
649    if let Some(active) = resolve_active_state_file(loop_dir) {
650        return Some(active);
651    }
652
653    // Fall back to terminal states (check in order of preference)
654    let terminal_states = [
655        "complete-state.md",
656        "cancel-state.md",
657        "maxiter-state.md",
658        "stop-state.md",
659        "unexpected-state.md",
660        "approve-state.md",
661        "merged-state.md",
662        "closed-state.md",
663    ];
664
665    for terminal in &terminal_states {
666        let path = loop_dir.join(terminal);
667        if path.exists() {
668            return Some(path);
669        }
670    }
671
672    None
673}
674
675/// Check if a loop is in finalize phase.
676///
677/// A loop is in finalize phase if it has a finalize-state.md file.
678pub fn is_finalize_phase(loop_dir: &Path) -> bool {
679    loop_dir.join("finalize-state.md").exists()
680}
681
682/// Check if a loop has a pending session handshake.
683///
684/// Returns true if .pending-session-id signal file exists.
685pub fn has_pending_session(project_root: &Path) -> bool {
686    project_root.join(".humanize/.pending-session-id").exists()
687}
688
689/// Generate a timestamp in ISO 8601 format (UTC).
690fn chrono_lite_now() -> String {
691    use chrono::Utc;
692    Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
693}
694
695/// Errors that can occur when working with state.
696#[derive(Debug, thiserror::Error)]
697pub enum StateError {
698    #[error("IO error: {0}")]
699    IoError(String),
700
701    #[error("Missing YAML frontmatter")]
702    MissingFrontmatter,
703
704    #[error("Missing YAML frontmatter end delimiter")]
705    MissingFrontmatterEnd,
706
707    #[error("YAML parse error: {0}")]
708    YamlParseError(String),
709
710    #[error("YAML serialize error: {0}")]
711    YamlSerializeError(String),
712
713    #[error("Missing required field: {0}")]
714    MissingRequiredField(String),
715
716    #[error("Invalid terminal reason: {0}")]
717    InvalidTerminalReason(String),
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    #[test]
725    fn test_state_to_markdown() {
726        let state = State::default();
727        let md = state.to_markdown().unwrap();
728        assert!(md.starts_with("---\n"));
729        assert!(md.contains("current_round: 0"));
730        assert!(md.contains("session_id:\n"));
731        assert!(!md.contains("session_id: ''"));
732    }
733
734    #[test]
735    fn test_state_from_markdown() {
736        let content = r#"---
737current_round: 1
738max_iterations: 42
739codex_model: gpt-5.4
740codex_effort: high
741codex_timeout: 5400
742push_every_round: false
743full_review_round: 5
744plan_file: docs/plan.md
745plan_tracked: false
746start_branch: master
747base_branch: master
748base_commit: abc123
749review_started: false
750ask_codex_question: true
751session_id:
752agent_teams: false
753---
754
755Some content below.
756"#;
757
758        let state = State::from_markdown(content).unwrap();
759        assert_eq!(state.current_round, 1);
760        assert_eq!(state.plan_file, "docs/plan.md");
761        assert!(state.session_id.is_none());
762    }
763
764    #[test]
765    fn test_state_roundtrip() {
766        let original = State::new_rlcr(
767            "docs/plan.md".to_string(),
768            false,
769            PlanMode::Snapshot,
770            "docs/plan.md".to_string(),
771            true,
772            false,
773            "sha256".to_string(),
774            ".humanize/rlcr/session/plan.md".to_string(),
775            None,
776            None,
777            None,
778            "master".to_string(),
779            "master".to_string(),
780            "abc123".to_string(),
781            None,
782            None,
783            None,
784            None,
785            false,
786            None,
787            true,
788            false,
789            false,
790        );
791
792        let md = original.to_markdown().unwrap();
793        let parsed = State::from_markdown(&md).unwrap();
794
795        assert_eq!(original.current_round, parsed.current_round);
796        assert_eq!(original.plan_file, parsed.plan_file);
797        assert_eq!(original.base_branch, parsed.base_branch);
798        assert_eq!(original.plan_mode, parsed.plan_mode);
799    }
800
801    #[test]
802    fn test_terminal_state_filename() {
803        assert_eq!(
804            State::terminal_state_filename("complete"),
805            Some("complete-state.md")
806        );
807        assert_eq!(
808            State::terminal_state_filename("cancel"),
809            Some("cancel-state.md")
810        );
811        assert_eq!(
812            State::terminal_state_filename("maxiter"),
813            Some("maxiter-state.md")
814        );
815        assert_eq!(State::terminal_state_filename("unknown"), None); // Invalid reason returns None
816    }
817
818    #[test]
819    fn test_strict_parsing_rejects_missing_required_fields() {
820        // Missing base_branch
821        let content_missing_base = r#"---
822current_round: 0
823max_iterations: 42
824review_started: false
825---
826"#;
827        let result = State::from_markdown_strict(content_missing_base);
828        assert!(result.is_err());
829        match result {
830            Err(StateError::MissingRequiredField(field)) => {
831                assert_eq!(field, "base_branch");
832            }
833            _ => panic!("Expected MissingRequiredField error for base_branch"),
834        }
835
836        // Missing max_iterations
837        let content_missing_max = r#"---
838current_round: 0
839review_started: false
840base_branch: master
841---
842"#;
843        let result = State::from_markdown_strict(content_missing_max);
844        assert!(result.is_err());
845
846        // Valid state with all required fields
847        let content_valid = r#"---
848current_round: 0
849max_iterations: 42
850review_started: false
851base_branch: master
852---
853"#;
854        let result = State::from_markdown_strict(content_valid);
855        assert!(result.is_ok());
856    }
857
858    #[test]
859    fn find_active_loop_uses_directory_name_order_without_session_filter() {
860        let tempdir = tempfile::tempdir().unwrap();
861        let base = tempdir.path().join("rlcr");
862        std::fs::create_dir_all(&base).unwrap();
863
864        let older = base.join("2026-03-18_00-00-00");
865        let newer = base.join("2026-03-19_00-00-00");
866        std::fs::create_dir_all(&older).unwrap();
867        std::fs::create_dir_all(&newer).unwrap();
868
869        std::fs::write(
870            older.join("state.md"),
871            "---\nsession_id: old\ncurrent_round: 0\nmax_iterations: 1\nplan_file: plan.md\nplan_tracked: false\nstart_branch: main\nbase_branch: main\nbase_commit: abc\nreview_started: false\n---\n",
872        )
873        .unwrap();
874        std::fs::write(
875            newer.join("cancel-state.md"),
876            "---\nsession_id: new\ncurrent_round: 0\nmax_iterations: 1\nplan_file: plan.md\nplan_tracked: false\nstart_branch: main\nbase_branch: main\nbase_commit: abc\nreview_started: false\n---\n",
877        )
878        .unwrap();
879
880        assert_eq!(find_active_loop(&base, None), None);
881    }
882
883    #[test]
884    fn find_active_loop_stops_at_newest_terminal_match_for_session() {
885        let tempdir = tempfile::tempdir().unwrap();
886        let base = tempdir.path().join("rlcr");
887        std::fs::create_dir_all(&base).unwrap();
888
889        let older = base.join("2026-03-18_00-00-00");
890        let newer = base.join("2026-03-19_00-00-00");
891        std::fs::create_dir_all(&older).unwrap();
892        std::fs::create_dir_all(&newer).unwrap();
893
894        let state = "---\nsession_id: session-1\ncurrent_round: 0\nmax_iterations: 1\nplan_file: plan.md\nplan_tracked: false\nstart_branch: main\nbase_branch: main\nbase_commit: abc\nreview_started: false\n---\n";
895        std::fs::write(older.join("state.md"), state).unwrap();
896        std::fs::write(newer.join("cancel-state.md"), state).unwrap();
897
898        assert_eq!(find_active_loop(&base, Some("session-1")), None);
899    }
900}