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)]
121 pub start_branch: String,
122
123 #[serde(default)]
125 pub base_branch: String,
126
127 #[serde(default)]
129 pub base_commit: String,
130
131 #[serde(default)]
133 pub review_started: bool,
134
135 #[serde(default = "default_ask_codex_question")]
137 pub ask_codex_question: bool,
138
139 #[serde(default, serialize_with = "serialize_optional_empty")]
142 pub session_id: Option<String>,
143
144 #[serde(default)]
146 pub agent_teams: bool,
147
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub started_at: Option<String>,
151
152 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub pr_number: Option<u32>,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub configured_bots: Option<Vec<String>>,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub active_bots: Option<Vec<String>>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub poll_interval: Option<u64>,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub poll_timeout: Option<u64>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub startup_case: Option<String>,
176
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub latest_commit_sha: Option<String>,
180
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub latest_commit_at: Option<String>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub last_trigger_at: Option<String>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub trigger_comment_id: Option<String>,
192}
193
194fn default_max_iterations() -> u32 {
196 42
197}
198
199fn default_codex_model() -> String {
200 "gpt-5.4".to_string()
201}
202
203fn default_codex_effort() -> String {
204 "xhigh".to_string()
205}
206
207fn default_codex_timeout() -> u64 {
208 5400
209}
210
211fn default_full_review_round() -> u32 {
212 5
213}
214
215fn default_ask_codex_question() -> bool {
216 true
217}
218
219impl Default for State {
220 fn default() -> Self {
221 Self {
222 current_round: 0,
223 max_iterations: default_max_iterations(),
224 codex_model: default_codex_model(),
225 codex_effort: default_codex_effort(),
226 codex_timeout: default_codex_timeout(),
227 push_every_round: false,
228 full_review_round: default_full_review_round(),
229 plan_file: String::new(),
230 plan_tracked: false,
231 plan_mode: PlanMode::Snapshot,
232 plan_source_path: String::new(),
233 plan_source_exists_at_start: false,
234 plan_source_tracked_at_start: false,
235 plan_source_sha256: String::new(),
236 plan_snapshot_path: String::new(),
237 plan_source_git_oid: None,
238 start_branch: String::new(),
239 base_branch: String::new(),
240 base_commit: String::new(),
241 review_started: false,
242 ask_codex_question: default_ask_codex_question(),
243 session_id: None,
244 agent_teams: false,
245 started_at: None,
246 pr_number: None,
248 configured_bots: None,
249 active_bots: None,
250 poll_interval: None,
251 poll_timeout: None,
252 startup_case: None,
253 latest_commit_sha: None,
254 latest_commit_at: None,
255 last_trigger_at: None,
256 trigger_comment_id: None,
257 }
258 }
259}
260
261impl State {
262 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, StateError> {
264 let content = std::fs::read_to_string(path.as_ref())
265 .map_err(|e| StateError::IoError(e.to_string()))?;
266 Self::from_markdown(&content)
267 }
268
269 pub fn from_markdown(content: &str) -> Result<Self, StateError> {
271 let content = content.trim();
272
273 if !content.starts_with(YAML_FRONTMATTER_START) {
275 return Err(StateError::MissingFrontmatter);
276 }
277
278 let rest = &content[YAML_FRONTMATTER_START.len()..];
280 let end_pos = rest
281 .find("\n---")
282 .ok_or(StateError::MissingFrontmatterEnd)?;
283
284 let yaml_content = &rest[..end_pos];
285
286 let state: State = serde_yaml::from_str(yaml_content)
288 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
289
290 Ok(state)
294 }
295
296 pub fn from_markdown_strict(content: &str) -> Result<Self, StateError> {
302 let content = content.trim();
303
304 if !content.starts_with(YAML_FRONTMATTER_START) {
306 return Err(StateError::MissingFrontmatter);
307 }
308
309 let rest = &content[YAML_FRONTMATTER_START.len()..];
311 let end_pos = rest
312 .find("\n---")
313 .ok_or(StateError::MissingFrontmatterEnd)?;
314
315 let yaml_content = &rest[..end_pos];
316
317 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_content)
319 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
320
321 let mapping = yaml_value.as_mapping().ok_or_else(|| {
322 StateError::MissingRequiredField("YAML must be a mapping".to_string())
323 })?;
324
325 let required_fields = [
327 "current_round",
328 "max_iterations",
329 "review_started",
330 "base_branch",
331 ];
332 for field in &required_fields {
333 if !mapping.contains_key(serde_yaml::Value::String(field.to_string())) {
334 return Err(StateError::MissingRequiredField(field.to_string()));
335 }
336 }
337
338 let state: State = serde_yaml::from_str(yaml_content)
340 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
341
342 Ok(state)
343 }
344
345 pub fn to_markdown(&self) -> Result<String, StateError> {
347 let mut yaml = serde_yaml::to_string(self)
348 .map_err(|e| StateError::YamlSerializeError(e.to_string()))?;
349 yaml = yaml.replace("session_id: ''\n", "session_id:\n");
350 yaml = yaml.replace("session_id: \"\"\n", "session_id:\n");
351
352 Ok(format!(
355 "{}\n{}\n{}\n\n",
356 YAML_FRONTMATTER_START,
357 yaml.trim_end(),
358 YAML_FRONTMATTER_END
359 ))
360 }
361
362 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), StateError> {
364 let content = self.to_markdown()?;
365 std::fs::write(path.as_ref(), content).map_err(|e| StateError::IoError(e.to_string()))?;
366 Ok(())
367 }
368
369 pub fn new_rlcr(
371 plan_file: String,
372 plan_tracked: bool,
373 plan_mode: PlanMode,
374 plan_source_path: String,
375 plan_source_exists_at_start: bool,
376 plan_source_tracked_at_start: bool,
377 plan_source_sha256: String,
378 plan_snapshot_path: String,
379 plan_source_git_oid: Option<String>,
380 start_branch: String,
381 base_branch: String,
382 base_commit: String,
383 max_iterations: Option<u32>,
384 codex_model: Option<String>,
385 codex_effort: Option<String>,
386 codex_timeout: Option<u64>,
387 push_every_round: bool,
388 full_review_round: Option<u32>,
389 ask_codex_question: bool,
390 agent_teams: bool,
391 review_started: bool,
392 ) -> Self {
393 let now = chrono_lite_now();
394 Self {
395 current_round: 0,
396 max_iterations: max_iterations.unwrap_or_else(default_max_iterations),
397 codex_model: codex_model.unwrap_or_else(default_codex_model),
398 codex_effort: codex_effort.unwrap_or_else(default_codex_effort),
399 codex_timeout: codex_timeout.unwrap_or_else(default_codex_timeout),
400 push_every_round,
401 full_review_round: full_review_round.unwrap_or_else(default_full_review_round),
402 plan_file,
403 plan_tracked,
404 plan_mode,
405 plan_source_path,
406 plan_source_exists_at_start,
407 plan_source_tracked_at_start,
408 plan_source_sha256,
409 plan_snapshot_path,
410 plan_source_git_oid,
411 start_branch,
412 base_branch,
413 base_commit,
414 review_started,
415 ask_codex_question,
416 session_id: None, agent_teams,
418 started_at: Some(now),
419 pr_number: None,
421 configured_bots: None,
422 active_bots: None,
423 poll_interval: None,
424 poll_timeout: None,
425 startup_case: None,
426 latest_commit_sha: None,
427 latest_commit_at: None,
428 last_trigger_at: None,
429 trigger_comment_id: None,
430 }
431 }
432
433 pub fn increment_round(&mut self) {
435 self.current_round += 1;
436 }
437
438 pub fn is_max_iterations_reached(&self) -> bool {
440 self.current_round >= self.max_iterations
441 }
442
443 pub fn is_terminal_state_file(filename: &str) -> bool {
445 let terminal_states = [
446 "complete-state.md",
447 "cancel-state.md",
448 "maxiter-state.md",
449 "stop-state.md",
450 "unexpected-state.md",
451 "approve-state.md",
452 "merged-state.md",
453 "closed-state.md",
454 ];
455 terminal_states.contains(&filename)
456 }
457
458 pub fn is_valid_terminal_reason(reason: &str) -> bool {
460 matches!(
461 reason,
462 "complete"
463 | "cancel"
464 | "maxiter"
465 | "stop"
466 | "unexpected"
467 | "approve"
468 | "merged"
469 | "closed"
470 )
471 }
472
473 pub fn terminal_state_filename(reason: &str) -> Option<&'static str> {
478 match reason {
479 "complete" => Some("complete-state.md"),
480 "cancel" => Some("cancel-state.md"),
481 "maxiter" => Some("maxiter-state.md"),
482 "stop" => Some("stop-state.md"),
483 "unexpected" => Some("unexpected-state.md"),
484 "approve" => Some("approve-state.md"),
485 "merged" => Some("merged-state.md"),
486 "closed" => Some("closed-state.md"),
487 _ => None,
488 }
489 }
490
491 pub fn rename_to_terminal<P: AsRef<Path>>(
498 state_path: P,
499 reason: &str,
500 ) -> Result<PathBuf, StateError> {
501 let terminal_name = Self::terminal_state_filename(reason)
502 .ok_or_else(|| StateError::InvalidTerminalReason(reason.to_string()))?;
503
504 let state_path = state_path.as_ref();
505 let dir = state_path
506 .parent()
507 .ok_or_else(|| StateError::IoError("Cannot determine parent directory".to_string()))?;
508
509 let terminal_path = dir.join(terminal_name);
510
511 std::fs::rename(state_path, &terminal_path)
512 .map_err(|e| StateError::IoError(e.to_string()))?;
513
514 Ok(terminal_path)
515 }
516}
517
518pub fn find_active_loop(base_dir: &Path, session_id: Option<&str>) -> Option<PathBuf> {
526 if !base_dir.exists() {
527 return None;
528 }
529
530 let entries = match std::fs::read_dir(base_dir) {
531 Ok(e) => e,
532 Err(_) => return None,
533 };
534
535 let mut dirs = entries
536 .flatten()
537 .map(|entry| entry.path())
538 .filter(|path| path.is_dir())
539 .collect::<Vec<_>>();
540 dirs.sort();
541 dirs.reverse();
542
543 if session_id.is_none() {
544 if let Some(newest_dir) = dirs.first() {
546 if resolve_active_state_file(newest_dir).is_some() {
548 return Some(newest_dir.clone());
549 }
550 }
551 return None;
552 }
553
554 let filter_sid = session_id.unwrap();
556
557 for loop_dir in dirs {
558 let any_state = resolve_any_state_file(&loop_dir);
560 if any_state.is_none() {
561 continue;
562 }
563
564 let stored_session_id = any_state
566 .and_then(|path| std::fs::read_to_string(path).ok())
567 .and_then(|content| extract_session_id_from_frontmatter(&content));
568
569 let matches_session = match stored_session_id {
571 None => true, Some(ref stored) => stored == filter_sid,
573 };
574
575 if matches_session {
576 if resolve_active_state_file(&loop_dir).is_some() {
578 return Some(loop_dir);
579 }
580 return None;
582 }
583 }
584
585 None
586}
587
588fn extract_session_id_from_frontmatter(content: &str) -> Option<String> {
589 let mut lines = content.lines();
590 if lines.next()? != "---" {
591 return None;
592 }
593
594 for line in lines {
595 if line == "---" {
596 break;
597 }
598
599 if let Some(value) = line.strip_prefix("session_id:") {
600 return normalize_empty_session_id(value.trim());
601 }
602 }
603
604 None
605}
606
607pub fn resolve_active_state_file(loop_dir: &Path) -> Option<PathBuf> {
613 let finalize_file = loop_dir.join("finalize-state.md");
615 if finalize_file.exists() {
616 return Some(finalize_file);
617 }
618
619 let state_file = loop_dir.join("state.md");
621 if state_file.exists() {
622 return Some(state_file);
623 }
624
625 None
626}
627
628pub fn resolve_any_state_file(loop_dir: &Path) -> Option<PathBuf> {
634 if let Some(active) = resolve_active_state_file(loop_dir) {
636 return Some(active);
637 }
638
639 let terminal_states = [
641 "complete-state.md",
642 "cancel-state.md",
643 "maxiter-state.md",
644 "stop-state.md",
645 "unexpected-state.md",
646 "approve-state.md",
647 "merged-state.md",
648 "closed-state.md",
649 ];
650
651 for terminal in &terminal_states {
652 let path = loop_dir.join(terminal);
653 if path.exists() {
654 return Some(path);
655 }
656 }
657
658 None
659}
660
661pub fn is_finalize_phase(loop_dir: &Path) -> bool {
665 loop_dir.join("finalize-state.md").exists()
666}
667
668pub fn has_pending_session(project_root: &Path) -> bool {
672 project_root.join(".humanize/.pending-session-id").exists()
673}
674
675fn chrono_lite_now() -> String {
677 use chrono::Utc;
678 Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
679}
680
681#[derive(Debug, thiserror::Error)]
683pub enum StateError {
684 #[error("IO error: {0}")]
685 IoError(String),
686
687 #[error("Missing YAML frontmatter")]
688 MissingFrontmatter,
689
690 #[error("Missing YAML frontmatter end delimiter")]
691 MissingFrontmatterEnd,
692
693 #[error("YAML parse error: {0}")]
694 YamlParseError(String),
695
696 #[error("YAML serialize error: {0}")]
697 YamlSerializeError(String),
698
699 #[error("Missing required field: {0}")]
700 MissingRequiredField(String),
701
702 #[error("Invalid terminal reason: {0}")]
703 InvalidTerminalReason(String),
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[test]
711 fn test_state_to_markdown() {
712 let state = State::default();
713 let md = state.to_markdown().unwrap();
714 assert!(md.starts_with("---\n"));
715 assert!(md.contains("current_round: 0"));
716 assert!(md.contains("session_id:\n"));
717 assert!(!md.contains("session_id: ''"));
718 }
719
720 #[test]
721 fn test_state_from_markdown() {
722 let content = r#"---
723current_round: 1
724max_iterations: 42
725codex_model: gpt-5.4
726codex_effort: high
727codex_timeout: 5400
728push_every_round: false
729full_review_round: 5
730plan_file: docs/plan.md
731plan_tracked: false
732start_branch: master
733base_branch: master
734base_commit: abc123
735review_started: false
736ask_codex_question: true
737session_id:
738agent_teams: false
739---
740
741Some content below.
742"#;
743
744 let state = State::from_markdown(content).unwrap();
745 assert_eq!(state.current_round, 1);
746 assert_eq!(state.plan_file, "docs/plan.md");
747 assert!(state.session_id.is_none());
748 }
749
750 #[test]
751 fn test_state_roundtrip() {
752 let original = State::new_rlcr(
753 "docs/plan.md".to_string(),
754 false,
755 PlanMode::Snapshot,
756 "docs/plan.md".to_string(),
757 true,
758 false,
759 "sha256".to_string(),
760 ".humanize/rlcr/session/plan.md".to_string(),
761 None,
762 "master".to_string(),
763 "master".to_string(),
764 "abc123".to_string(),
765 None,
766 None,
767 None,
768 None,
769 false,
770 None,
771 true,
772 false,
773 false,
774 );
775
776 let md = original.to_markdown().unwrap();
777 let parsed = State::from_markdown(&md).unwrap();
778
779 assert_eq!(original.current_round, parsed.current_round);
780 assert_eq!(original.plan_file, parsed.plan_file);
781 assert_eq!(original.base_branch, parsed.base_branch);
782 assert_eq!(original.plan_mode, parsed.plan_mode);
783 }
784
785 #[test]
786 fn test_terminal_state_filename() {
787 assert_eq!(
788 State::terminal_state_filename("complete"),
789 Some("complete-state.md")
790 );
791 assert_eq!(
792 State::terminal_state_filename("cancel"),
793 Some("cancel-state.md")
794 );
795 assert_eq!(
796 State::terminal_state_filename("maxiter"),
797 Some("maxiter-state.md")
798 );
799 assert_eq!(State::terminal_state_filename("unknown"), None); }
801
802 #[test]
803 fn test_strict_parsing_rejects_missing_required_fields() {
804 let content_missing_base = r#"---
806current_round: 0
807max_iterations: 42
808review_started: false
809---
810"#;
811 let result = State::from_markdown_strict(content_missing_base);
812 assert!(result.is_err());
813 match result {
814 Err(StateError::MissingRequiredField(field)) => {
815 assert_eq!(field, "base_branch");
816 }
817 _ => panic!("Expected MissingRequiredField error for base_branch"),
818 }
819
820 let content_missing_max = r#"---
822current_round: 0
823review_started: false
824base_branch: master
825---
826"#;
827 let result = State::from_markdown_strict(content_missing_max);
828 assert!(result.is_err());
829
830 let content_valid = r#"---
832current_round: 0
833max_iterations: 42
834review_started: false
835base_branch: master
836---
837"#;
838 let result = State::from_markdown_strict(content_valid);
839 assert!(result.is_ok());
840 }
841
842 #[test]
843 fn find_active_loop_uses_directory_name_order_without_session_filter() {
844 let tempdir = tempfile::tempdir().unwrap();
845 let base = tempdir.path().join("rlcr");
846 std::fs::create_dir_all(&base).unwrap();
847
848 let older = base.join("2026-03-18_00-00-00");
849 let newer = base.join("2026-03-19_00-00-00");
850 std::fs::create_dir_all(&older).unwrap();
851 std::fs::create_dir_all(&newer).unwrap();
852
853 std::fs::write(
854 older.join("state.md"),
855 "---\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",
856 )
857 .unwrap();
858 std::fs::write(
859 newer.join("cancel-state.md"),
860 "---\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",
861 )
862 .unwrap();
863
864 assert_eq!(find_active_loop(&base, None), None);
865 }
866
867 #[test]
868 fn find_active_loop_stops_at_newest_terminal_match_for_session() {
869 let tempdir = tempfile::tempdir().unwrap();
870 let base = tempdir.path().join("rlcr");
871 std::fs::create_dir_all(&base).unwrap();
872
873 let older = base.join("2026-03-18_00-00-00");
874 let newer = base.join("2026-03-19_00-00-00");
875 std::fs::create_dir_all(&older).unwrap();
876 std::fs::create_dir_all(&newer).unwrap();
877
878 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";
879 std::fs::write(older.join("state.md"), state).unwrap();
880 std::fs::write(newer.join("cancel-state.md"), state).unwrap();
881
882 assert_eq!(find_active_loop(&base, Some("session-1")), None);
883 }
884}