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
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct State {
32 #[serde(default)]
34 pub current_round: u32,
35
36 #[serde(default = "default_max_iterations")]
38 pub max_iterations: u32,
39
40 #[serde(default = "default_codex_model")]
42 pub codex_model: String,
43
44 #[serde(default = "default_codex_effort")]
46 pub codex_effort: String,
47
48 #[serde(default = "default_codex_timeout")]
50 pub codex_timeout: u64,
51
52 #[serde(default)]
54 pub push_every_round: bool,
55
56 #[serde(default = "default_full_review_round")]
58 pub full_review_round: u32,
59
60 #[serde(default)]
62 pub plan_file: String,
63
64 #[serde(default)]
66 pub plan_tracked: bool,
67
68 #[serde(default)]
70 pub start_branch: String,
71
72 #[serde(default)]
74 pub base_branch: String,
75
76 #[serde(default)]
78 pub base_commit: String,
79
80 #[serde(default)]
82 pub review_started: bool,
83
84 #[serde(default = "default_ask_codex_question")]
86 pub ask_codex_question: bool,
87
88 #[serde(default, serialize_with = "serialize_optional_empty")]
91 pub session_id: Option<String>,
92
93 #[serde(default)]
95 pub agent_teams: bool,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub started_at: Option<String>,
100
101 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub pr_number: Option<u32>,
106
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub configured_bots: Option<Vec<String>>,
110
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub active_bots: Option<Vec<String>>,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub poll_interval: Option<u64>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub poll_timeout: Option<u64>,
122
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub startup_case: Option<String>,
126
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub latest_commit_sha: Option<String>,
130
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub latest_commit_at: Option<String>,
134
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub last_trigger_at: Option<String>,
138
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub trigger_comment_id: Option<String>,
142}
143
144fn default_max_iterations() -> u32 {
146 42
147}
148
149fn default_codex_model() -> String {
150 "gpt-5.4".to_string()
151}
152
153fn default_codex_effort() -> String {
154 "xhigh".to_string()
155}
156
157fn default_codex_timeout() -> u64 {
158 5400
159}
160
161fn default_full_review_round() -> u32 {
162 5
163}
164
165fn default_ask_codex_question() -> bool {
166 true
167}
168
169impl Default for State {
170 fn default() -> Self {
171 Self {
172 current_round: 0,
173 max_iterations: default_max_iterations(),
174 codex_model: default_codex_model(),
175 codex_effort: default_codex_effort(),
176 codex_timeout: default_codex_timeout(),
177 push_every_round: false,
178 full_review_round: default_full_review_round(),
179 plan_file: String::new(),
180 plan_tracked: false,
181 start_branch: String::new(),
182 base_branch: String::new(),
183 base_commit: String::new(),
184 review_started: false,
185 ask_codex_question: default_ask_codex_question(),
186 session_id: None,
187 agent_teams: false,
188 started_at: None,
189 pr_number: None,
191 configured_bots: None,
192 active_bots: None,
193 poll_interval: None,
194 poll_timeout: None,
195 startup_case: None,
196 latest_commit_sha: None,
197 latest_commit_at: None,
198 last_trigger_at: None,
199 trigger_comment_id: None,
200 }
201 }
202}
203
204impl State {
205 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, StateError> {
207 let content = std::fs::read_to_string(path.as_ref())
208 .map_err(|e| StateError::IoError(e.to_string()))?;
209 Self::from_markdown(&content)
210 }
211
212 pub fn from_markdown(content: &str) -> Result<Self, StateError> {
214 let content = content.trim();
215
216 if !content.starts_with(YAML_FRONTMATTER_START) {
218 return Err(StateError::MissingFrontmatter);
219 }
220
221 let rest = &content[YAML_FRONTMATTER_START.len()..];
223 let end_pos = rest
224 .find("\n---")
225 .ok_or(StateError::MissingFrontmatterEnd)?;
226
227 let yaml_content = &rest[..end_pos];
228
229 let state: State = serde_yaml::from_str(yaml_content)
231 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
232
233 Ok(state)
237 }
238
239 pub fn from_markdown_strict(content: &str) -> Result<Self, StateError> {
245 let content = content.trim();
246
247 if !content.starts_with(YAML_FRONTMATTER_START) {
249 return Err(StateError::MissingFrontmatter);
250 }
251
252 let rest = &content[YAML_FRONTMATTER_START.len()..];
254 let end_pos = rest
255 .find("\n---")
256 .ok_or(StateError::MissingFrontmatterEnd)?;
257
258 let yaml_content = &rest[..end_pos];
259
260 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_content)
262 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
263
264 let mapping = yaml_value
265 .as_mapping()
266 .ok_or_else(|| StateError::MissingRequiredField("YAML must be a mapping".to_string()))?;
267
268 let required_fields = ["current_round", "max_iterations", "review_started", "base_branch"];
270 for field in &required_fields {
271 if !mapping.contains_key(serde_yaml::Value::String(field.to_string())) {
272 return Err(StateError::MissingRequiredField(field.to_string()));
273 }
274 }
275
276 let state: State = serde_yaml::from_str(yaml_content)
278 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
279
280 Ok(state)
281 }
282
283 pub fn to_markdown(&self) -> Result<String, StateError> {
285 let yaml = serde_yaml::to_string(self)
286 .map_err(|e| StateError::YamlSerializeError(e.to_string()))?;
287
288 Ok(format!(
291 "{}\n{}\n{}\n\n",
292 YAML_FRONTMATTER_START,
293 yaml.trim_end(),
294 YAML_FRONTMATTER_END
295 ))
296 }
297
298 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), StateError> {
300 let content = self.to_markdown()?;
301 std::fs::write(path.as_ref(), content)
302 .map_err(|e| StateError::IoError(e.to_string()))?;
303 Ok(())
304 }
305
306 pub fn new_rlcr(
308 plan_file: String,
309 plan_tracked: bool,
310 start_branch: String,
311 base_branch: String,
312 base_commit: String,
313 max_iterations: Option<u32>,
314 codex_model: Option<String>,
315 codex_effort: Option<String>,
316 codex_timeout: Option<u64>,
317 push_every_round: bool,
318 full_review_round: Option<u32>,
319 ask_codex_question: bool,
320 agent_teams: bool,
321 review_started: bool,
322 ) -> Self {
323 let now = chrono_lite_now();
324 Self {
325 current_round: 0,
326 max_iterations: max_iterations.unwrap_or_else(default_max_iterations),
327 codex_model: codex_model.unwrap_or_else(default_codex_model),
328 codex_effort: codex_effort.unwrap_or_else(default_codex_effort),
329 codex_timeout: codex_timeout.unwrap_or_else(default_codex_timeout),
330 push_every_round,
331 full_review_round: full_review_round.unwrap_or_else(default_full_review_round),
332 plan_file,
333 plan_tracked,
334 start_branch,
335 base_branch,
336 base_commit,
337 review_started,
338 ask_codex_question,
339 session_id: None, agent_teams,
341 started_at: Some(now),
342 pr_number: None,
344 configured_bots: None,
345 active_bots: None,
346 poll_interval: None,
347 poll_timeout: None,
348 startup_case: None,
349 latest_commit_sha: None,
350 latest_commit_at: None,
351 last_trigger_at: None,
352 trigger_comment_id: None,
353 }
354 }
355
356 pub fn increment_round(&mut self) {
358 self.current_round += 1;
359 }
360
361 pub fn is_max_iterations_reached(&self) -> bool {
363 self.current_round >= self.max_iterations
364 }
365
366 pub fn is_terminal_state_file(filename: &str) -> bool {
368 let terminal_states = [
369 "complete-state.md",
370 "cancel-state.md",
371 "maxiter-state.md",
372 "stop-state.md",
373 "unexpected-state.md",
374 "approve-state.md",
375 "merged-state.md",
376 "closed-state.md",
377 ];
378 terminal_states.contains(&filename)
379 }
380
381 pub fn is_valid_terminal_reason(reason: &str) -> bool {
383 matches!(
384 reason,
385 "complete"
386 | "cancel"
387 | "maxiter"
388 | "stop"
389 | "unexpected"
390 | "approve"
391 | "merged"
392 | "closed"
393 )
394 }
395
396 pub fn terminal_state_filename(reason: &str) -> Option<&'static str> {
401 match reason {
402 "complete" => Some("complete-state.md"),
403 "cancel" => Some("cancel-state.md"),
404 "maxiter" => Some("maxiter-state.md"),
405 "stop" => Some("stop-state.md"),
406 "unexpected" => Some("unexpected-state.md"),
407 "approve" => Some("approve-state.md"),
408 "merged" => Some("merged-state.md"),
409 "closed" => Some("closed-state.md"),
410 _ => None,
411 }
412 }
413
414 pub fn rename_to_terminal<P: AsRef<Path>>(
421 state_path: P,
422 reason: &str,
423 ) -> Result<PathBuf, StateError> {
424 let terminal_name = Self::terminal_state_filename(reason)
425 .ok_or_else(|| StateError::InvalidTerminalReason(reason.to_string()))?;
426
427 let state_path = state_path.as_ref();
428 let dir = state_path
429 .parent()
430 .ok_or_else(|| StateError::IoError("Cannot determine parent directory".to_string()))?;
431
432 let terminal_path = dir.join(terminal_name);
433
434 std::fs::rename(state_path, &terminal_path)
435 .map_err(|e| StateError::IoError(e.to_string()))?;
436
437 Ok(terminal_path)
438 }
439}
440
441pub fn find_active_loop(
449 base_dir: &Path,
450 session_id: Option<&str>,
451) -> Option<PathBuf> {
452 if !base_dir.exists() {
453 return None;
454 }
455
456 let mut dirs_with_mtime: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
458
459 let entries = match std::fs::read_dir(base_dir) {
460 Ok(e) => e,
461 Err(_) => return None,
462 };
463
464 for entry in entries.flatten() {
465 let path = entry.path();
466 if path.is_dir() {
467 let mtime = std::fs::metadata(&path)
468 .and_then(|m| m.modified())
469 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
470 dirs_with_mtime.push((path, mtime));
471 }
472 }
473
474 dirs_with_mtime.sort_by(|a, b| b.1.cmp(&a.1));
476
477 if session_id.is_none() {
478 if let Some((newest_dir, _)) = dirs_with_mtime.first() {
480 if resolve_active_state_file(newest_dir).is_some() {
482 return Some(newest_dir.clone());
483 }
484 }
485 return None;
486 }
487
488 let filter_sid = session_id.unwrap();
490
491 for (loop_dir, _) in dirs_with_mtime {
492 let any_state = resolve_any_state_file(&loop_dir);
494 if any_state.is_none() {
495 continue;
496 }
497
498 let stored_session_id = any_state
500 .and_then(|path| std::fs::read_to_string(path).ok())
501 .and_then(|content| {
502 for line in content.lines() {
504 if line.starts_with("session_id:") {
505 let value = line.strip_prefix("session_id:").unwrap_or("").trim();
506 return Some(value.to_string());
507 }
508 if line == "---" && !content.starts_with("---") {
509 break; }
511 }
512 None
513 });
514
515 let matches_session = match stored_session_id {
517 None => true, Some(ref stored) if stored.is_empty() => true, Some(ref stored) => stored == filter_sid,
520 };
521
522 if matches_session {
523 if resolve_active_state_file(&loop_dir).is_some() {
525 return Some(loop_dir);
526 }
527 }
528 }
529
530 None
531}
532
533pub fn resolve_active_state_file(loop_dir: &Path) -> Option<PathBuf> {
539 let finalize_file = loop_dir.join("finalize-state.md");
541 if finalize_file.exists() {
542 return Some(finalize_file);
543 }
544
545 let state_file = loop_dir.join("state.md");
547 if state_file.exists() {
548 return Some(state_file);
549 }
550
551 None
552}
553
554pub fn resolve_any_state_file(loop_dir: &Path) -> Option<PathBuf> {
560 if let Some(active) = resolve_active_state_file(loop_dir) {
562 return Some(active);
563 }
564
565 let terminal_states = [
567 "complete-state.md",
568 "cancel-state.md",
569 "maxiter-state.md",
570 "stop-state.md",
571 "unexpected-state.md",
572 "approve-state.md",
573 "merged-state.md",
574 "closed-state.md",
575 ];
576
577 for terminal in &terminal_states {
578 let path = loop_dir.join(terminal);
579 if path.exists() {
580 return Some(path);
581 }
582 }
583
584 None
585}
586
587pub fn is_finalize_phase(loop_dir: &Path) -> bool {
591 loop_dir.join("finalize-state.md").exists()
592}
593
594pub fn has_pending_session(project_root: &Path) -> bool {
598 project_root.join(".humanize/.pending-session-id").exists()
599}
600
601fn chrono_lite_now() -> String {
603 use chrono::Utc;
604 Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
605}
606
607#[derive(Debug, thiserror::Error)]
609pub enum StateError {
610 #[error("IO error: {0}")]
611 IoError(String),
612
613 #[error("Missing YAML frontmatter")]
614 MissingFrontmatter,
615
616 #[error("Missing YAML frontmatter end delimiter")]
617 MissingFrontmatterEnd,
618
619 #[error("YAML parse error: {0}")]
620 YamlParseError(String),
621
622 #[error("YAML serialize error: {0}")]
623 YamlSerializeError(String),
624
625 #[error("Missing required field: {0}")]
626 MissingRequiredField(String),
627
628 #[error("Invalid terminal reason: {0}")]
629 InvalidTerminalReason(String),
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 #[test]
637 fn test_state_to_markdown() {
638 let state = State::default();
639 let md = state.to_markdown().unwrap();
640 assert!(md.starts_with("---\n"));
641 assert!(md.contains("current_round: 0"));
642 }
643
644 #[test]
645 fn test_state_from_markdown() {
646 let content = r#"---
647current_round: 1
648max_iterations: 42
649codex_model: gpt-5.4
650codex_effort: high
651codex_timeout: 5400
652push_every_round: false
653full_review_round: 5
654plan_file: docs/plan.md
655plan_tracked: false
656start_branch: master
657base_branch: master
658base_commit: abc123
659review_started: false
660ask_codex_question: true
661session_id:
662agent_teams: false
663---
664
665Some content below.
666"#;
667
668 let state = State::from_markdown(content).unwrap();
669 assert_eq!(state.current_round, 1);
670 assert_eq!(state.plan_file, "docs/plan.md");
671 assert!(state.session_id.is_none());
672 }
673
674 #[test]
675 fn test_state_roundtrip() {
676 let original = State::new_rlcr(
677 "docs/plan.md".to_string(),
678 false,
679 "master".to_string(),
680 "master".to_string(),
681 "abc123".to_string(),
682 None,
683 None,
684 None,
685 None,
686 false,
687 None,
688 true,
689 false,
690 false,
691 );
692
693 let md = original.to_markdown().unwrap();
694 let parsed = State::from_markdown(&md).unwrap();
695
696 assert_eq!(original.current_round, parsed.current_round);
697 assert_eq!(original.plan_file, parsed.plan_file);
698 assert_eq!(original.base_branch, parsed.base_branch);
699 }
700
701 #[test]
702 fn test_terminal_state_filename() {
703 assert_eq!(State::terminal_state_filename("complete"), Some("complete-state.md"));
704 assert_eq!(State::terminal_state_filename("cancel"), Some("cancel-state.md"));
705 assert_eq!(State::terminal_state_filename("maxiter"), Some("maxiter-state.md"));
706 assert_eq!(State::terminal_state_filename("unknown"), None); }
708
709 #[test]
710 fn test_strict_parsing_rejects_missing_required_fields() {
711 let content_missing_base = r#"---
713current_round: 0
714max_iterations: 42
715review_started: false
716---
717"#;
718 let result = State::from_markdown_strict(content_missing_base);
719 assert!(result.is_err());
720 match result {
721 Err(StateError::MissingRequiredField(field)) => {
722 assert_eq!(field, "base_branch");
723 }
724 _ => panic!("Expected MissingRequiredField error for base_branch"),
725 }
726
727 let content_missing_max = r#"---
729current_round: 0
730review_started: false
731base_branch: master
732---
733"#;
734 let result = State::from_markdown_strict(content_missing_max);
735 assert!(result.is_err());
736
737 let content_valid = r#"---
739current_round: 0
740max_iterations: 42
741review_started: false
742base_branch: master
743---
744"#;
745 let result = State::from_markdown_strict(content_valid);
746 assert!(result.is_ok());
747 }
748}