1use std::path::Path;
43
44use anyhow::Result;
45use chrono::{DateTime, Utc};
46use serde::{Deserialize, Serialize};
47
48use crate::util::{atomic_write, validate_unit_id};
49
50pub mod types;
51pub use types::*;
52
53pub fn validate_priority(priority: u8) -> Result<()> {
59 if priority > 4 {
60 return Err(anyhow::anyhow!(
61 "Invalid priority: {}. Priority must be in range 0-4 (P0-P4)",
62 priority
63 ));
64 }
65 Ok(())
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct Unit {
83 pub id: String,
84 pub title: String,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub slug: Option<String>,
87 pub status: Status,
88 #[serde(default = "default_priority")]
89 pub priority: u8,
90 pub created_at: DateTime<Utc>,
91 pub updated_at: DateTime<Utc>,
92
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub description: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub acceptance: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub notes: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub design: Option<String>,
101
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub labels: Vec<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub assignee: Option<String>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub closed_at: Option<DateTime<Utc>>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub close_reason: Option<String>,
111
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub parent: Option<String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub dependencies: Vec<String>,
116
117 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub verify: Option<String>,
121 #[serde(default, skip_serializing_if = "is_false")]
124 pub fail_first: bool,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub checkpoint: Option<String>,
129 #[serde(default, skip_serializing_if = "is_zero")]
131 pub attempts: u32,
132 #[serde(
134 default = "default_max_attempts",
135 skip_serializing_if = "is_default_max_attempts"
136 )]
137 pub max_attempts: u32,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub claimed_by: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub claimed_at: Option<DateTime<Utc>>,
144
145 #[serde(default, skip_serializing_if = "is_false")]
147 pub is_archived: bool,
148
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub produces: Vec<String>,
153
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub requires: Vec<String>,
158
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub on_fail: Option<OnFailAction>,
162
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
166 pub on_close: Vec<OnCloseAction>,
167
168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 pub history: Vec<RunRecord>,
171
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub outputs: Option<serde_json::Value>,
175
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub max_loops: Option<u32>,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub verify_timeout: Option<u64>,
184
185 #[serde(
188 default = "default_unit_type",
189 skip_serializing_if = "is_default_unit_type"
190 )]
191 pub unit_type: String,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub last_verified: Option<DateTime<Utc>>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub stale_after: Option<DateTime<Utc>>,
200
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub paths: Vec<String>,
204
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
208 pub attempt_log: Vec<AttemptRecord>,
209
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub created_by: Option<String>,
213
214 #[serde(default, skip_serializing_if = "is_false")]
216 pub feature: bool,
217
218 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub decisions: Vec<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub model: Option<String>,
227}
228
229fn default_priority() -> u8 {
230 2
231}
232
233fn default_max_attempts() -> u32 {
234 3
235}
236
237fn is_zero(v: &u32) -> bool {
238 *v == 0
239}
240
241fn is_default_max_attempts(v: &u32) -> bool {
242 *v == 3
243}
244
245fn is_false(v: &bool) -> bool {
246 !*v
247}
248
249fn default_unit_type() -> String {
250 "task".to_string()
251}
252
253fn is_default_unit_type(v: &str) -> bool {
254 v == "task"
255}
256
257impl Unit {
258 pub fn try_new(id: impl Into<String>, title: impl Into<String>) -> Result<Self> {
261 let id_str = id.into();
262 validate_unit_id(&id_str)?;
263
264 let now = Utc::now();
265 Ok(Self {
266 id: id_str,
267 title: title.into(),
268 slug: None,
269 status: Status::Open,
270 priority: 2,
271 created_at: now,
272 updated_at: now,
273 description: None,
274 acceptance: None,
275 notes: None,
276 design: None,
277 labels: Vec::new(),
278 assignee: None,
279 closed_at: None,
280 close_reason: None,
281 parent: None,
282 dependencies: Vec::new(),
283 verify: None,
284 fail_first: false,
285 checkpoint: None,
286 attempts: 0,
287 max_attempts: 3,
288 claimed_by: None,
289 claimed_at: None,
290 is_archived: false,
291 feature: false,
292 produces: Vec::new(),
293 requires: Vec::new(),
294 on_fail: None,
295 on_close: Vec::new(),
296 history: Vec::new(),
297 outputs: None,
298 max_loops: None,
299 verify_timeout: None,
300 unit_type: "task".to_string(),
301 last_verified: None,
302 stale_after: None,
303 paths: Vec::new(),
304 attempt_log: Vec::new(),
305 created_by: None,
306 decisions: Vec::new(),
307 model: None,
308 })
309 }
310
311 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
314 Self::try_new(id, title).expect("Invalid unit ID")
315 }
316
317 pub fn effective_max_loops(&self, config_max: u32) -> u32 {
320 self.max_loops.unwrap_or(config_max)
321 }
322
323 pub fn effective_verify_timeout(&self, config_timeout: Option<u64>) -> Option<u64> {
325 self.verify_timeout.or(config_timeout)
326 }
327
328 fn parse_frontmatter(content: &str) -> Result<(String, Option<String>)> {
340 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
342 return Err(anyhow::anyhow!("Not markdown frontmatter format"));
344 }
345
346 let after_first_delimiter = if let Some(stripped) = content.strip_prefix("---\r\n") {
348 stripped
349 } else if let Some(stripped) = content.strip_prefix("---\n") {
350 stripped
351 } else {
352 return Err(anyhow::anyhow!("Not markdown frontmatter format"));
353 };
354
355 let second_delimiter_pos =
356 Self::find_closing_delimiter(after_first_delimiter).ok_or_else(|| {
357 anyhow::anyhow!("Markdown frontmatter is missing closing delimiter (---)")
358 })?;
359 let frontmatter = &after_first_delimiter[..second_delimiter_pos];
360
361 let body_start = second_delimiter_pos + 3;
363 let body_raw = &after_first_delimiter[body_start..];
364
365 let body = body_raw.trim();
367 let body = (!body.is_empty()).then(|| body.to_string());
368
369 Ok((frontmatter.to_string(), body))
370 }
371
372 fn find_closing_delimiter(content: &str) -> Option<usize> {
375 if content.starts_with("---\n") || content.starts_with("---\r\n") || content == "---" {
376 return Some(0);
377 }
378 let mut search_from = 0;
379 while let Some(pos) = content[search_from..].find("\n---") {
380 let abs_pos = search_from + pos;
381 let delimiter_start = abs_pos + 1;
382 let after_dashes = delimiter_start + 3;
383 if after_dashes >= content.len()
384 || content.as_bytes()[after_dashes] == b'\n'
385 || content.as_bytes()[after_dashes] == b'\r'
386 {
387 return Some(delimiter_start);
388 }
389 search_from = abs_pos + 1;
390 }
391 None
392 }
393
394 pub fn from_string(content: &str) -> Result<Self> {
396 match Self::parse_frontmatter(content) {
398 Ok((frontmatter, body)) => {
399 let mut unit: Unit = serde_yml::from_str(&frontmatter)?;
401
402 if let Some(markdown_body) = body {
404 if unit.description.is_none() {
405 unit.description = Some(markdown_body);
406 }
407 }
408
409 Ok(unit)
410 }
411 Err(_) => {
412 let unit: Unit = serde_yml::from_str(content)?;
414 Ok(unit)
415 }
416 }
417 }
418
419 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
421 let contents = std::fs::read_to_string(path.as_ref())?;
422 Self::from_string(&contents)
423 }
424
425 pub fn to_file(&self, path: impl AsRef<Path>) -> Result<()> {
429 let path = path.as_ref();
430 let is_md = path.extension().and_then(|e| e.to_str()) == Some("md");
431
432 if is_md {
433 let mut frontmatter_unit = self.clone();
435 let description = frontmatter_unit.description.take(); let yaml = serde_yml::to_string(&frontmatter_unit)?;
437 let mut content = String::from("---\n");
438 content.push_str(yaml.trim_start_matches("---\n").trim_end());
439 content.push_str("\n---\n");
440 if let Some(desc) = description {
441 content.push('\n');
442 content.push_str(&desc);
443 if !desc.ends_with('\n') {
444 content.push('\n');
445 }
446 }
447 atomic_write(path, &content)?;
448 } else {
449 let yaml = serde_yml::to_string(self)?;
450 atomic_write(path, &yaml)?;
451 }
452 Ok(())
453 }
454
455 pub fn hash(&self) -> String {
460 use sha2::{Digest, Sha256};
461 let canonical = self.clone();
462
463 let json =
465 serde_json::to_string(&canonical).expect("Unit serialization to JSON cannot fail");
466 let mut hasher = Sha256::new();
467 hasher.update(json.as_bytes());
468 format!("{:x}", hasher.finalize())
469 }
470
471 pub fn from_file_with_hash(path: impl AsRef<Path>) -> Result<(Self, String)> {
476 let unit = Self::from_file(path)?;
477 let hash = unit.hash();
478 Ok((unit, hash))
479 }
480
481 pub fn apply_value(&mut self, field: &str, json_value: &str) -> Result<()> {
494 match field {
495 "title" => self.title = serde_json::from_str(json_value)?,
496 "status" => self.status = serde_json::from_str(json_value)?,
497 "priority" => self.priority = serde_json::from_str(json_value)?,
498 "description" => self.description = serde_json::from_str(json_value)?,
499 "acceptance" => self.acceptance = serde_json::from_str(json_value)?,
500 "notes" => self.notes = serde_json::from_str(json_value)?,
501 "design" => self.design = serde_json::from_str(json_value)?,
502 "assignee" => self.assignee = serde_json::from_str(json_value)?,
503 "labels" => self.labels = serde_json::from_str(json_value)?,
504 "dependencies" => self.dependencies = serde_json::from_str(json_value)?,
505 "parent" => self.parent = serde_json::from_str(json_value)?,
506 "verify" => self.verify = serde_json::from_str(json_value)?,
507 "produces" => self.produces = serde_json::from_str(json_value)?,
508 "requires" => self.requires = serde_json::from_str(json_value)?,
509 "claimed_by" => self.claimed_by = serde_json::from_str(json_value)?,
510 "close_reason" => self.close_reason = serde_json::from_str(json_value)?,
511 "on_fail" => self.on_fail = serde_json::from_str(json_value)?,
512 "outputs" => self.outputs = serde_json::from_str(json_value)?,
513 "max_loops" => self.max_loops = serde_json::from_str(json_value)?,
514 "unit_type" => self.unit_type = serde_json::from_str(json_value)?,
515 "last_verified" => self.last_verified = serde_json::from_str(json_value)?,
516 "stale_after" => self.stale_after = serde_json::from_str(json_value)?,
517 "paths" => self.paths = serde_json::from_str(json_value)?,
518 "decisions" => self.decisions = serde_json::from_str(json_value)?,
519 "model" => self.model = serde_json::from_str(json_value)?,
520 _ => return Err(anyhow::anyhow!("Unknown field: {}", field)),
521 }
522 self.updated_at = Utc::now();
523 Ok(())
524 }
525}
526
527#[cfg(test)]
532mod tests {
533 use super::*;
534 use tempfile::NamedTempFile;
535
536 #[test]
537 fn round_trip_minimal_unit() {
538 let unit = Unit::new("1", "My first unit");
539
540 let yaml = serde_yml::to_string(&unit).unwrap();
542
543 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
545
546 assert_eq!(unit, restored);
547 }
548
549 #[test]
550 fn round_trip_full_unit() {
551 let now = Utc::now();
552 let unit = Unit {
553 id: "3.2.1".to_string(),
554 title: "Implement parser".to_string(),
555 slug: None,
556 status: Status::InProgress,
557 priority: 1,
558 created_at: now,
559 updated_at: now,
560 description: Some("Build a robust YAML parser".to_string()),
561 acceptance: Some("All tests pass".to_string()),
562 notes: Some("Watch out for edge cases".to_string()),
563 design: Some("Use serde_yaml".to_string()),
564 labels: vec!["backend".to_string(), "core".to_string()],
565 assignee: Some("alice".to_string()),
566 closed_at: Some(now),
567 close_reason: Some("Done".to_string()),
568 parent: Some("3.2".to_string()),
569 dependencies: vec!["3.1".to_string()],
570 verify: Some("cargo test".to_string()),
571 fail_first: false,
572 checkpoint: None,
573 attempts: 1,
574 max_attempts: 5,
575 claimed_by: Some("agent-7".to_string()),
576 claimed_at: Some(now),
577 is_archived: false,
578 feature: false,
579 produces: vec!["Parser".to_string()],
580 requires: vec!["Lexer".to_string()],
581 on_fail: Some(OnFailAction::Retry {
582 max: Some(5),
583 delay_secs: None,
584 }),
585 on_close: vec![
586 OnCloseAction::Run {
587 command: "echo done".to_string(),
588 },
589 OnCloseAction::Notify {
590 message: "Task complete".to_string(),
591 },
592 ],
593 verify_timeout: None,
594 history: Vec::new(),
595 outputs: Some(serde_json::json!({"key": "value"})),
596 max_loops: None,
597 unit_type: "task".to_string(),
598 last_verified: None,
599 stale_after: None,
600 paths: Vec::new(),
601 attempt_log: Vec::new(),
602 created_by: Some("alice".to_string()),
603 decisions: vec!["JWT or sessions?".to_string()],
604 model: Some("claude-sonnet".to_string()),
605 };
606
607 let yaml = serde_yml::to_string(&unit).unwrap();
608 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
609
610 assert_eq!(unit, restored);
611 }
612
613 #[test]
614 fn optional_fields_omitted_when_none() {
615 let unit = Unit::new("1", "Minimal");
616 let yaml = serde_yml::to_string(&unit).unwrap();
617
618 assert!(!yaml.contains("description:"));
619 assert!(!yaml.contains("acceptance:"));
620 assert!(!yaml.contains("notes:"));
621 assert!(!yaml.contains("design:"));
622 assert!(!yaml.contains("assignee:"));
623 assert!(!yaml.contains("closed_at:"));
624 assert!(!yaml.contains("close_reason:"));
625 assert!(!yaml.contains("parent:"));
626 assert!(!yaml.contains("labels:"));
627 assert!(!yaml.contains("dependencies:"));
628 assert!(!yaml.contains("verify:"));
629 assert!(!yaml.contains("attempts:"));
630 assert!(!yaml.contains("max_attempts:"));
631 assert!(!yaml.contains("claimed_by:"));
632 assert!(!yaml.contains("claimed_at:"));
633 assert!(!yaml.contains("is_archived:"));
634 assert!(!yaml.contains("on_fail:"));
635 assert!(!yaml.contains("on_close:"));
636 assert!(!yaml.contains("history:"));
637 assert!(!yaml.contains("outputs:"));
638 }
639
640 #[test]
641 fn timestamps_serialize_as_iso8601() {
642 let unit = Unit::new("1", "Check timestamps");
643 let yaml = serde_yml::to_string(&unit).unwrap();
644
645 for line in yaml.lines() {
647 if line.starts_with("created_at:") || line.starts_with("updated_at:") {
648 let value = line.split_once(':').unwrap().1.trim();
649 assert!(value.contains('T'), "timestamp should be ISO 8601: {value}");
650 }
651 }
652 }
653
654 #[test]
655 fn file_round_trip() {
656 let unit = Unit::new("42", "File I/O test");
657
658 let tmp = NamedTempFile::new().unwrap();
659 let path = tmp.path().to_path_buf();
660
661 unit.to_file(&path).unwrap();
663
664 let restored = Unit::from_file(&path).unwrap();
666 assert_eq!(unit, restored);
667
668 let raw = std::fs::read_to_string(&path).unwrap();
670 assert!(raw.contains("id: '42'") || raw.contains("id: \"42\""));
671 assert!(raw.contains("title: File I/O test") || raw.contains("title: 'File I/O test'"));
672 drop(tmp);
673 }
674
675 #[test]
676 fn defaults_are_correct() {
677 let unit = Unit::new("1", "Defaults");
678 assert_eq!(unit.status, Status::Open);
679 assert_eq!(unit.priority, 2);
680 assert!(unit.labels.is_empty());
681 assert!(unit.dependencies.is_empty());
682 assert!(unit.description.is_none());
683 }
684
685 #[test]
686 fn deserialize_with_missing_optional_fields() {
687 let yaml = r#"
688id: "5"
689title: Sparse unit
690status: open
691priority: 3
692created_at: "2025-01-01T00:00:00Z"
693updated_at: "2025-01-01T00:00:00Z"
694"#;
695 let unit: Unit = serde_yml::from_str(yaml).unwrap();
696 assert_eq!(unit.id, "5");
697 assert_eq!(unit.priority, 3);
698 assert!(unit.description.is_none());
699 assert!(unit.labels.is_empty());
700 }
701
702 #[test]
703 fn validate_priority_accepts_valid_range() {
704 for priority in 0..=4 {
705 assert!(
706 validate_priority(priority).is_ok(),
707 "Priority {} should be valid",
708 priority
709 );
710 }
711 }
712
713 #[test]
714 fn validate_priority_rejects_out_of_range() {
715 assert!(validate_priority(5).is_err());
716 assert!(validate_priority(10).is_err());
717 assert!(validate_priority(255).is_err());
718 }
719
720 #[test]
725 fn test_parse_md_frontmatter() {
726 let content = r#"---
727id: 11.1
728title: Test Unit
729status: open
730priority: 2
731created_at: "2026-01-26T15:00:00Z"
732updated_at: "2026-01-26T15:00:00Z"
733---
734
735# Description
736
737Test markdown body.
738"#;
739 let unit = Unit::from_string(content).unwrap();
740 assert_eq!(unit.id, "11.1");
741 assert_eq!(unit.title, "Test Unit");
742 assert_eq!(unit.status, Status::Open);
743 assert!(unit.description.is_some());
744 assert!(unit.description.as_ref().unwrap().contains("# Description"));
745 assert!(unit
746 .description
747 .as_ref()
748 .unwrap()
749 .contains("Test markdown body"));
750 }
751
752 #[test]
753 fn test_parse_md_frontmatter_preserves_metadata_fields() {
754 let content = r#"---
755id: "2.5"
756title: Complex Unit
757status: in_progress
758priority: 1
759created_at: "2026-01-01T10:00:00Z"
760updated_at: "2026-01-26T15:00:00Z"
761parent: "2"
762labels:
763 - backend
764 - urgent
765dependencies:
766 - "2.1"
767 - "2.2"
768---
769
770## Implementation Notes
771
772This is a complex unit with multiple metadata fields.
773"#;
774 let unit = Unit::from_string(content).unwrap();
775 assert_eq!(unit.id, "2.5");
776 assert_eq!(unit.title, "Complex Unit");
777 assert_eq!(unit.status, Status::InProgress);
778 assert_eq!(unit.priority, 1);
779 assert_eq!(unit.parent, Some("2".to_string()));
780 assert_eq!(
781 unit.labels,
782 vec!["backend".to_string(), "urgent".to_string()]
783 );
784 assert_eq!(
785 unit.dependencies,
786 vec!["2.1".to_string(), "2.2".to_string()]
787 );
788 assert!(unit.description.is_some());
789 }
790
791 #[test]
792 fn test_parse_md_frontmatter_empty_body() {
793 let content = r#"---
794id: "3"
795title: No Body Unit
796status: open
797priority: 2
798created_at: "2026-01-01T00:00:00Z"
799updated_at: "2026-01-01T00:00:00Z"
800---
801"#;
802 let unit = Unit::from_string(content).unwrap();
803 assert_eq!(unit.id, "3");
804 assert_eq!(unit.title, "No Body Unit");
805 assert!(unit.description.is_none());
806 }
807
808 #[test]
809 fn test_parse_md_frontmatter_with_body_containing_dashes() {
810 let content = r#"---
811id: "4"
812title: Dashes in Body
813status: open
814priority: 2
815created_at: "2026-01-01T00:00:00Z"
816updated_at: "2026-01-01T00:00:00Z"
817---
818
819# Section 1
820
821This has --- inside the body, which should not break parsing.
822
823---
824
825More content after a horizontal rule.
826"#;
827 let unit = Unit::from_string(content).unwrap();
828 assert_eq!(unit.id, "4");
829 assert!(unit.description.is_some());
830 let body = unit.description.as_ref().unwrap();
831 assert!(body.contains("---"));
832 assert!(body.contains("horizontal rule"));
833 }
834
835 #[test]
836 fn test_parse_md_frontmatter_with_whitespace_in_body() {
837 let content = r#"---
838id: "5"
839title: Whitespace Test
840status: open
841priority: 2
842created_at: "2026-01-01T00:00:00Z"
843updated_at: "2026-01-01T00:00:00Z"
844---
845
846
847 Leading whitespace preserved after trimming newlines.
848
849"#;
850 let unit = Unit::from_string(content).unwrap();
851 assert_eq!(unit.id, "5");
852 assert!(unit.description.is_some());
853 let body = unit.description.as_ref().unwrap();
854 assert!(body.contains("Leading whitespace"));
856 }
857
858 #[test]
859 fn test_fallback_to_yaml_parsing() {
860 let yaml_content = r#"
861id: "6"
862title: Pure YAML Unit
863status: open
864priority: 3
865created_at: "2026-01-01T00:00:00Z"
866updated_at: "2026-01-01T00:00:00Z"
867description: "This is YAML, not markdown"
868"#;
869 let unit = Unit::from_string(yaml_content).unwrap();
870 assert_eq!(unit.id, "6");
871 assert_eq!(unit.title, "Pure YAML Unit");
872 assert_eq!(
873 unit.description,
874 Some("This is YAML, not markdown".to_string())
875 );
876 }
877
878 #[test]
879 fn test_file_round_trip_with_markdown() {
880 let content = r#"---
881id: "7"
882title: File Markdown Test
883status: open
884priority: 2
885created_at: "2026-01-01T00:00:00Z"
886updated_at: "2026-01-01T00:00:00Z"
887---
888
889# Markdown Body
890
891This is a test of reading markdown from a file.
892"#;
893
894 let dir = tempfile::tempdir().unwrap();
896 let path = dir.path().join("7-test.md");
897
898 std::fs::write(&path, content).unwrap();
900
901 let unit = Unit::from_file(&path).unwrap();
903 assert_eq!(unit.id, "7");
904 assert_eq!(unit.title, "File Markdown Test");
905 assert!(unit.description.is_some());
906 assert!(unit
907 .description
908 .as_ref()
909 .unwrap()
910 .contains("# Markdown Body"));
911
912 unit.to_file(&path).unwrap();
914
915 let written = std::fs::read_to_string(&path).unwrap();
917 assert!(
918 written.starts_with("---\n"),
919 "Should start with frontmatter delimiter, got: {}",
920 &written[..50.min(written.len())]
921 );
922 assert!(
923 written.contains("# Markdown Body"),
924 "Should contain markdown body"
925 );
926 let parts: Vec<&str> = written.splitn(3, "---").collect();
928 assert!(parts.len() >= 3, "Should have frontmatter delimiters");
929 let frontmatter_section = parts[1];
930 assert!(
931 !frontmatter_section.contains("# Markdown Body"),
932 "Description should be in body, not frontmatter"
933 );
934
935 let unit2 = Unit::from_file(&path).unwrap();
937 assert_eq!(unit2.id, unit.id);
938 assert_eq!(unit2.title, unit.title);
939 assert_eq!(unit2.description, unit.description);
940 }
941
942 #[test]
943 fn test_parse_md_frontmatter_missing_closing_delimiter() {
944 let bad_content = r#"---
945id: "8"
946title: Missing Delimiter
947status: open
948"#;
949 let result = Unit::from_string(bad_content);
950 assert!(result.is_err());
952 }
953
954 #[test]
955 fn test_parse_md_frontmatter_multiline_fields() {
956 let content = r#"---
957id: "9"
958title: Multiline Test
959status: open
960priority: 2
961created_at: "2026-01-01T00:00:00Z"
962updated_at: "2026-01-01T00:00:00Z"
963acceptance: |
964 - Criterion 1
965 - Criterion 2
966 - Criterion 3
967---
968
969# Implementation
970
971Start implementing...
972"#;
973 let unit = Unit::from_string(content).unwrap();
974 assert_eq!(unit.id, "9");
975 assert!(unit.acceptance.is_some());
976 let acceptance = unit.acceptance.as_ref().unwrap();
977 assert!(acceptance.contains("Criterion 1"));
978 assert!(acceptance.contains("Criterion 2"));
979 assert!(unit.description.is_some());
980 }
981
982 #[test]
983 fn test_parse_md_with_crlf_line_endings() {
984 let content = "---\r\nid: \"10\"\r\ntitle: CRLF Test\r\nstatus: open\r\npriority: 2\r\ncreated_at: \"2026-01-01T00:00:00Z\"\r\nupdated_at: \"2026-01-01T00:00:00Z\"\r\n---\r\n\r\n# Body\r\n\r\nCRLF line endings.";
985 let unit = Unit::from_string(content).unwrap();
986 assert_eq!(unit.id, "10");
987 assert_eq!(unit.title, "CRLF Test");
988 assert!(unit.description.is_some());
989 }
990
991 #[test]
992 fn test_parse_md_description_does_not_override_yaml_description() {
993 let content = r#"---
994id: "11"
995title: Override Test
996status: open
997priority: 2
998created_at: "2026-01-01T00:00:00Z"
999updated_at: "2026-01-01T00:00:00Z"
1000description: "From YAML metadata"
1001---
1002
1003# From Markdown Body
1004
1005This should not override.
1006"#;
1007 let unit = Unit::from_string(content).unwrap();
1008 assert_eq!(unit.description, Some("From YAML metadata".to_string()));
1010 }
1011
1012 #[test]
1017 fn test_hash_consistency() {
1018 let unit1 = Unit::new("1", "Test unit");
1019 let unit2 = unit1.clone();
1020 assert_eq!(unit1.hash(), unit2.hash());
1022 assert_eq!(unit1.hash(), unit1.hash());
1024 }
1025
1026 #[test]
1027 fn test_hash_changes_with_content() {
1028 let unit1 = Unit::new("1", "Test unit");
1029 let unit2 = Unit::new("1", "Different title");
1030 assert_ne!(unit1.hash(), unit2.hash());
1031 }
1032
1033 #[test]
1034 fn test_from_file_with_hash() {
1035 let unit = Unit::new("42", "Hash file test");
1036 let expected_hash = unit.hash();
1037
1038 let tmp = NamedTempFile::new().unwrap();
1039 unit.to_file(tmp.path()).unwrap();
1040
1041 let (loaded, hash) = Unit::from_file_with_hash(tmp.path()).unwrap();
1042 assert_eq!(loaded, unit);
1043 assert_eq!(hash, expected_hash);
1044 }
1045
1046 #[test]
1051 fn on_close_empty_vec_not_serialized() {
1052 let unit = Unit::new("1", "No actions");
1053 let yaml = serde_yml::to_string(&unit).unwrap();
1054 assert!(!yaml.contains("on_close"));
1055 }
1056
1057 #[test]
1058 fn on_close_round_trip_run_action() {
1059 let mut unit = Unit::new("1", "With run");
1060 unit.on_close = vec![OnCloseAction::Run {
1061 command: "echo hi".to_string(),
1062 }];
1063
1064 let yaml = serde_yml::to_string(&unit).unwrap();
1065 assert!(yaml.contains("on_close"));
1066 assert!(yaml.contains("action: run"));
1067 assert!(yaml.contains("echo hi"));
1068
1069 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1070 assert_eq!(restored.on_close, unit.on_close);
1071 }
1072
1073 #[test]
1074 fn on_close_round_trip_notify_action() {
1075 let mut unit = Unit::new("1", "With notify");
1076 unit.on_close = vec![OnCloseAction::Notify {
1077 message: "Done!".to_string(),
1078 }];
1079
1080 let yaml = serde_yml::to_string(&unit).unwrap();
1081 assert!(yaml.contains("action: notify"));
1082 assert!(yaml.contains("Done!"));
1083
1084 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1085 assert_eq!(restored.on_close, unit.on_close);
1086 }
1087
1088 #[test]
1089 fn on_close_round_trip_multiple_actions() {
1090 let mut unit = Unit::new("1", "Multiple actions");
1091 unit.on_close = vec![
1092 OnCloseAction::Run {
1093 command: "make deploy".to_string(),
1094 },
1095 OnCloseAction::Notify {
1096 message: "Deployed".to_string(),
1097 },
1098 OnCloseAction::Run {
1099 command: "echo cleanup".to_string(),
1100 },
1101 ];
1102
1103 let yaml = serde_yml::to_string(&unit).unwrap();
1104 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1105 assert_eq!(restored.on_close.len(), 3);
1106 assert_eq!(restored.on_close, unit.on_close);
1107 }
1108
1109 #[test]
1110 fn on_close_deserialized_from_yaml() {
1111 let yaml = r#"
1112id: "1"
1113title: From YAML
1114status: open
1115priority: 2
1116created_at: "2026-01-01T00:00:00Z"
1117updated_at: "2026-01-01T00:00:00Z"
1118on_close:
1119 - action: run
1120 command: "cargo test"
1121 - action: notify
1122 message: "Tests passed"
1123"#;
1124 let unit: Unit = serde_yml::from_str(yaml).unwrap();
1125 assert_eq!(unit.on_close.len(), 2);
1126 assert_eq!(
1127 unit.on_close[0],
1128 OnCloseAction::Run {
1129 command: "cargo test".to_string()
1130 }
1131 );
1132 assert_eq!(
1133 unit.on_close[1],
1134 OnCloseAction::Notify {
1135 message: "Tests passed".to_string()
1136 }
1137 );
1138 }
1139
1140 #[test]
1145 fn history_empty_not_serialized() {
1146 let unit = Unit::new("1", "No history");
1147 let yaml = serde_yml::to_string(&unit).unwrap();
1148 assert!(!yaml.contains("history:"));
1149 }
1150
1151 #[test]
1152 fn history_round_trip_yaml() {
1153 let now = Utc::now();
1154 let mut unit = Unit::new("1", "With history");
1155 unit.history = vec![
1156 RunRecord {
1157 attempt: 1,
1158 started_at: now,
1159 finished_at: Some(now),
1160 duration_secs: Some(5.2),
1161 agent: Some("agent-1".to_string()),
1162 result: RunResult::Fail,
1163 exit_code: Some(1),
1164 tokens: None,
1165 cost: None,
1166 output_snippet: Some("error: test failed".to_string()),
1167 },
1168 RunRecord {
1169 attempt: 2,
1170 started_at: now,
1171 finished_at: Some(now),
1172 duration_secs: Some(3.1),
1173 agent: Some("agent-1".to_string()),
1174 result: RunResult::Pass,
1175 exit_code: Some(0),
1176 tokens: Some(12000),
1177 cost: Some(0.05),
1178 output_snippet: None,
1179 },
1180 ];
1181
1182 let yaml = serde_yml::to_string(&unit).unwrap();
1183 assert!(yaml.contains("history:"));
1184
1185 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1186 assert_eq!(restored.history.len(), 2);
1187 assert_eq!(restored.history[0].result, RunResult::Fail);
1188 assert_eq!(restored.history[1].result, RunResult::Pass);
1189 assert_eq!(restored.history[0].attempt, 1);
1190 assert_eq!(restored.history[1].attempt, 2);
1191 assert_eq!(restored.history, unit.history);
1192 }
1193
1194 #[test]
1195 fn history_deserialized_from_yaml() {
1196 let yaml = r#"
1197id: "1"
1198title: From YAML
1199status: open
1200priority: 2
1201created_at: "2026-01-01T00:00:00Z"
1202updated_at: "2026-01-01T00:00:00Z"
1203history:
1204 - attempt: 1
1205 started_at: "2026-01-01T00:01:00Z"
1206 duration_secs: 10.0
1207 result: timeout
1208 exit_code: 124
1209 - attempt: 2
1210 started_at: "2026-01-01T00:05:00Z"
1211 finished_at: "2026-01-01T00:05:03Z"
1212 duration_secs: 3.0
1213 agent: agent-7
1214 result: pass
1215 exit_code: 0
1216"#;
1217 let unit: Unit = serde_yml::from_str(yaml).unwrap();
1218 assert_eq!(unit.history.len(), 2);
1219 assert_eq!(unit.history[0].result, RunResult::Timeout);
1220 assert_eq!(unit.history[0].exit_code, Some(124));
1221 assert_eq!(unit.history[1].result, RunResult::Pass);
1222 assert_eq!(unit.history[1].agent, Some("agent-7".to_string()));
1223 }
1224
1225 #[test]
1230 fn on_fail_none_not_serialized() {
1231 let unit = Unit::new("1", "No fail action");
1232 let yaml = serde_yml::to_string(&unit).unwrap();
1233 assert!(!yaml.contains("on_fail"));
1234 }
1235
1236 #[test]
1237 fn on_fail_retry_round_trip() {
1238 let mut unit = Unit::new("1", "With retry");
1239 unit.on_fail = Some(OnFailAction::Retry {
1240 max: Some(5),
1241 delay_secs: Some(10),
1242 });
1243
1244 let yaml = serde_yml::to_string(&unit).unwrap();
1245 assert!(yaml.contains("on_fail"));
1246 assert!(yaml.contains("action: retry"));
1247 assert!(yaml.contains("max: 5"));
1248 assert!(yaml.contains("delay_secs: 10"));
1249
1250 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1251 assert_eq!(restored.on_fail, unit.on_fail);
1252 }
1253
1254 #[test]
1255 fn on_fail_retry_minimal_round_trip() {
1256 let mut unit = Unit::new("1", "Retry minimal");
1257 unit.on_fail = Some(OnFailAction::Retry {
1258 max: None,
1259 delay_secs: None,
1260 });
1261
1262 let yaml = serde_yml::to_string(&unit).unwrap();
1263 assert!(yaml.contains("action: retry"));
1264 assert!(!yaml.contains("max:"));
1266 assert!(!yaml.contains("delay_secs:"));
1267
1268 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1269 assert_eq!(restored.on_fail, unit.on_fail);
1270 }
1271
1272 #[test]
1273 fn on_fail_escalate_round_trip() {
1274 let mut unit = Unit::new("1", "With escalate");
1275 unit.on_fail = Some(OnFailAction::Escalate {
1276 priority: Some(0),
1277 message: Some("Needs attention".to_string()),
1278 });
1279
1280 let yaml = serde_yml::to_string(&unit).unwrap();
1281 assert!(yaml.contains("action: escalate"));
1282 assert!(yaml.contains("priority: 0"));
1283 assert!(yaml.contains("Needs attention"));
1284
1285 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1286 assert_eq!(restored.on_fail, unit.on_fail);
1287 }
1288
1289 #[test]
1290 fn on_fail_escalate_minimal_round_trip() {
1291 let mut unit = Unit::new("1", "Escalate minimal");
1292 unit.on_fail = Some(OnFailAction::Escalate {
1293 priority: None,
1294 message: None,
1295 });
1296
1297 let yaml = serde_yml::to_string(&unit).unwrap();
1298 assert!(yaml.contains("action: escalate"));
1299 let on_fail_section = yaml.split("on_fail:").nth(1).unwrap();
1302 let on_fail_end = on_fail_section
1303 .find("\non_close:")
1304 .or_else(|| on_fail_section.find("\nhistory:"))
1305 .unwrap_or(on_fail_section.len());
1306 let on_fail_block = &on_fail_section[..on_fail_end];
1307 assert!(
1308 !on_fail_block.contains("priority:"),
1309 "on_fail block should not contain priority"
1310 );
1311 assert!(
1312 !on_fail_block.contains("message:"),
1313 "on_fail block should not contain message"
1314 );
1315
1316 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1317 assert_eq!(restored.on_fail, unit.on_fail);
1318 }
1319
1320 #[test]
1321 fn on_fail_deserialized_from_yaml() {
1322 let yaml = r#"
1323id: "1"
1324title: From YAML
1325status: open
1326priority: 2
1327created_at: "2026-01-01T00:00:00Z"
1328updated_at: "2026-01-01T00:00:00Z"
1329on_fail:
1330 action: retry
1331 max: 3
1332 delay_secs: 30
1333"#;
1334 let unit: Unit = serde_yml::from_str(yaml).unwrap();
1335 assert_eq!(
1336 unit.on_fail,
1337 Some(OnFailAction::Retry {
1338 max: Some(3),
1339 delay_secs: Some(30),
1340 })
1341 );
1342 }
1343
1344 #[test]
1345 fn on_fail_escalate_deserialized_from_yaml() {
1346 let yaml = r#"
1347id: "1"
1348title: Escalate YAML
1349status: open
1350priority: 2
1351created_at: "2026-01-01T00:00:00Z"
1352updated_at: "2026-01-01T00:00:00Z"
1353on_fail:
1354 action: escalate
1355 priority: 0
1356 message: "Critical failure"
1357"#;
1358 let unit: Unit = serde_yml::from_str(yaml).unwrap();
1359 assert_eq!(
1360 unit.on_fail,
1361 Some(OnFailAction::Escalate {
1362 priority: Some(0),
1363 message: Some("Critical failure".to_string()),
1364 })
1365 );
1366 }
1367
1368 #[test]
1373 fn outputs_none_not_serialized() {
1374 let unit = Unit::new("1", "No outputs");
1375 let yaml = serde_yml::to_string(&unit).unwrap();
1376 assert!(
1377 !yaml.contains("outputs:"),
1378 "outputs field should be omitted when None, got:\n{yaml}"
1379 );
1380 }
1381
1382 #[test]
1383 fn outputs_round_trip_nested_object() {
1384 let mut unit = Unit::new("1", "With outputs");
1385 unit.outputs = Some(serde_json::json!({
1386 "test_results": {
1387 "passed": 42,
1388 "failed": 0,
1389 "skipped": 3
1390 },
1391 "coverage": 87.5
1392 }));
1393
1394 let yaml = serde_yml::to_string(&unit).unwrap();
1395 assert!(yaml.contains("outputs"));
1396
1397 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1398 assert_eq!(restored.outputs, unit.outputs);
1399 let out = restored.outputs.unwrap();
1400 assert_eq!(out["test_results"]["passed"], 42);
1401 assert_eq!(out["coverage"], 87.5);
1402 }
1403
1404 #[test]
1405 fn outputs_round_trip_array() {
1406 let mut unit = Unit::new("1", "Array outputs");
1407 unit.outputs = Some(serde_json::json!(["artifact1.tar.gz", "artifact2.zip"]));
1408
1409 let yaml = serde_yml::to_string(&unit).unwrap();
1410 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1411 assert_eq!(restored.outputs, unit.outputs);
1412 let arr = restored.outputs.unwrap();
1413 assert_eq!(arr.as_array().unwrap().len(), 2);
1414 assert_eq!(arr[0], "artifact1.tar.gz");
1415 }
1416
1417 #[test]
1418 fn outputs_round_trip_simple_values() {
1419 let mut unit = Unit::new("1", "String output");
1421 unit.outputs = Some(serde_json::json!("just a string"));
1422 let yaml = serde_yml::to_string(&unit).unwrap();
1423 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1424 assert_eq!(restored.outputs, unit.outputs);
1425
1426 unit.outputs = Some(serde_json::json!(42));
1428 let yaml = serde_yml::to_string(&unit).unwrap();
1429 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1430 assert_eq!(restored.outputs, unit.outputs);
1431
1432 unit.outputs = Some(serde_json::json!(true));
1434 let yaml = serde_yml::to_string(&unit).unwrap();
1435 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1436 assert_eq!(restored.outputs, unit.outputs);
1437 }
1438
1439 #[test]
1440 fn max_loops_defaults_to_none() {
1441 let unit = Unit::new("1", "No max_loops");
1442 assert_eq!(unit.max_loops, None);
1443 let yaml = serde_yml::to_string(&unit).unwrap();
1444 assert!(!yaml.contains("max_loops:"));
1445 }
1446
1447 #[test]
1448 fn max_loops_overrides_config_when_set() {
1449 let mut unit = Unit::new("1", "With max_loops");
1450 unit.max_loops = Some(5);
1451
1452 let yaml = serde_yml::to_string(&unit).unwrap();
1453 assert!(yaml.contains("max_loops: 5"));
1454
1455 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1456 assert_eq!(restored.max_loops, Some(5));
1457 }
1458
1459 #[test]
1460 fn max_loops_effective_returns_unit_value_when_set() {
1461 let mut unit = Unit::new("1", "Override");
1462 unit.max_loops = Some(20);
1463 assert_eq!(unit.effective_max_loops(10), 20);
1464 }
1465
1466 #[test]
1467 fn max_loops_effective_returns_config_value_when_none() {
1468 let unit = Unit::new("1", "Default");
1469 assert_eq!(unit.effective_max_loops(10), 10);
1470 assert_eq!(unit.effective_max_loops(42), 42);
1471 }
1472
1473 #[test]
1474 fn max_loops_zero_means_unlimited() {
1475 let mut unit = Unit::new("1", "Unlimited");
1476 unit.max_loops = Some(0);
1477 assert_eq!(unit.effective_max_loops(10), 0);
1478
1479 let unit2 = Unit::new("2", "Config unlimited");
1481 assert_eq!(unit2.effective_max_loops(0), 0);
1482 }
1483
1484 #[test]
1485 fn outputs_deserialized_from_yaml() {
1486 let yaml = r#"
1487id: "1"
1488title: Outputs YAML
1489status: open
1490priority: 2
1491created_at: "2026-01-01T00:00:00Z"
1492updated_at: "2026-01-01T00:00:00Z"
1493outputs:
1494 binary: /tmp/build/app
1495 size_bytes: 1048576
1496 checksums:
1497 sha256: abc123
1498"#;
1499 let unit: Unit = serde_yml::from_str(yaml).unwrap();
1500 assert!(unit.outputs.is_some());
1501 let out = unit.outputs.unwrap();
1502 assert_eq!(out["binary"], "/tmp/build/app");
1503 assert_eq!(out["size_bytes"], 1048576);
1504 assert_eq!(out["checksums"]["sha256"], "abc123");
1505 }
1506}