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