1use serde::{Deserialize, Serialize, Serializer};
10use std::path::{Path, PathBuf};
11
12use crate::constants::{YAML_FRONTMATTER_END, YAML_FRONTMATTER_START};
13
14fn 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)]
40pub struct State {
41 #[serde(default)]
43 pub current_round: u32,
44
45 #[serde(default = "default_max_iterations")]
47 pub max_iterations: u32,
48
49 #[serde(default = "default_codex_model")]
51 pub codex_model: String,
52
53 #[serde(default = "default_codex_effort")]
55 pub codex_effort: String,
56
57 #[serde(default = "default_codex_timeout")]
59 pub codex_timeout: u64,
60
61 #[serde(default)]
63 pub push_every_round: bool,
64
65 #[serde(default = "default_full_review_round")]
67 pub full_review_round: u32,
68
69 #[serde(default)]
71 pub plan_file: String,
72
73 #[serde(default)]
75 pub plan_tracked: bool,
76
77 #[serde(default)]
79 pub start_branch: String,
80
81 #[serde(default)]
83 pub base_branch: String,
84
85 #[serde(default)]
87 pub base_commit: String,
88
89 #[serde(default)]
91 pub review_started: bool,
92
93 #[serde(default = "default_ask_codex_question")]
95 pub ask_codex_question: bool,
96
97 #[serde(default, serialize_with = "serialize_optional_empty")]
100 pub session_id: Option<String>,
101
102 #[serde(default)]
104 pub agent_teams: bool,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub started_at: Option<String>,
109
110 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub pr_number: Option<u32>,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub configured_bots: Option<Vec<String>>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub active_bots: Option<Vec<String>>,
122
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub poll_interval: Option<u64>,
126
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub poll_timeout: Option<u64>,
130
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub startup_case: Option<String>,
134
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub latest_commit_sha: Option<String>,
138
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub latest_commit_at: Option<String>,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub last_trigger_at: Option<String>,
146
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub trigger_comment_id: Option<String>,
150}
151
152fn 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_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 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 pub fn from_markdown(content: &str) -> Result<Self, StateError> {
222 let content = content.trim();
223
224 if !content.starts_with(YAML_FRONTMATTER_START) {
226 return Err(StateError::MissingFrontmatter);
227 }
228
229 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 let state: State = serde_yaml::from_str(yaml_content)
239 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
240
241 Ok(state)
245 }
246
247 pub fn from_markdown_strict(content: &str) -> Result<Self, StateError> {
253 let content = content.trim();
254
255 if !content.starts_with(YAML_FRONTMATTER_START) {
257 return Err(StateError::MissingFrontmatter);
258 }
259
260 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 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 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 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 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 Ok(format!(
306 "{}\n{}\n{}\n\n",
307 YAML_FRONTMATTER_START,
308 yaml.trim_end(),
309 YAML_FRONTMATTER_END
310 ))
311 }
312
313 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 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, agent_teams,
355 started_at: Some(now),
356 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 pub fn increment_round(&mut self) {
372 self.current_round += 1;
373 }
374
375 pub fn is_max_iterations_reached(&self) -> bool {
377 self.current_round >= self.max_iterations
378 }
379
380 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 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 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 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
455pub fn find_active_loop(base_dir: &Path, session_id: Option<&str>) -> Option<PathBuf> {
463 if !base_dir.exists() {
464 return None;
465 }
466
467 let entries = match std::fs::read_dir(base_dir) {
468 Ok(e) => e,
469 Err(_) => return None,
470 };
471
472 let mut dirs = entries
473 .flatten()
474 .map(|entry| entry.path())
475 .filter(|path| path.is_dir())
476 .collect::<Vec<_>>();
477 dirs.sort();
478 dirs.reverse();
479
480 if session_id.is_none() {
481 if let Some(newest_dir) = dirs.first() {
483 if resolve_active_state_file(newest_dir).is_some() {
485 return Some(newest_dir.clone());
486 }
487 }
488 return None;
489 }
490
491 let filter_sid = session_id.unwrap();
493
494 for loop_dir in dirs {
495 let any_state = resolve_any_state_file(&loop_dir);
497 if any_state.is_none() {
498 continue;
499 }
500
501 let stored_session_id = any_state
503 .and_then(|path| std::fs::read_to_string(path).ok())
504 .and_then(|content| extract_session_id_from_frontmatter(&content));
505
506 let matches_session = match stored_session_id {
508 None => true, Some(ref stored) => stored == filter_sid,
510 };
511
512 if matches_session {
513 if resolve_active_state_file(&loop_dir).is_some() {
515 return Some(loop_dir);
516 }
517 return None;
519 }
520 }
521
522 None
523}
524
525fn extract_session_id_from_frontmatter(content: &str) -> Option<String> {
526 let mut lines = content.lines();
527 if lines.next()? != "---" {
528 return None;
529 }
530
531 for line in lines {
532 if line == "---" {
533 break;
534 }
535
536 if let Some(value) = line.strip_prefix("session_id:") {
537 return normalize_empty_session_id(value.trim());
538 }
539 }
540
541 None
542}
543
544pub fn resolve_active_state_file(loop_dir: &Path) -> Option<PathBuf> {
550 let finalize_file = loop_dir.join("finalize-state.md");
552 if finalize_file.exists() {
553 return Some(finalize_file);
554 }
555
556 let state_file = loop_dir.join("state.md");
558 if state_file.exists() {
559 return Some(state_file);
560 }
561
562 None
563}
564
565pub fn resolve_any_state_file(loop_dir: &Path) -> Option<PathBuf> {
571 if let Some(active) = resolve_active_state_file(loop_dir) {
573 return Some(active);
574 }
575
576 let terminal_states = [
578 "complete-state.md",
579 "cancel-state.md",
580 "maxiter-state.md",
581 "stop-state.md",
582 "unexpected-state.md",
583 "approve-state.md",
584 "merged-state.md",
585 "closed-state.md",
586 ];
587
588 for terminal in &terminal_states {
589 let path = loop_dir.join(terminal);
590 if path.exists() {
591 return Some(path);
592 }
593 }
594
595 None
596}
597
598pub fn is_finalize_phase(loop_dir: &Path) -> bool {
602 loop_dir.join("finalize-state.md").exists()
603}
604
605pub fn has_pending_session(project_root: &Path) -> bool {
609 project_root.join(".humanize/.pending-session-id").exists()
610}
611
612fn chrono_lite_now() -> String {
614 use chrono::Utc;
615 Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
616}
617
618#[derive(Debug, thiserror::Error)]
620pub enum StateError {
621 #[error("IO error: {0}")]
622 IoError(String),
623
624 #[error("Missing YAML frontmatter")]
625 MissingFrontmatter,
626
627 #[error("Missing YAML frontmatter end delimiter")]
628 MissingFrontmatterEnd,
629
630 #[error("YAML parse error: {0}")]
631 YamlParseError(String),
632
633 #[error("YAML serialize error: {0}")]
634 YamlSerializeError(String),
635
636 #[error("Missing required field: {0}")]
637 MissingRequiredField(String),
638
639 #[error("Invalid terminal reason: {0}")]
640 InvalidTerminalReason(String),
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 #[test]
648 fn test_state_to_markdown() {
649 let state = State::default();
650 let md = state.to_markdown().unwrap();
651 assert!(md.starts_with("---\n"));
652 assert!(md.contains("current_round: 0"));
653 assert!(md.contains("session_id:\n"));
654 assert!(!md.contains("session_id: ''"));
655 }
656
657 #[test]
658 fn test_state_from_markdown() {
659 let content = r#"---
660current_round: 1
661max_iterations: 42
662codex_model: gpt-5.4
663codex_effort: high
664codex_timeout: 5400
665push_every_round: false
666full_review_round: 5
667plan_file: docs/plan.md
668plan_tracked: false
669start_branch: master
670base_branch: master
671base_commit: abc123
672review_started: false
673ask_codex_question: true
674session_id:
675agent_teams: false
676---
677
678Some content below.
679"#;
680
681 let state = State::from_markdown(content).unwrap();
682 assert_eq!(state.current_round, 1);
683 assert_eq!(state.plan_file, "docs/plan.md");
684 assert!(state.session_id.is_none());
685 }
686
687 #[test]
688 fn test_state_roundtrip() {
689 let original = State::new_rlcr(
690 "docs/plan.md".to_string(),
691 false,
692 "master".to_string(),
693 "master".to_string(),
694 "abc123".to_string(),
695 None,
696 None,
697 None,
698 None,
699 false,
700 None,
701 true,
702 false,
703 false,
704 );
705
706 let md = original.to_markdown().unwrap();
707 let parsed = State::from_markdown(&md).unwrap();
708
709 assert_eq!(original.current_round, parsed.current_round);
710 assert_eq!(original.plan_file, parsed.plan_file);
711 assert_eq!(original.base_branch, parsed.base_branch);
712 }
713
714 #[test]
715 fn test_terminal_state_filename() {
716 assert_eq!(
717 State::terminal_state_filename("complete"),
718 Some("complete-state.md")
719 );
720 assert_eq!(
721 State::terminal_state_filename("cancel"),
722 Some("cancel-state.md")
723 );
724 assert_eq!(
725 State::terminal_state_filename("maxiter"),
726 Some("maxiter-state.md")
727 );
728 assert_eq!(State::terminal_state_filename("unknown"), None); }
730
731 #[test]
732 fn test_strict_parsing_rejects_missing_required_fields() {
733 let content_missing_base = r#"---
735current_round: 0
736max_iterations: 42
737review_started: false
738---
739"#;
740 let result = State::from_markdown_strict(content_missing_base);
741 assert!(result.is_err());
742 match result {
743 Err(StateError::MissingRequiredField(field)) => {
744 assert_eq!(field, "base_branch");
745 }
746 _ => panic!("Expected MissingRequiredField error for base_branch"),
747 }
748
749 let content_missing_max = r#"---
751current_round: 0
752review_started: false
753base_branch: master
754---
755"#;
756 let result = State::from_markdown_strict(content_missing_max);
757 assert!(result.is_err());
758
759 let content_valid = r#"---
761current_round: 0
762max_iterations: 42
763review_started: false
764base_branch: master
765---
766"#;
767 let result = State::from_markdown_strict(content_valid);
768 assert!(result.is_ok());
769 }
770
771 #[test]
772 fn find_active_loop_uses_directory_name_order_without_session_filter() {
773 let tempdir = tempfile::tempdir().unwrap();
774 let base = tempdir.path().join("rlcr");
775 std::fs::create_dir_all(&base).unwrap();
776
777 let older = base.join("2026-03-18_00-00-00");
778 let newer = base.join("2026-03-19_00-00-00");
779 std::fs::create_dir_all(&older).unwrap();
780 std::fs::create_dir_all(&newer).unwrap();
781
782 std::fs::write(
783 older.join("state.md"),
784 "---\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",
785 )
786 .unwrap();
787 std::fs::write(
788 newer.join("cancel-state.md"),
789 "---\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",
790 )
791 .unwrap();
792
793 assert_eq!(find_active_loop(&base, None), None);
794 }
795
796 #[test]
797 fn find_active_loop_stops_at_newest_terminal_match_for_session() {
798 let tempdir = tempfile::tempdir().unwrap();
799 let base = tempdir.path().join("rlcr");
800 std::fs::create_dir_all(&base).unwrap();
801
802 let older = base.join("2026-03-18_00-00-00");
803 let newer = base.join("2026-03-19_00-00-00");
804 std::fs::create_dir_all(&older).unwrap();
805 std::fs::create_dir_all(&newer).unwrap();
806
807 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";
808 std::fs::write(older.join("state.md"), state).unwrap();
809 std::fs::write(newer.join("cancel-state.md"), state).unwrap();
810
811 assert_eq!(find_active_loop(&base, Some("session-1")), None);
812 }
813}