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