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    /// PR number for PR loops.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub pr_number: Option<u32>,
105
106    /// List of configured bots for PR review.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub configured_bots: Option<Vec<String>>,
109
110    /// List of active bots for PR review.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub active_bots: Option<Vec<String>>,
113
114    /// Polling interval for PR state checks (seconds).
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub poll_interval: Option<u64>,
117
118    /// Timeout for PR polling (seconds).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub poll_timeout: Option<u64>,
121
122    /// Startup case for PR loop (e.g., "new_pr", "existing_pr").
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub startup_case: Option<String>,
125
126    /// Latest commit SHA for PR.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub latest_commit_sha: Option<String>,
129
130    /// Timestamp of latest commit.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub latest_commit_at: Option<String>,
133
134    /// Timestamp of last trigger.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub last_trigger_at: Option<String>,
137
138    /// ID of trigger comment.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub trigger_comment_id: Option<String>,
141}
142
143// Default functions for serde
144fn default_max_iterations() -> u32 {
145    42
146}
147
148fn default_codex_model() -> String {
149    "gpt-5.4".to_string()
150}
151
152fn default_codex_effort() -> String {
153    "xhigh".to_string()
154}
155
156fn default_codex_timeout() -> u64 {
157    5400
158}
159
160fn default_full_review_round() -> u32 {
161    5
162}
163
164fn default_ask_codex_question() -> bool {
165    true
166}
167
168impl Default for State {
169    fn default() -> Self {
170        Self {
171            current_round: 0,
172            max_iterations: default_max_iterations(),
173            codex_model: default_codex_model(),
174            codex_effort: default_codex_effort(),
175            codex_timeout: default_codex_timeout(),
176            push_every_round: false,
177            full_review_round: default_full_review_round(),
178            plan_file: String::new(),
179            plan_tracked: false,
180            start_branch: String::new(),
181            base_branch: String::new(),
182            base_commit: String::new(),
183            review_started: false,
184            ask_codex_question: default_ask_codex_question(),
185            session_id: None,
186            agent_teams: false,
187            started_at: None,
188            // PR loop fields
189            pr_number: None,
190            configured_bots: None,
191            active_bots: None,
192            poll_interval: None,
193            poll_timeout: None,
194            startup_case: None,
195            latest_commit_sha: None,
196            latest_commit_at: None,
197            last_trigger_at: None,
198            trigger_comment_id: None,
199        }
200    }
201}
202
203impl State {
204    /// Parse state from a file containing YAML frontmatter.
205    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, StateError> {
206        let content = std::fs::read_to_string(path.as_ref())
207            .map_err(|e| StateError::IoError(e.to_string()))?;
208        Self::from_markdown(&content)
209    }
210
211    /// Parse state from markdown content with YAML frontmatter.
212    pub fn from_markdown(content: &str) -> Result<Self, StateError> {
213        let content = content.trim();
214
215        // Check for YAML frontmatter start
216        if !content.starts_with(YAML_FRONTMATTER_START) {
217            return Err(StateError::MissingFrontmatter);
218        }
219
220        // Find the closing delimiter (must be on its own line)
221        let rest = &content[YAML_FRONTMATTER_START.len()..];
222        let end_pos = rest
223            .find("\n---")
224            .ok_or(StateError::MissingFrontmatterEnd)?;
225
226        let yaml_content = &rest[..end_pos];
227
228        // Parse YAML
229        let state: State = serde_yaml::from_str(yaml_content)
230            .map_err(|e| StateError::YamlParseError(e.to_string()))?;
231
232        // Ensure defaults are applied for missing fields
233        // (serde's default attribute handles this for Option types and defaults)
234
235        Ok(state)
236    }
237
238    /// Parse state from markdown with strict validation of required fields.
239    ///
240    /// This matches the shell behavior in loop-common.sh parse_state_file_strict()
241    /// which rejects missing required fields: current_round, max_iterations,
242    /// review_started, and base_branch.
243    pub fn from_markdown_strict(content: &str) -> Result<Self, StateError> {
244        let content = content.trim();
245
246        // Check for YAML frontmatter start
247        if !content.starts_with(YAML_FRONTMATTER_START) {
248            return Err(StateError::MissingFrontmatter);
249        }
250
251        // Find the closing delimiter
252        let rest = &content[YAML_FRONTMATTER_START.len()..];
253        let end_pos = rest
254            .find("\n---")
255            .ok_or(StateError::MissingFrontmatterEnd)?;
256
257        let yaml_content = &rest[..end_pos];
258
259        // First parse as generic YAML to check for required fields
260        let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_content)
261            .map_err(|e| StateError::YamlParseError(e.to_string()))?;
262
263        let mapping = yaml_value.as_mapping().ok_or_else(|| {
264            StateError::MissingRequiredField("YAML must be a mapping".to_string())
265        })?;
266
267        // Validate required fields per loop-common.sh parse_state_file_strict
268        let required_fields = [
269            "current_round",
270            "max_iterations",
271            "review_started",
272            "base_branch",
273        ];
274        for field in &required_fields {
275            if !mapping.contains_key(serde_yaml::Value::String(field.to_string())) {
276                return Err(StateError::MissingRequiredField(field.to_string()));
277            }
278        }
279
280        // Now parse into State struct
281        let state: State = serde_yaml::from_str(yaml_content)
282            .map_err(|e| StateError::YamlParseError(e.to_string()))?;
283
284        Ok(state)
285    }
286
287    /// Serialize state to markdown with YAML frontmatter.
288    pub fn to_markdown(&self) -> Result<String, StateError> {
289        let yaml = serde_yaml::to_string(self)
290            .map_err(|e| StateError::YamlSerializeError(e.to_string()))?;
291
292        // Format: ---\n<yaml>\n---\n\n
293        // This matches the Bash implementation's format
294        Ok(format!(
295            "{}\n{}\n{}\n\n",
296            YAML_FRONTMATTER_START,
297            yaml.trim_end(),
298            YAML_FRONTMATTER_END
299        ))
300    }
301
302    /// Save state to a file.
303    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), StateError> {
304        let content = self.to_markdown()?;
305        std::fs::write(path.as_ref(), content).map_err(|e| StateError::IoError(e.to_string()))?;
306        Ok(())
307    }
308
309    /// Create a new RLCR state with the given parameters.
310    pub fn new_rlcr(
311        plan_file: String,
312        plan_tracked: bool,
313        start_branch: String,
314        base_branch: String,
315        base_commit: String,
316        max_iterations: Option<u32>,
317        codex_model: Option<String>,
318        codex_effort: Option<String>,
319        codex_timeout: Option<u64>,
320        push_every_round: bool,
321        full_review_round: Option<u32>,
322        ask_codex_question: bool,
323        agent_teams: bool,
324        review_started: bool,
325    ) -> Self {
326        let now = chrono_lite_now();
327        Self {
328            current_round: 0,
329            max_iterations: max_iterations.unwrap_or_else(default_max_iterations),
330            codex_model: codex_model.unwrap_or_else(default_codex_model),
331            codex_effort: codex_effort.unwrap_or_else(default_codex_effort),
332            codex_timeout: codex_timeout.unwrap_or_else(default_codex_timeout),
333            push_every_round,
334            full_review_round: full_review_round.unwrap_or_else(default_full_review_round),
335            plan_file,
336            plan_tracked,
337            start_branch,
338            base_branch,
339            base_commit,
340            review_started,
341            ask_codex_question,
342            session_id: None, // Empty initially, filled by PostToolUse hook
343            agent_teams,
344            started_at: Some(now),
345            // PR loop fields (all None for RLCR)
346            pr_number: None,
347            configured_bots: None,
348            active_bots: None,
349            poll_interval: None,
350            poll_timeout: None,
351            startup_case: None,
352            latest_commit_sha: None,
353            latest_commit_at: None,
354            last_trigger_at: None,
355            trigger_comment_id: None,
356        }
357    }
358
359    /// Increment the round counter.
360    pub fn increment_round(&mut self) {
361        self.current_round += 1;
362    }
363
364    /// Check if max iterations have been reached.
365    pub fn is_max_iterations_reached(&self) -> bool {
366        self.current_round >= self.max_iterations
367    }
368
369    /// Check if this is a terminal state filename.
370    pub fn is_terminal_state_file(filename: &str) -> bool {
371        let terminal_states = [
372            "complete-state.md",
373            "cancel-state.md",
374            "maxiter-state.md",
375            "stop-state.md",
376            "unexpected-state.md",
377            "approve-state.md",
378            "merged-state.md",
379            "closed-state.md",
380        ];
381        terminal_states.contains(&filename)
382    }
383
384    /// Check if a reason is a valid terminal state reason.
385    pub fn is_valid_terminal_reason(reason: &str) -> bool {
386        matches!(
387            reason,
388            "complete"
389                | "cancel"
390                | "maxiter"
391                | "stop"
392                | "unexpected"
393                | "approve"
394                | "merged"
395                | "closed"
396        )
397    }
398
399    /// Get the terminal state filename for a given exit reason.
400    ///
401    /// Returns None if the reason is not a valid terminal reason.
402    /// Shell contract: invalid reasons should error, not silently map to unexpected.
403    pub fn terminal_state_filename(reason: &str) -> Option<&'static str> {
404        match reason {
405            "complete" => Some("complete-state.md"),
406            "cancel" => Some("cancel-state.md"),
407            "maxiter" => Some("maxiter-state.md"),
408            "stop" => Some("stop-state.md"),
409            "unexpected" => Some("unexpected-state.md"),
410            "approve" => Some("approve-state.md"),
411            "merged" => Some("merged-state.md"),
412            "closed" => Some("closed-state.md"),
413            _ => None,
414        }
415    }
416
417    /// Rename state file to terminal state file.
418    ///
419    /// This implements the end-loop rename behavior from loop-common.sh:
420    /// After determining the exit reason, rename state.md to <reason>-state.md
421    ///
422    /// Returns error if reason is not valid (matching shell end_loop behavior).
423    pub fn rename_to_terminal<P: AsRef<Path>>(
424        state_path: P,
425        reason: &str,
426    ) -> Result<PathBuf, StateError> {
427        let terminal_name = Self::terminal_state_filename(reason)
428            .ok_or_else(|| StateError::InvalidTerminalReason(reason.to_string()))?;
429
430        let state_path = state_path.as_ref();
431        let dir = state_path
432            .parent()
433            .ok_or_else(|| StateError::IoError("Cannot determine parent directory".to_string()))?;
434
435        let terminal_path = dir.join(terminal_name);
436
437        std::fs::rename(state_path, &terminal_path)
438            .map_err(|e| StateError::IoError(e.to_string()))?;
439
440        Ok(terminal_path)
441    }
442}
443
444/// Find the active RLCR loop directory.
445///
446/// Matches loop-common.sh find_active_loop behavior exactly:
447/// - Without session filter: only check the single newest directory (zombie-loop protection)
448/// - With session filter: iterate newest-to-oldest, find first matching session
449/// - Empty stored session_id matches any filter (backward compatibility)
450/// - Only return if still active (has active state file, not terminal)
451pub fn find_active_loop(base_dir: &Path, session_id: Option<&str>) -> 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!(
704            State::terminal_state_filename("complete"),
705            Some("complete-state.md")
706        );
707        assert_eq!(
708            State::terminal_state_filename("cancel"),
709            Some("cancel-state.md")
710        );
711        assert_eq!(
712            State::terminal_state_filename("maxiter"),
713            Some("maxiter-state.md")
714        );
715        assert_eq!(State::terminal_state_filename("unknown"), None); // Invalid reason returns None
716    }
717
718    #[test]
719    fn test_strict_parsing_rejects_missing_required_fields() {
720        // Missing base_branch
721        let content_missing_base = r#"---
722current_round: 0
723max_iterations: 42
724review_started: false
725---
726"#;
727        let result = State::from_markdown_strict(content_missing_base);
728        assert!(result.is_err());
729        match result {
730            Err(StateError::MissingRequiredField(field)) => {
731                assert_eq!(field, "base_branch");
732            }
733            _ => panic!("Expected MissingRequiredField error for base_branch"),
734        }
735
736        // Missing max_iterations
737        let content_missing_max = r#"---
738current_round: 0
739review_started: false
740base_branch: master
741---
742"#;
743        let result = State::from_markdown_strict(content_missing_max);
744        assert!(result.is_err());
745
746        // Valid state with all required fields
747        let content_valid = r#"---
748current_round: 0
749max_iterations: 42
750review_started: false
751base_branch: master
752---
753"#;
754        let result = State::from_markdown_strict(content_valid);
755        assert!(result.is_ok());
756    }
757}