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