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 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 dirs_with_mtime.sort_by(|a, b| b.1.cmp(&a.1));
487
488 if session_id.is_none() {
489 if let Some((newest_dir, _)) = dirs_with_mtime.first() {
491 if resolve_active_state_file(newest_dir).is_some() {
493 return Some(newest_dir.clone());
494 }
495 }
496 return None;
497 }
498
499 let filter_sid = session_id.unwrap();
501
502 for (loop_dir, _) in dirs_with_mtime {
503 let any_state = resolve_any_state_file(&loop_dir);
505 if any_state.is_none() {
506 continue;
507 }
508
509 let stored_session_id = any_state
511 .and_then(|path| std::fs::read_to_string(path).ok())
512 .and_then(|content| {
513 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; }
522 }
523 None
524 });
525
526 let matches_session = match stored_session_id {
528 None => true, Some(ref stored) => stored == filter_sid,
530 };
531
532 if matches_session {
533 if resolve_active_state_file(&loop_dir).is_some() {
535 return Some(loop_dir);
536 }
537 }
538 }
539
540 None
541}
542
543pub fn resolve_active_state_file(loop_dir: &Path) -> Option<PathBuf> {
549 let finalize_file = loop_dir.join("finalize-state.md");
551 if finalize_file.exists() {
552 return Some(finalize_file);
553 }
554
555 let state_file = loop_dir.join("state.md");
557 if state_file.exists() {
558 return Some(state_file);
559 }
560
561 None
562}
563
564pub fn resolve_any_state_file(loop_dir: &Path) -> Option<PathBuf> {
570 if let Some(active) = resolve_active_state_file(loop_dir) {
572 return Some(active);
573 }
574
575 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
597pub fn is_finalize_phase(loop_dir: &Path) -> bool {
601 loop_dir.join("finalize-state.md").exists()
602}
603
604pub fn has_pending_session(project_root: &Path) -> bool {
608 project_root.join(".humanize/.pending-session-id").exists()
609}
610
611fn chrono_lite_now() -> String {
613 use chrono::Utc;
614 Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
615}
616
617#[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); }
729
730 #[test]
731 fn test_strict_parsing_rejects_missing_required_fields() {
732 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 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 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}