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")]
104 pub pr_number: Option<u32>,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub configured_bots: Option<Vec<String>>,
109
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub active_bots: Option<Vec<String>>,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub poll_interval: Option<u64>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub poll_timeout: Option<u64>,
121
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub startup_case: Option<String>,
125
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub latest_commit_sha: Option<String>,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub latest_commit_at: Option<String>,
133
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub last_trigger_at: Option<String>,
137
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub trigger_comment_id: Option<String>,
141}
142
143fn default_max_iterations() -> u32 {
145 42
146}
147
148fn default_codex_model() -> String {
149 "gpt-5.4".to_string()
150}
151
152fn default_codex_effort() -> String {
153 "xhigh".to_string()
154}
155
156fn default_codex_timeout() -> u64 {
157 5400
158}
159
160fn default_full_review_round() -> u32 {
161 5
162}
163
164fn default_ask_codex_question() -> bool {
165 true
166}
167
168impl Default for State {
169 fn default() -> Self {
170 Self {
171 current_round: 0,
172 max_iterations: default_max_iterations(),
173 codex_model: default_codex_model(),
174 codex_effort: default_codex_effort(),
175 codex_timeout: default_codex_timeout(),
176 push_every_round: false,
177 full_review_round: default_full_review_round(),
178 plan_file: String::new(),
179 plan_tracked: false,
180 start_branch: String::new(),
181 base_branch: String::new(),
182 base_commit: String::new(),
183 review_started: false,
184 ask_codex_question: default_ask_codex_question(),
185 session_id: None,
186 agent_teams: false,
187 started_at: None,
188 pr_number: None,
190 configured_bots: None,
191 active_bots: None,
192 poll_interval: None,
193 poll_timeout: None,
194 startup_case: None,
195 latest_commit_sha: None,
196 latest_commit_at: None,
197 last_trigger_at: None,
198 trigger_comment_id: None,
199 }
200 }
201}
202
203impl State {
204 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, StateError> {
206 let content = std::fs::read_to_string(path.as_ref())
207 .map_err(|e| StateError::IoError(e.to_string()))?;
208 Self::from_markdown(&content)
209 }
210
211 pub fn from_markdown(content: &str) -> Result<Self, StateError> {
213 let content = content.trim();
214
215 if !content.starts_with(YAML_FRONTMATTER_START) {
217 return Err(StateError::MissingFrontmatter);
218 }
219
220 let rest = &content[YAML_FRONTMATTER_START.len()..];
222 let end_pos = rest
223 .find("\n---")
224 .ok_or(StateError::MissingFrontmatterEnd)?;
225
226 let yaml_content = &rest[..end_pos];
227
228 let state: State = serde_yaml::from_str(yaml_content)
230 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
231
232 Ok(state)
236 }
237
238 pub fn from_markdown_strict(content: &str) -> Result<Self, StateError> {
244 let content = content.trim();
245
246 if !content.starts_with(YAML_FRONTMATTER_START) {
248 return Err(StateError::MissingFrontmatter);
249 }
250
251 let rest = &content[YAML_FRONTMATTER_START.len()..];
253 let end_pos = rest
254 .find("\n---")
255 .ok_or(StateError::MissingFrontmatterEnd)?;
256
257 let yaml_content = &rest[..end_pos];
258
259 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_content)
261 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
262
263 let mapping = yaml_value.as_mapping().ok_or_else(|| {
264 StateError::MissingRequiredField("YAML must be a mapping".to_string())
265 })?;
266
267 let required_fields = [
269 "current_round",
270 "max_iterations",
271 "review_started",
272 "base_branch",
273 ];
274 for field in &required_fields {
275 if !mapping.contains_key(serde_yaml::Value::String(field.to_string())) {
276 return Err(StateError::MissingRequiredField(field.to_string()));
277 }
278 }
279
280 let state: State = serde_yaml::from_str(yaml_content)
282 .map_err(|e| StateError::YamlParseError(e.to_string()))?;
283
284 Ok(state)
285 }
286
287 pub fn to_markdown(&self) -> Result<String, StateError> {
289 let yaml = serde_yaml::to_string(self)
290 .map_err(|e| StateError::YamlSerializeError(e.to_string()))?;
291
292 Ok(format!(
295 "{}\n{}\n{}\n\n",
296 YAML_FRONTMATTER_START,
297 yaml.trim_end(),
298 YAML_FRONTMATTER_END
299 ))
300 }
301
302 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), StateError> {
304 let content = self.to_markdown()?;
305 std::fs::write(path.as_ref(), content).map_err(|e| StateError::IoError(e.to_string()))?;
306 Ok(())
307 }
308
309 pub fn new_rlcr(
311 plan_file: String,
312 plan_tracked: bool,
313 start_branch: String,
314 base_branch: String,
315 base_commit: String,
316 max_iterations: Option<u32>,
317 codex_model: Option<String>,
318 codex_effort: Option<String>,
319 codex_timeout: Option<u64>,
320 push_every_round: bool,
321 full_review_round: Option<u32>,
322 ask_codex_question: bool,
323 agent_teams: bool,
324 review_started: bool,
325 ) -> Self {
326 let now = chrono_lite_now();
327 Self {
328 current_round: 0,
329 max_iterations: max_iterations.unwrap_or_else(default_max_iterations),
330 codex_model: codex_model.unwrap_or_else(default_codex_model),
331 codex_effort: codex_effort.unwrap_or_else(default_codex_effort),
332 codex_timeout: codex_timeout.unwrap_or_else(default_codex_timeout),
333 push_every_round,
334 full_review_round: full_review_round.unwrap_or_else(default_full_review_round),
335 plan_file,
336 plan_tracked,
337 start_branch,
338 base_branch,
339 base_commit,
340 review_started,
341 ask_codex_question,
342 session_id: None, agent_teams,
344 started_at: Some(now),
345 pr_number: None,
347 configured_bots: None,
348 active_bots: None,
349 poll_interval: None,
350 poll_timeout: None,
351 startup_case: None,
352 latest_commit_sha: None,
353 latest_commit_at: None,
354 last_trigger_at: None,
355 trigger_comment_id: None,
356 }
357 }
358
359 pub fn increment_round(&mut self) {
361 self.current_round += 1;
362 }
363
364 pub fn is_max_iterations_reached(&self) -> bool {
366 self.current_round >= self.max_iterations
367 }
368
369 pub fn is_terminal_state_file(filename: &str) -> bool {
371 let terminal_states = [
372 "complete-state.md",
373 "cancel-state.md",
374 "maxiter-state.md",
375 "stop-state.md",
376 "unexpected-state.md",
377 "approve-state.md",
378 "merged-state.md",
379 "closed-state.md",
380 ];
381 terminal_states.contains(&filename)
382 }
383
384 pub fn is_valid_terminal_reason(reason: &str) -> bool {
386 matches!(
387 reason,
388 "complete"
389 | "cancel"
390 | "maxiter"
391 | "stop"
392 | "unexpected"
393 | "approve"
394 | "merged"
395 | "closed"
396 )
397 }
398
399 pub fn terminal_state_filename(reason: &str) -> Option<&'static str> {
404 match reason {
405 "complete" => Some("complete-state.md"),
406 "cancel" => Some("cancel-state.md"),
407 "maxiter" => Some("maxiter-state.md"),
408 "stop" => Some("stop-state.md"),
409 "unexpected" => Some("unexpected-state.md"),
410 "approve" => Some("approve-state.md"),
411 "merged" => Some("merged-state.md"),
412 "closed" => Some("closed-state.md"),
413 _ => None,
414 }
415 }
416
417 pub fn rename_to_terminal<P: AsRef<Path>>(
424 state_path: P,
425 reason: &str,
426 ) -> Result<PathBuf, StateError> {
427 let terminal_name = Self::terminal_state_filename(reason)
428 .ok_or_else(|| StateError::InvalidTerminalReason(reason.to_string()))?;
429
430 let state_path = state_path.as_ref();
431 let dir = state_path
432 .parent()
433 .ok_or_else(|| StateError::IoError("Cannot determine parent directory".to_string()))?;
434
435 let terminal_path = dir.join(terminal_name);
436
437 std::fs::rename(state_path, &terminal_path)
438 .map_err(|e| StateError::IoError(e.to_string()))?;
439
440 Ok(terminal_path)
441 }
442}
443
444pub fn find_active_loop(base_dir: &Path, session_id: Option<&str>) -> 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!(
704 State::terminal_state_filename("complete"),
705 Some("complete-state.md")
706 );
707 assert_eq!(
708 State::terminal_state_filename("cancel"),
709 Some("cancel-state.md")
710 );
711 assert_eq!(
712 State::terminal_state_filename("maxiter"),
713 Some("maxiter-state.md")
714 );
715 assert_eq!(State::terminal_state_filename("unknown"), None); }
717
718 #[test]
719 fn test_strict_parsing_rejects_missing_required_fields() {
720 let content_missing_base = r#"---
722current_round: 0
723max_iterations: 42
724review_started: false
725---
726"#;
727 let result = State::from_markdown_strict(content_missing_base);
728 assert!(result.is_err());
729 match result {
730 Err(StateError::MissingRequiredField(field)) => {
731 assert_eq!(field, "base_branch");
732 }
733 _ => panic!("Expected MissingRequiredField error for base_branch"),
734 }
735
736 let content_missing_max = r#"---
738current_round: 0
739review_started: false
740base_branch: master
741---
742"#;
743 let result = State::from_markdown_strict(content_missing_max);
744 assert!(result.is_err());
745
746 let content_valid = r#"---
748current_round: 0
749max_iterations: 42
750review_started: false
751base_branch: master
752---
753"#;
754 let result = State::from_markdown_strict(content_valid);
755 assert!(result.is_ok());
756 }
757}