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, Eq)]
36#[serde(rename_all = "snake_case")]
37pub enum PlanMode {
38 Snapshot,
39 SourceClean,
40 SourceImmutable,
41}
42
43impl Default for PlanMode {
44 fn default() -> Self {
45 Self::Snapshot
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct State {
55 #[serde(default)]
57 pub current_round: u32,
58
59 #[serde(default = "default_max_iterations")]
61 pub max_iterations: u32,
62
63 #[serde(default = "default_codex_model")]
65 pub codex_model: String,
66
67 #[serde(default = "default_codex_effort")]
69 pub codex_effort: String,
70
71 #[serde(default = "default_codex_timeout")]
73 pub codex_timeout: u64,
74
75 #[serde(default)]
77 pub push_every_round: bool,
78
79 #[serde(default = "default_full_review_round")]
81 pub full_review_round: u32,
82
83 #[serde(default)]
85 pub plan_file: String,
86
87 #[serde(default)]
89 pub plan_tracked: bool,
90
91 #[serde(default)]
93 pub plan_mode: PlanMode,
94
95 #[serde(default)]
97 pub plan_source_path: String,
98
99 #[serde(default)]
101 pub plan_source_exists_at_start: bool,
102
103 #[serde(default)]
105 pub plan_source_tracked_at_start: bool,
106
107 #[serde(default)]
109 pub plan_source_sha256: String,
110
111 #[serde(default)]
113 pub plan_snapshot_path: String,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub plan_source_git_oid: Option<String>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub source_plan_id: Option<String>,
122
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub source_plan_revision: Option<u32>,
126
127 #[serde(default)]
129 pub start_branch: String,
130
131 #[serde(default)]
133 pub base_branch: String,
134
135 #[serde(default)]
137 pub base_commit: String,
138
139 #[serde(default)]
141 pub review_started: bool,
142
143 #[serde(default = "default_ask_codex_question")]
145 pub ask_codex_question: bool,
146
147 #[serde(default, serialize_with = "serialize_optional_empty")]
150 pub session_id: Option<String>,
151
152 #[serde(default)]
154 pub agent_teams: bool,
155
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub started_at: Option<String>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub pr_number: Option<u32>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub configured_bots: Option<Vec<String>>,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub active_bots: Option<Vec<String>>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub poll_interval: Option<u64>,
176
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub poll_timeout: Option<u64>,
180
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub startup_case: Option<String>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub latest_commit_sha: Option<String>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub latest_commit_at: Option<String>,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub last_trigger_at: Option<String>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub trigger_comment_id: Option<String>,
200}
201
202fn default_max_iterations() -> u32 {
204 42
205}
206
207fn default_codex_model() -> String {
208 "gpt-5.4".to_string()
209}
210
211fn default_codex_effort() -> String {
212 "xhigh".to_string()
213}
214
215fn default_codex_timeout() -> u64 {
216 5400
217}
218
219fn default_full_review_round() -> u32 {
220 5
221}
222
223fn default_ask_codex_question() -> bool {
224 true
225}
226
227impl Default for State {
228 fn default() -> Self {
229 Self {
230 current_round: 0,
231 max_iterations: default_max_iterations(),
232 codex_model: default_codex_model(),
233 codex_effort: default_codex_effort(),
234 codex_timeout: default_codex_timeout(),
235 push_every_round: false,
236 full_review_round: default_full_review_round(),
237 plan_file: String::new(),
238 plan_tracked: false,
239 plan_mode: PlanMode::Snapshot,
240 plan_source_path: String::new(),
241 plan_source_exists_at_start: false,
242 plan_source_tracked_at_start: false,
243 plan_source_sha256: String::new(),
244 plan_snapshot_path: String::new(),
245 plan_source_git_oid: None,
246 source_plan_id: None,
247 source_plan_revision: None,
248 start_branch: String::new(),
249 base_branch: String::new(),
250 base_commit: String::new(),
251 review_started: false,
252 ask_codex_question: default_ask_codex_question(),
253 session_id: None,
254 agent_teams: false,
255 started_at: None,
256 pr_number: None,
258 configured_bots: None,
259 active_bots: None,
260 poll_interval: None,
261 poll_timeout: None,
262 startup_case: None,
263 latest_commit_sha: None,
264 latest_commit_at: None,
265 last_trigger_at: None,
266 trigger_comment_id: None,
267 }
268 }
269}
270
271impl State {
272 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, StateError> {
274 let content = std::fs::read_to_string(path.as_ref())
275 .map_err(|e| StateError::IoError(e.to_string()))?;
276 Self::from_markdown(&content)
277 }
278
279 pub fn from_markdown(content: &str) -> Result<Self, StateError> {
281 let content = content.trim();
282
283 if !content.starts_with(YAML_FRONTMATTER_START) {
285 return Err(StateError::MissingFrontmatter);
286 }
287
288 let rest = &content[YAML_FRONTMATTER_START.len()..];
290 let end_pos = rest
291 .find("\n---")
292 .ok_or(StateError::MissingFrontmatterEnd)?;
293
294 let yaml_content = &rest[..end_pos];
295
296 let state: State = serde_yaml::from_str(yaml_content)
298 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
299
300 Ok(state)
304 }
305
306 pub fn from_markdown_strict(content: &str) -> Result<Self, StateError> {
312 let content = content.trim();
313
314 if !content.starts_with(YAML_FRONTMATTER_START) {
316 return Err(StateError::MissingFrontmatter);
317 }
318
319 let rest = &content[YAML_FRONTMATTER_START.len()..];
321 let end_pos = rest
322 .find("\n---")
323 .ok_or(StateError::MissingFrontmatterEnd)?;
324
325 let yaml_content = &rest[..end_pos];
326
327 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_content)
329 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
330
331 let mapping = yaml_value.as_mapping().ok_or_else(|| {
332 StateError::MissingRequiredField("YAML must be a mapping".to_string())
333 })?;
334
335 let required_fields = [
337 "current_round",
338 "max_iterations",
339 "review_started",
340 "base_branch",
341 ];
342 for field in &required_fields {
343 if !mapping.contains_key(serde_yaml::Value::String(field.to_string())) {
344 return Err(StateError::MissingRequiredField(field.to_string()));
345 }
346 }
347
348 let state: State = serde_yaml::from_str(yaml_content)
350 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
351
352 Ok(state)
353 }
354
355 pub fn to_markdown(&self) -> Result<String, StateError> {
357 let mut yaml = serde_yaml::to_string(self)
358 .map_err(|e| StateError::YamlSerializeError(e.to_string()))?;
359 yaml = yaml.replace("session_id: ''\n", "session_id:\n");
360 yaml = yaml.replace("session_id: \"\"\n", "session_id:\n");
361
362 Ok(format!(
365 "{}\n{}\n{}\n\n",
366 YAML_FRONTMATTER_START,
367 yaml.trim_end(),
368 YAML_FRONTMATTER_END
369 ))
370 }
371
372 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), StateError> {
374 let content = self.to_markdown()?;
375 std::fs::write(path.as_ref(), content).map_err(|e| StateError::IoError(e.to_string()))?;
376 Ok(())
377 }
378
379 pub fn new_rlcr(
381 plan_file: String,
382 plan_tracked: bool,
383 plan_mode: PlanMode,
384 plan_source_path: String,
385 plan_source_exists_at_start: bool,
386 plan_source_tracked_at_start: bool,
387 plan_source_sha256: String,
388 plan_snapshot_path: String,
389 plan_source_git_oid: Option<String>,
390 source_plan_id: Option<String>,
391 source_plan_revision: Option<u32>,
392 start_branch: String,
393 base_branch: String,
394 base_commit: String,
395 max_iterations: Option<u32>,
396 codex_model: Option<String>,
397 codex_effort: Option<String>,
398 codex_timeout: Option<u64>,
399 push_every_round: bool,
400 full_review_round: Option<u32>,
401 ask_codex_question: bool,
402 agent_teams: bool,
403 review_started: bool,
404 ) -> Self {
405 let now = chrono_lite_now();
406 Self {
407 current_round: 0,
408 max_iterations: max_iterations.unwrap_or_else(default_max_iterations),
409 codex_model: codex_model.unwrap_or_else(default_codex_model),
410 codex_effort: codex_effort.unwrap_or_else(default_codex_effort),
411 codex_timeout: codex_timeout.unwrap_or_else(default_codex_timeout),
412 push_every_round,
413 full_review_round: full_review_round.unwrap_or_else(default_full_review_round),
414 plan_file,
415 plan_tracked,
416 plan_mode,
417 plan_source_path,
418 plan_source_exists_at_start,
419 plan_source_tracked_at_start,
420 plan_source_sha256,
421 plan_snapshot_path,
422 plan_source_git_oid,
423 source_plan_id,
424 source_plan_revision,
425 start_branch,
426 base_branch,
427 base_commit,
428 review_started,
429 ask_codex_question,
430 session_id: None, agent_teams,
432 started_at: Some(now),
433 pr_number: None,
435 configured_bots: None,
436 active_bots: None,
437 poll_interval: None,
438 poll_timeout: None,
439 startup_case: None,
440 latest_commit_sha: None,
441 latest_commit_at: None,
442 last_trigger_at: None,
443 trigger_comment_id: None,
444 }
445 }
446
447 pub fn increment_round(&mut self) {
449 self.current_round += 1;
450 }
451
452 pub fn is_max_iterations_reached(&self) -> bool {
454 self.current_round >= self.max_iterations
455 }
456
457 pub fn is_terminal_state_file(filename: &str) -> bool {
459 let terminal_states = [
460 "complete-state.md",
461 "cancel-state.md",
462 "maxiter-state.md",
463 "stop-state.md",
464 "unexpected-state.md",
465 "approve-state.md",
466 "merged-state.md",
467 "closed-state.md",
468 ];
469 terminal_states.contains(&filename)
470 }
471
472 pub fn is_valid_terminal_reason(reason: &str) -> bool {
474 matches!(
475 reason,
476 "complete"
477 | "cancel"
478 | "maxiter"
479 | "stop"
480 | "unexpected"
481 | "approve"
482 | "merged"
483 | "closed"
484 )
485 }
486
487 pub fn terminal_state_filename(reason: &str) -> Option<&'static str> {
492 match reason {
493 "complete" => Some("complete-state.md"),
494 "cancel" => Some("cancel-state.md"),
495 "maxiter" => Some("maxiter-state.md"),
496 "stop" => Some("stop-state.md"),
497 "unexpected" => Some("unexpected-state.md"),
498 "approve" => Some("approve-state.md"),
499 "merged" => Some("merged-state.md"),
500 "closed" => Some("closed-state.md"),
501 _ => None,
502 }
503 }
504
505 pub fn rename_to_terminal<P: AsRef<Path>>(
512 state_path: P,
513 reason: &str,
514 ) -> Result<PathBuf, StateError> {
515 let terminal_name = Self::terminal_state_filename(reason)
516 .ok_or_else(|| StateError::InvalidTerminalReason(reason.to_string()))?;
517
518 let state_path = state_path.as_ref();
519 let dir = state_path
520 .parent()
521 .ok_or_else(|| StateError::IoError("Cannot determine parent directory".to_string()))?;
522
523 let terminal_path = dir.join(terminal_name);
524
525 std::fs::rename(state_path, &terminal_path)
526 .map_err(|e| StateError::IoError(e.to_string()))?;
527
528 Ok(terminal_path)
529 }
530}
531
532pub fn find_active_loop(base_dir: &Path, session_id: Option<&str>) -> Option<PathBuf> {
540 if !base_dir.exists() {
541 return None;
542 }
543
544 let entries = match std::fs::read_dir(base_dir) {
545 Ok(e) => e,
546 Err(_) => return None,
547 };
548
549 let mut dirs = entries
550 .flatten()
551 .map(|entry| entry.path())
552 .filter(|path| path.is_dir())
553 .collect::<Vec<_>>();
554 dirs.sort();
555 dirs.reverse();
556
557 if session_id.is_none() {
558 if let Some(newest_dir) = dirs.first() {
560 if resolve_active_state_file(newest_dir).is_some() {
562 return Some(newest_dir.clone());
563 }
564 }
565 return None;
566 }
567
568 let filter_sid = session_id.unwrap();
570
571 for loop_dir in dirs {
572 let any_state = resolve_any_state_file(&loop_dir);
574 if any_state.is_none() {
575 continue;
576 }
577
578 let stored_session_id = any_state
580 .and_then(|path| std::fs::read_to_string(path).ok())
581 .and_then(|content| extract_session_id_from_frontmatter(&content));
582
583 let matches_session = match stored_session_id {
585 None => true, Some(ref stored) => stored == filter_sid,
587 };
588
589 if matches_session {
590 if resolve_active_state_file(&loop_dir).is_some() {
592 return Some(loop_dir);
593 }
594 return None;
596 }
597 }
598
599 None
600}
601
602fn extract_session_id_from_frontmatter(content: &str) -> Option<String> {
603 let mut lines = content.lines();
604 if lines.next()? != "---" {
605 return None;
606 }
607
608 for line in lines {
609 if line == "---" {
610 break;
611 }
612
613 if let Some(value) = line.strip_prefix("session_id:") {
614 return normalize_empty_session_id(value.trim());
615 }
616 }
617
618 None
619}
620
621pub fn resolve_active_state_file(loop_dir: &Path) -> Option<PathBuf> {
627 let finalize_file = loop_dir.join("finalize-state.md");
629 if finalize_file.exists() {
630 return Some(finalize_file);
631 }
632
633 let state_file = loop_dir.join("state.md");
635 if state_file.exists() {
636 return Some(state_file);
637 }
638
639 None
640}
641
642pub fn resolve_any_state_file(loop_dir: &Path) -> Option<PathBuf> {
648 if let Some(active) = resolve_active_state_file(loop_dir) {
650 return Some(active);
651 }
652
653 let terminal_states = [
655 "complete-state.md",
656 "cancel-state.md",
657 "maxiter-state.md",
658 "stop-state.md",
659 "unexpected-state.md",
660 "approve-state.md",
661 "merged-state.md",
662 "closed-state.md",
663 ];
664
665 for terminal in &terminal_states {
666 let path = loop_dir.join(terminal);
667 if path.exists() {
668 return Some(path);
669 }
670 }
671
672 None
673}
674
675pub fn is_finalize_phase(loop_dir: &Path) -> bool {
679 loop_dir.join("finalize-state.md").exists()
680}
681
682pub fn has_pending_session(project_root: &Path) -> bool {
686 project_root.join(".humanize/.pending-session-id").exists()
687}
688
689fn chrono_lite_now() -> String {
691 use chrono::Utc;
692 Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
693}
694
695#[derive(Debug, thiserror::Error)]
697pub enum StateError {
698 #[error("IO error: {0}")]
699 IoError(String),
700
701 #[error("Missing YAML frontmatter")]
702 MissingFrontmatter,
703
704 #[error("Missing YAML frontmatter end delimiter")]
705 MissingFrontmatterEnd,
706
707 #[error("YAML parse error: {0}")]
708 YamlParseError(String),
709
710 #[error("YAML serialize error: {0}")]
711 YamlSerializeError(String),
712
713 #[error("Missing required field: {0}")]
714 MissingRequiredField(String),
715
716 #[error("Invalid terminal reason: {0}")]
717 InvalidTerminalReason(String),
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn test_state_to_markdown() {
726 let state = State::default();
727 let md = state.to_markdown().unwrap();
728 assert!(md.starts_with("---\n"));
729 assert!(md.contains("current_round: 0"));
730 assert!(md.contains("session_id:\n"));
731 assert!(!md.contains("session_id: ''"));
732 }
733
734 #[test]
735 fn test_state_from_markdown() {
736 let content = r#"---
737current_round: 1
738max_iterations: 42
739codex_model: gpt-5.4
740codex_effort: high
741codex_timeout: 5400
742push_every_round: false
743full_review_round: 5
744plan_file: docs/plan.md
745plan_tracked: false
746start_branch: master
747base_branch: master
748base_commit: abc123
749review_started: false
750ask_codex_question: true
751session_id:
752agent_teams: false
753---
754
755Some content below.
756"#;
757
758 let state = State::from_markdown(content).unwrap();
759 assert_eq!(state.current_round, 1);
760 assert_eq!(state.plan_file, "docs/plan.md");
761 assert!(state.session_id.is_none());
762 }
763
764 #[test]
765 fn test_state_roundtrip() {
766 let original = State::new_rlcr(
767 "docs/plan.md".to_string(),
768 false,
769 PlanMode::Snapshot,
770 "docs/plan.md".to_string(),
771 true,
772 false,
773 "sha256".to_string(),
774 ".humanize/rlcr/session/plan.md".to_string(),
775 None,
776 None,
777 None,
778 "master".to_string(),
779 "master".to_string(),
780 "abc123".to_string(),
781 None,
782 None,
783 None,
784 None,
785 false,
786 None,
787 true,
788 false,
789 false,
790 );
791
792 let md = original.to_markdown().unwrap();
793 let parsed = State::from_markdown(&md).unwrap();
794
795 assert_eq!(original.current_round, parsed.current_round);
796 assert_eq!(original.plan_file, parsed.plan_file);
797 assert_eq!(original.base_branch, parsed.base_branch);
798 assert_eq!(original.plan_mode, parsed.plan_mode);
799 }
800
801 #[test]
802 fn test_terminal_state_filename() {
803 assert_eq!(
804 State::terminal_state_filename("complete"),
805 Some("complete-state.md")
806 );
807 assert_eq!(
808 State::terminal_state_filename("cancel"),
809 Some("cancel-state.md")
810 );
811 assert_eq!(
812 State::terminal_state_filename("maxiter"),
813 Some("maxiter-state.md")
814 );
815 assert_eq!(State::terminal_state_filename("unknown"), None); }
817
818 #[test]
819 fn test_strict_parsing_rejects_missing_required_fields() {
820 let content_missing_base = r#"---
822current_round: 0
823max_iterations: 42
824review_started: false
825---
826"#;
827 let result = State::from_markdown_strict(content_missing_base);
828 assert!(result.is_err());
829 match result {
830 Err(StateError::MissingRequiredField(field)) => {
831 assert_eq!(field, "base_branch");
832 }
833 _ => panic!("Expected MissingRequiredField error for base_branch"),
834 }
835
836 let content_missing_max = r#"---
838current_round: 0
839review_started: false
840base_branch: master
841---
842"#;
843 let result = State::from_markdown_strict(content_missing_max);
844 assert!(result.is_err());
845
846 let content_valid = r#"---
848current_round: 0
849max_iterations: 42
850review_started: false
851base_branch: master
852---
853"#;
854 let result = State::from_markdown_strict(content_valid);
855 assert!(result.is_ok());
856 }
857
858 #[test]
859 fn find_active_loop_uses_directory_name_order_without_session_filter() {
860 let tempdir = tempfile::tempdir().unwrap();
861 let base = tempdir.path().join("rlcr");
862 std::fs::create_dir_all(&base).unwrap();
863
864 let older = base.join("2026-03-18_00-00-00");
865 let newer = base.join("2026-03-19_00-00-00");
866 std::fs::create_dir_all(&older).unwrap();
867 std::fs::create_dir_all(&newer).unwrap();
868
869 std::fs::write(
870 older.join("state.md"),
871 "---\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",
872 )
873 .unwrap();
874 std::fs::write(
875 newer.join("cancel-state.md"),
876 "---\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",
877 )
878 .unwrap();
879
880 assert_eq!(find_active_loop(&base, None), None);
881 }
882
883 #[test]
884 fn find_active_loop_stops_at_newest_terminal_match_for_session() {
885 let tempdir = tempfile::tempdir().unwrap();
886 let base = tempdir.path().join("rlcr");
887 std::fs::create_dir_all(&base).unwrap();
888
889 let older = base.join("2026-03-18_00-00-00");
890 let newer = base.join("2026-03-19_00-00-00");
891 std::fs::create_dir_all(&older).unwrap();
892 std::fs::create_dir_all(&newer).unwrap();
893
894 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";
895 std::fs::write(older.join("state.md"), state).unwrap();
896 std::fs::write(newer.join("cancel-state.md"), state).unwrap();
897
898 assert_eq!(find_active_loop(&base, Some("session-1")), None);
899 }
900}