1use std::path::Path;
43
44use anyhow::Result;
45use chrono::{DateTime, Utc};
46use serde::{Deserialize, Deserializer, Serialize};
47use sha2::{Digest, Sha256};
48
49use crate::handle::generate_handle;
50use crate::util::{atomic_write, validate_unit_id};
51use crate::yaml;
52
53pub mod types;
54pub use types::*;
55
56pub fn validate_priority(priority: u8) -> Result<()> {
62 if priority > 4 {
63 return Err(anyhow::anyhow!(
64 "Invalid priority: {}. Priority must be in range 0-4 (P0-P4)",
65 priority
66 ));
67 }
68 Ok(())
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "snake_case")]
78pub enum UnitType {
79 Epic,
80 Task,
81 Fact,
82}
83
84impl<'de> Deserialize<'de> for UnitType {
85 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
86 where
87 D: Deserializer<'de>,
88 {
89 let value = String::deserialize(deserializer)?;
90 match value.as_str() {
91 "epic" => Ok(UnitType::Epic),
92 "task" | "job" => Ok(UnitType::Task),
93 "fact" => Ok(UnitType::Fact),
94 other => Err(serde::de::Error::unknown_variant(
95 other,
96 &["epic", "task", "job", "fact"],
97 )),
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize)]
116pub struct Unit {
117 pub id: String,
118 pub title: String,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub slug: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub handle: Option<String>,
125 pub status: Status,
126 #[serde(default = "default_priority")]
127 pub priority: u8,
128 pub created_at: DateTime<Utc>,
129 pub updated_at: DateTime<Utc>,
130
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub description: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub acceptance: Option<String>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub notes: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub design: Option<String>,
139
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub labels: Vec<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub assignee: Option<String>,
144
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub closed_at: Option<DateTime<Utc>>,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub close_reason: Option<String>,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub parent: Option<String>,
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub dependencies: Vec<String>,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub verify: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub verify_fast: Option<String>,
162 #[serde(default, skip_serializing_if = "is_false")]
165 pub fail_first: bool,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub checkpoint: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub verify_hash: Option<String>,
175 #[serde(default, skip_serializing_if = "is_zero")]
177 pub attempts: u32,
178 #[serde(
180 default = "default_max_attempts",
181 skip_serializing_if = "is_default_max_attempts"
182 )]
183 pub max_attempts: u32,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub claimed_by: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub claimed_at: Option<DateTime<Utc>>,
190
191 #[serde(default, skip_serializing_if = "is_false")]
193 pub is_archived: bool,
194
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub produces: Vec<String>,
199
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub requires: Vec<String>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub on_fail: Option<OnFailAction>,
208
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub on_close: Vec<OnCloseAction>,
213
214 #[serde(default, skip_serializing_if = "Vec::is_empty")]
216 pub history: Vec<RunRecord>,
217
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub outputs: Option<serde_json::Value>,
221
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub max_loops: Option<u32>,
225
226 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub verify_timeout: Option<u64>,
230
231 pub kind: UnitType,
234
235 #[serde(
237 default = "default_unit_type",
238 skip_serializing_if = "is_default_unit_type"
239 )]
240 pub unit_type: String,
241
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub last_verified: Option<DateTime<Utc>>,
245
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub stale_after: Option<DateTime<Utc>>,
249
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub paths: Vec<String>,
253
254 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub attempt_log: Vec<AttemptRecord>,
258
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub created_by: Option<String>,
262
263 #[serde(default, skip_serializing_if = "is_false")]
265 pub feature: bool,
266
267 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub decisions: Vec<String>,
272
273 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub autonomy_disposition: Option<AutonomyDisposition>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub model: Option<String>,
281}
282
283fn default_priority() -> u8 {
284 2
285}
286
287fn default_max_attempts() -> u32 {
288 3
289}
290
291fn is_zero(v: &u32) -> bool {
292 *v == 0
293}
294
295fn is_default_max_attempts(v: &u32) -> bool {
296 *v == 3
297}
298
299fn is_false(v: &bool) -> bool {
300 !*v
301}
302
303fn default_unit_type() -> String {
304 "task".to_string()
305}
306
307fn is_default_unit_type(v: &str) -> bool {
308 v == "task"
309}
310
311fn default_unit_type_kind() -> UnitType {
312 UnitType::Task
313}
314
315fn infer_unit_type(kind: Option<UnitType>, unit_type: &str, verify: Option<&str>) -> UnitType {
316 kind.unwrap_or_else(|| {
317 if unit_type == "fact" {
318 UnitType::Fact
319 } else if verify.is_some_and(|command| !command.trim().is_empty()) {
320 UnitType::Task
321 } else {
322 UnitType::Epic
323 }
324 })
325}
326
327impl UnitType {
328 pub fn is_dispatchable_task(self) -> bool {
329 matches!(self, UnitType::Task)
330 }
331
332 pub fn is_claimable(self) -> bool {
333 !matches!(self, UnitType::Fact)
334 }
335
336 pub fn is_epic_like(self, feature: bool) -> bool {
337 feature || matches!(self, UnitType::Epic)
338 }
339}
340
341impl Unit {
342 pub fn is_dispatchable_task(&self) -> bool {
343 self.kind.is_dispatchable_task()
344 && self.verify.as_ref().is_some_and(|v| !v.trim().is_empty())
345 }
346
347 pub fn is_claimable(&self) -> bool {
348 self.kind.is_claimable()
349 }
350
351 pub fn requires_human_close(&self) -> bool {
352 self.feature
353 }
354
355 pub fn is_epic_like(&self) -> bool {
356 self.kind.is_epic_like(self.feature)
357 }
358}
359
360#[derive(Debug, Deserialize)]
361struct UnitWire {
362 id: String,
363 title: String,
364 #[serde(default)]
365 slug: Option<String>,
366 #[serde(default)]
367 handle: Option<String>,
368 status: Status,
369 #[serde(default = "default_priority")]
370 priority: u8,
371 created_at: DateTime<Utc>,
372 updated_at: DateTime<Utc>,
373
374 #[serde(default)]
375 description: Option<String>,
376 #[serde(default)]
377 acceptance: Option<String>,
378 #[serde(default)]
379 notes: Option<String>,
380 #[serde(default)]
381 design: Option<String>,
382
383 #[serde(default)]
384 labels: Vec<String>,
385 #[serde(default)]
386 assignee: Option<String>,
387
388 #[serde(default)]
389 closed_at: Option<DateTime<Utc>>,
390 #[serde(default)]
391 close_reason: Option<String>,
392
393 #[serde(default)]
394 parent: Option<String>,
395 #[serde(default)]
396 dependencies: Vec<String>,
397
398 #[serde(default)]
399 verify: Option<String>,
400 #[serde(default)]
401 verify_fast: Option<String>,
402 #[serde(default)]
403 fail_first: bool,
404 #[serde(default)]
405 checkpoint: Option<String>,
406 #[serde(default)]
407 verify_hash: Option<String>,
408 #[serde(default)]
409 attempts: u32,
410 #[serde(default = "default_max_attempts")]
411 max_attempts: u32,
412 #[serde(default)]
413 claimed_by: Option<String>,
414 #[serde(default)]
415 claimed_at: Option<DateTime<Utc>>,
416
417 #[serde(default)]
418 is_archived: bool,
419
420 #[serde(default)]
421 produces: Vec<String>,
422
423 #[serde(default)]
424 requires: Vec<String>,
425
426 #[serde(default)]
427 on_fail: Option<OnFailAction>,
428
429 #[serde(default)]
430 on_close: Vec<OnCloseAction>,
431
432 #[serde(default)]
433 history: Vec<RunRecord>,
434
435 #[serde(default)]
436 outputs: Option<serde_json::Value>,
437
438 #[serde(default)]
439 max_loops: Option<u32>,
440
441 #[serde(default)]
442 verify_timeout: Option<u64>,
443
444 #[serde(default)]
445 kind: Option<UnitType>,
446
447 #[serde(default = "default_unit_type")]
448 unit_type: String,
449
450 #[serde(default)]
451 last_verified: Option<DateTime<Utc>>,
452
453 #[serde(default)]
454 stale_after: Option<DateTime<Utc>>,
455
456 #[serde(default)]
457 paths: Vec<String>,
458
459 #[serde(default)]
460 attempt_log: Vec<AttemptRecord>,
461
462 #[serde(default)]
463 created_by: Option<String>,
464
465 #[serde(default)]
466 feature: bool,
467
468 #[serde(default)]
469 decisions: Vec<String>,
470 #[serde(default)]
471 autonomy_disposition: Option<AutonomyDisposition>,
472 #[serde(default)]
473 model: Option<String>,
474}
475
476impl From<UnitWire> for Unit {
477 fn from(raw: UnitWire) -> Self {
478 let kind = infer_unit_type(raw.kind, &raw.unit_type, raw.verify.as_deref());
479
480 Self {
481 id: raw.id,
482 title: raw.title,
483 slug: raw.slug,
484 handle: raw.handle,
485 status: raw.status,
486 priority: raw.priority,
487 created_at: raw.created_at,
488 updated_at: raw.updated_at,
489 description: raw.description,
490 acceptance: raw.acceptance,
491 notes: raw.notes,
492 design: raw.design,
493 labels: raw.labels,
494 assignee: raw.assignee,
495 closed_at: raw.closed_at,
496 close_reason: raw.close_reason,
497 parent: raw.parent,
498 dependencies: raw.dependencies,
499 verify: raw.verify,
500 verify_fast: raw.verify_fast,
501 fail_first: raw.fail_first,
502 checkpoint: raw.checkpoint,
503 verify_hash: raw.verify_hash,
504 attempts: raw.attempts,
505 max_attempts: raw.max_attempts,
506 claimed_by: raw.claimed_by,
507 claimed_at: raw.claimed_at,
508 is_archived: raw.is_archived,
509 produces: raw.produces,
510 requires: raw.requires,
511 on_fail: raw.on_fail,
512 on_close: raw.on_close,
513 history: raw.history,
514 outputs: raw.outputs,
515 max_loops: raw.max_loops,
516 verify_timeout: raw.verify_timeout,
517 kind,
518 unit_type: raw.unit_type,
519 last_verified: raw.last_verified,
520 stale_after: raw.stale_after,
521 paths: raw.paths,
522 attempt_log: raw.attempt_log,
523 created_by: raw.created_by,
524 feature: raw.feature,
525 decisions: raw.decisions,
526 autonomy_disposition: raw.autonomy_disposition,
527 model: raw.model,
528 }
529 }
530}
531
532impl<'de> Deserialize<'de> for Unit {
533 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
534 where
535 D: Deserializer<'de>,
536 {
537 UnitWire::deserialize(deserializer).map(Unit::from)
538 }
539}
540
541impl Unit {
542 fn push_unique_blocker(blockers: &mut Vec<AutonomyBlockerCode>, blocker: AutonomyBlockerCode) {
543 if !blockers.contains(&blocker) {
544 blockers.push(blocker);
545 }
546 }
547
548 pub fn try_new(id: impl Into<String>, title: impl Into<String>) -> Result<Self> {
551 let id_str = id.into();
552 validate_unit_id(&id_str)?;
553
554 let now = Utc::now();
555 Ok(Self {
556 id: id_str,
557 title: title.into(),
558 slug: None,
559 handle: None,
560 status: Status::Open,
561 priority: 2,
562 created_at: now,
563 updated_at: now,
564 description: None,
565 acceptance: None,
566 notes: None,
567 design: None,
568 labels: Vec::new(),
569 assignee: None,
570 closed_at: None,
571 close_reason: None,
572 parent: None,
573 dependencies: Vec::new(),
574 verify: None,
575 verify_fast: None,
576 fail_first: false,
577 checkpoint: None,
578 verify_hash: None,
579 attempts: 0,
580 max_attempts: 3,
581 claimed_by: None,
582 claimed_at: None,
583 is_archived: false,
584 feature: false,
585 produces: Vec::new(),
586 requires: Vec::new(),
587 on_fail: None,
588 on_close: Vec::new(),
589 history: Vec::new(),
590 outputs: None,
591 max_loops: None,
592 verify_timeout: None,
593 kind: default_unit_type_kind(),
594 unit_type: "task".to_string(),
595 last_verified: None,
596 stale_after: None,
597 paths: Vec::new(),
598 attempt_log: Vec::new(),
599 created_by: None,
600 decisions: Vec::new(),
601 autonomy_disposition: None,
602 model: None,
603 })
604 .map(|mut unit| {
605 unit.ensure_handle();
606 unit
607 })
608 }
609
610 pub fn ensure_handle(&mut self) {
612 if self
613 .handle
614 .as_ref()
615 .is_none_or(|handle| handle.trim().is_empty())
616 {
617 self.handle = generate_handle(&self.title);
618 }
619 }
620
621 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
624 Self::try_new(id, title).expect("Invalid unit ID")
625 }
626
627 pub fn refresh_autonomy_disposition(&mut self) {
629 let evaluation = derive_attempt_pressure(
630 self.attempts,
631 self.max_attempts,
632 self.on_fail.as_ref(),
633 &self.labels,
634 &self.attempt_log,
635 &self.history,
636 );
637
638 let prior = self
639 .autonomy_disposition
640 .clone()
641 .unwrap_or_else(AutonomyDisposition::unknown);
642
643 let review = self.derive_review_state(&prior);
644 let approval = self.derive_approval_state(&prior);
645 let verify = self.derive_verify_posture(&prior);
646 let visibility = prior.visibility;
647 let risk = prior.risk;
648
649 let mut blockers = prior.blockers;
650 blockers.retain(|blocker| {
651 !matches!(
652 blocker,
653 AutonomyBlockerCode::HumanCloseRequired
654 | AutonomyBlockerCode::ApprovalRequired
655 | AutonomyBlockerCode::ReviewRequired
656 | AutonomyBlockerCode::ReviewPending
657 | AutonomyBlockerCode::ReviewRejected
658 | AutonomyBlockerCode::VerifyAbsent
659 | AutonomyBlockerCode::VerifyDeferred
660 | AutonomyBlockerCode::VerifyFailed
661 | AutonomyBlockerCode::VerifyFrozenViolation
662 | AutonomyBlockerCode::VerifyQualityUnknown
663 | AutonomyBlockerCode::VisibilityMissing
664 | AutonomyBlockerCode::AttemptBudgetExhausted
665 | AutonomyBlockerCode::CircuitBreakerTripped
666 )
667 });
668
669 if self.requires_human_close() {
670 Self::push_unique_blocker(&mut blockers, AutonomyBlockerCode::HumanCloseRequired);
671 }
672
673 match review {
674 ReviewState::Required => {
675 Self::push_unique_blocker(&mut blockers, AutonomyBlockerCode::ReviewRequired)
676 }
677 ReviewState::Pending => {
678 Self::push_unique_blocker(&mut blockers, AutonomyBlockerCode::ReviewPending)
679 }
680 ReviewState::Rejected => {
681 Self::push_unique_blocker(&mut blockers, AutonomyBlockerCode::ReviewRejected)
682 }
683 ReviewState::Unknown | ReviewState::NotRequired | ReviewState::Approved => {}
684 }
685
686 match approval {
687 ApprovalState::Required | ApprovalState::Pending | ApprovalState::Rejected => {
688 Self::push_unique_blocker(&mut blockers, AutonomyBlockerCode::ApprovalRequired)
689 }
690 ApprovalState::Unknown | ApprovalState::NotRequired | ApprovalState::Approved => {}
691 }
692
693 match verify {
694 VerifyPosture::Absent => {
695 Self::push_unique_blocker(&mut blockers, AutonomyBlockerCode::VerifyAbsent)
696 }
697 VerifyPosture::Deferred => {
698 Self::push_unique_blocker(&mut blockers, AutonomyBlockerCode::VerifyDeferred)
699 }
700 VerifyPosture::Failed => {
701 Self::push_unique_blocker(&mut blockers, AutonomyBlockerCode::VerifyFailed)
702 }
703 VerifyPosture::FrozenViolation => {
704 Self::push_unique_blocker(&mut blockers, AutonomyBlockerCode::VerifyFrozenViolation)
705 }
706 VerifyPosture::Weak | VerifyPosture::Unknown => {
707 if self.verify_requires_quality_blocker(verify) {
708 Self::push_unique_blocker(
709 &mut blockers,
710 AutonomyBlockerCode::VerifyQualityUnknown,
711 )
712 }
713 }
714 VerifyPosture::NotApplicable | VerifyPosture::Satisfied => {}
715 }
716
717 if visibility == VisibilityState::Missing {
718 Self::push_unique_blocker(&mut blockers, AutonomyBlockerCode::VisibilityMissing);
719 }
720
721 for blocker in evaluation.blockers {
722 Self::push_unique_blocker(&mut blockers, blocker);
723 }
724
725 let kind = if blockers.contains(&AutonomyBlockerCode::HumanCloseRequired) {
726 AutonomyDispositionKind::RequiresHuman
727 } else if blockers.is_empty() {
728 AutonomyDispositionKind::Eligible
729 } else {
730 AutonomyDispositionKind::Blocked
731 };
732
733 let provenance = if review != ReviewState::Unknown
734 || approval != ApprovalState::Unknown
735 || verify != VerifyPosture::Unknown
736 || visibility != VisibilityState::Unknown
737 || risk != RiskBand::Unknown
738 {
739 match prior.provenance {
740 AutonomyProvenance::Unknown | AutonomyProvenance::AttemptObservation => {
741 AutonomyProvenance::Mixed
742 }
743 existing => existing,
744 }
745 } else {
746 match prior.provenance {
747 AutonomyProvenance::Unknown => AutonomyProvenance::AttemptObservation,
748 existing => existing,
749 }
750 };
751
752 self.autonomy_disposition = Some(AutonomyDisposition {
753 kind,
754 blockers,
755 review,
756 approval,
757 verify,
758 visibility,
759 attempt_pressure: evaluation.pressure,
760 risk,
761 provenance,
762 continuation_budget: evaluation.continuation_budget,
763 });
764 }
765
766 fn derive_review_state(&self, prior: &AutonomyDisposition) -> ReviewState {
767 if self.labels.iter().any(|label| label == "reviewed") {
768 ReviewState::Approved
769 } else if self.labels.iter().any(|label| label == "rejected") {
770 ReviewState::Rejected
771 } else if self
772 .labels
773 .iter()
774 .any(|label| label == "needs-human-review")
775 {
776 ReviewState::Pending
777 } else if self.labels.iter().any(|label| label == "review-failed") {
778 if self.status == Status::Open {
779 ReviewState::Pending
780 } else {
781 ReviewState::Rejected
782 }
783 } else if !matches!(prior.review, ReviewState::Unknown) {
784 prior.review
785 } else {
786 ReviewState::Unknown
787 }
788 }
789
790 fn derive_approval_state(&self, prior: &AutonomyDisposition) -> ApprovalState {
791 if !matches!(prior.approval, ApprovalState::Unknown) {
792 prior.approval
793 } else {
794 ApprovalState::Unknown
795 }
796 }
797
798 fn derive_verify_posture(&self, prior: &AutonomyDisposition) -> VerifyPosture {
799 let has_verify = self
800 .verify
801 .as_ref()
802 .is_some_and(|verify| !verify.trim().is_empty());
803
804 if self.is_epic_like() && !has_verify {
805 return VerifyPosture::NotApplicable;
806 }
807
808 if !has_verify {
809 return VerifyPosture::Absent;
810 }
811
812 if self.verify_hash_mismatch() {
813 return VerifyPosture::FrozenViolation;
814 }
815
816 if self.status == Status::AwaitingVerify {
817 return VerifyPosture::Deferred;
818 }
819
820 if let Some(last_run) = self.history.last() {
821 match last_run.result {
822 RunResult::Pass => return VerifyPosture::Satisfied,
823 RunResult::Fail | RunResult::Timeout => return VerifyPosture::Failed,
824 RunResult::Cancelled => {}
825 }
826 }
827
828 if matches!(prior.verify, VerifyPosture::FrozenViolation) && self.verify_hash_mismatch() {
829 return VerifyPosture::FrozenViolation;
830 }
831
832 VerifyPosture::Weak
833 }
834
835 fn verify_hash_mismatch(&self) -> bool {
836 let (Some(stored_hash), Some(verify_cmd)) = (&self.verify_hash, &self.verify) else {
837 return false;
838 };
839 if verify_cmd.trim().is_empty() {
840 return false;
841 }
842
843 let mut hasher = Sha256::new();
844 hasher.update(verify_cmd.as_bytes());
845 let current_hash = format!("{:x}", hasher.finalize());
846 current_hash != *stored_hash
847 }
848
849 fn verify_requires_quality_blocker(&self, posture: VerifyPosture) -> bool {
850 !self.is_epic_like() && matches!(posture, VerifyPosture::Weak | VerifyPosture::Unknown)
851 }
852
853 pub fn effective_max_loops(&self, config_max: u32) -> u32 {
856 self.max_loops.unwrap_or(config_max)
857 }
858
859 pub fn effective_verify_timeout(&self, config_timeout: Option<u64>) -> Option<u64> {
861 self.verify_timeout.or(config_timeout)
862 }
863
864 fn parse_frontmatter(content: &str) -> Result<(String, Option<String>)> {
876 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
878 return Err(anyhow::anyhow!("Not markdown frontmatter format"));
880 }
881
882 let after_first_delimiter = if let Some(stripped) = content.strip_prefix("---\r\n") {
884 stripped
885 } else if let Some(stripped) = content.strip_prefix("---\n") {
886 stripped
887 } else {
888 return Err(anyhow::anyhow!("Not markdown frontmatter format"));
889 };
890
891 let second_delimiter_pos =
892 Self::find_closing_delimiter(after_first_delimiter).ok_or_else(|| {
893 anyhow::anyhow!("Markdown frontmatter is missing closing delimiter (---)")
894 })?;
895 let frontmatter = &after_first_delimiter[..second_delimiter_pos];
896
897 let body_start = second_delimiter_pos + 3;
899 let body_raw = &after_first_delimiter[body_start..];
900
901 let body = body_raw.trim();
903 let body = (!body.is_empty()).then(|| body.to_string());
904
905 Ok((frontmatter.to_string(), body))
906 }
907
908 fn find_closing_delimiter(content: &str) -> Option<usize> {
911 if content.starts_with("---\n") || content.starts_with("---\r\n") || content == "---" {
912 return Some(0);
913 }
914 let mut search_from = 0;
915 while let Some(pos) = content[search_from..].find("\n---") {
916 let abs_pos = search_from + pos;
917 let delimiter_start = abs_pos + 1;
918 let after_dashes = delimiter_start + 3;
919 if after_dashes >= content.len()
920 || content.as_bytes()[after_dashes] == b'\n'
921 || content.as_bytes()[after_dashes] == b'\r'
922 {
923 return Some(delimiter_start);
924 }
925 search_from = abs_pos + 1;
926 }
927 None
928 }
929
930 pub fn from_string(content: &str) -> Result<Self> {
932 match Self::parse_frontmatter(content) {
934 Ok((frontmatter, body)) => {
935 let mut unit: Unit = yaml::from_str(&frontmatter)?;
937
938 if let Some(markdown_body) = body {
940 if unit.description.is_none() {
941 unit.description = Some(markdown_body);
942 }
943 }
944
945 Ok(unit)
946 }
947 Err(_) => {
948 let unit: Unit = yaml::from_str(content)?;
950 Ok(unit)
951 }
952 }
953 }
954
955 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
957 let contents = std::fs::read_to_string(path.as_ref())?;
958 Self::from_string(&contents)
959 }
960
961 pub fn to_file(&self, path: impl AsRef<Path>) -> Result<()> {
965 let path = path.as_ref();
966 let is_md = path.extension().and_then(|e| e.to_str()) == Some("md");
967
968 if is_md {
969 let mut frontmatter_unit = self.clone();
971 let description = frontmatter_unit.description.take(); let yaml = serde_yml::to_string(&frontmatter_unit)?;
973 let mut content = String::from("---\n");
974 content.push_str(yaml.trim_start_matches("---\n").trim_end());
975 content.push_str("\n---\n");
976 if let Some(desc) = description {
977 content.push('\n');
978 content.push_str(&desc);
979 if !desc.ends_with('\n') {
980 content.push('\n');
981 }
982 }
983 atomic_write(path, &content)?;
984 } else {
985 let yaml = serde_yml::to_string(self)?;
986 atomic_write(path, &yaml)?;
987 }
988 Ok(())
989 }
990
991 pub fn hash(&self) -> String {
996 use sha2::{Digest, Sha256};
997 let canonical = self.clone();
998
999 let json =
1001 serde_json::to_string(&canonical).expect("Unit serialization to JSON cannot fail");
1002 let mut hasher = Sha256::new();
1003 hasher.update(json.as_bytes());
1004 format!("{:x}", hasher.finalize())
1005 }
1006
1007 pub fn from_file_with_hash(path: impl AsRef<Path>) -> Result<(Self, String)> {
1012 let unit = Self::from_file(path)?;
1013 let hash = unit.hash();
1014 Ok((unit, hash))
1015 }
1016
1017 pub fn apply_value(&mut self, field: &str, json_value: &str) -> Result<()> {
1030 match field {
1031 "title" => self.title = serde_json::from_str(json_value)?,
1032 "status" => self.status = serde_json::from_str(json_value)?,
1033 "priority" => self.priority = serde_json::from_str(json_value)?,
1034 "description" => self.description = serde_json::from_str(json_value)?,
1035 "acceptance" => self.acceptance = serde_json::from_str(json_value)?,
1036 "notes" => self.notes = serde_json::from_str(json_value)?,
1037 "design" => self.design = serde_json::from_str(json_value)?,
1038 "assignee" => self.assignee = serde_json::from_str(json_value)?,
1039 "labels" => self.labels = serde_json::from_str(json_value)?,
1040 "dependencies" => self.dependencies = serde_json::from_str(json_value)?,
1041 "parent" => self.parent = serde_json::from_str(json_value)?,
1042 "verify" => self.verify = serde_json::from_str(json_value)?,
1043 "produces" => self.produces = serde_json::from_str(json_value)?,
1044 "requires" => self.requires = serde_json::from_str(json_value)?,
1045 "claimed_by" => self.claimed_by = serde_json::from_str(json_value)?,
1046 "close_reason" => self.close_reason = serde_json::from_str(json_value)?,
1047 "on_fail" => self.on_fail = serde_json::from_str(json_value)?,
1048 "outputs" => self.outputs = serde_json::from_str(json_value)?,
1049 "max_loops" => self.max_loops = serde_json::from_str(json_value)?,
1050 "kind" => self.kind = serde_json::from_str(json_value)?,
1051 "unit_type" => self.unit_type = serde_json::from_str(json_value)?,
1052 "last_verified" => self.last_verified = serde_json::from_str(json_value)?,
1053 "stale_after" => self.stale_after = serde_json::from_str(json_value)?,
1054 "paths" => self.paths = serde_json::from_str(json_value)?,
1055 "decisions" => self.decisions = serde_json::from_str(json_value)?,
1056 "autonomy_disposition" => self.autonomy_disposition = serde_json::from_str(json_value)?,
1057 "model" => self.model = serde_json::from_str(json_value)?,
1058 _ => return Err(anyhow::anyhow!("Unknown field: {}", field)),
1059 }
1060 self.updated_at = Utc::now();
1061 Ok(())
1062 }
1063}
1064
1065#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use tempfile::NamedTempFile;
1073
1074 #[test]
1075 fn round_trip_minimal_unit() {
1076 let unit = Unit::new("1", "My first unit");
1077
1078 let yaml = serde_yml::to_string(&unit).unwrap();
1080
1081 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1083
1084 assert_eq!(unit, restored);
1085 }
1086
1087 #[test]
1088 fn epic_is_not_dispatchable() {
1089 let mut unit = Unit::new("1", "Epic");
1090 unit.kind = UnitType::Epic;
1091 unit.verify = Some("cargo test something".to_string());
1092
1093 assert!(!unit.is_dispatchable_task());
1094 assert!(unit.is_claimable());
1095 assert!(unit.is_epic_like());
1096 }
1097
1098 #[test]
1099 fn task_dispatchability_is_explicit() {
1100 let mut unit = Unit::new("2", "Task");
1101 unit.kind = UnitType::Task;
1102 unit.verify = Some("cargo test task_dispatchability_is_explicit".to_string());
1103
1104 assert!(unit.is_dispatchable_task());
1105 assert!(unit.is_claimable());
1106 assert!(!unit.is_epic_like());
1107
1108 unit.verify = Some(" ".to_string());
1109 assert!(!unit.is_dispatchable_task());
1110 }
1111
1112 #[test]
1113 fn feature_semantics_preserve_human_review() {
1114 let mut unit = Unit::new("3", "Feature epic");
1115 unit.kind = UnitType::Epic;
1116 unit.feature = true;
1117
1118 assert!(unit.is_epic_like());
1119 assert!(unit.requires_human_close());
1120 assert!(!unit.is_dispatchable_task());
1121 }
1122
1123 #[test]
1124 fn type_round_trip_yaml() {
1125 let mut unit = Unit::new("1", "Explicit type");
1126 unit.kind = UnitType::Epic;
1127 unit.verify = Some("cargo test unit::check".to_string());
1128
1129 let yaml = serde_yml::to_string(&unit).unwrap();
1130 assert!(yaml.contains("kind: epic"));
1131
1132 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1133
1134 assert_eq!(restored.kind, UnitType::Epic);
1135 assert_eq!(restored.verify, unit.verify);
1136 }
1137
1138 #[test]
1139 fn kind_infers_from_legacy_fields() {
1140 let fact_yaml = r#"
1141id: "1"
1142title: Legacy fact
1143status: open
1144priority: 2
1145created_at: "2025-01-01T00:00:00Z"
1146updated_at: "2025-01-01T00:00:00Z"
1147unit_type: fact
1148"#;
1149 let fact: Unit = serde_yml::from_str(fact_yaml).unwrap();
1150 assert_eq!(fact.kind, UnitType::Fact);
1151
1152 let epic_yaml = r#"
1153id: "2"
1154title: Legacy epic
1155status: open
1156priority: 2
1157created_at: "2025-01-01T00:00:00Z"
1158updated_at: "2025-01-01T00:00:00Z"
1159"#;
1160 let epic: Unit = serde_yml::from_str(epic_yaml).unwrap();
1161 assert_eq!(epic.kind, UnitType::Epic);
1162
1163 let job_yaml = r#"
1164id: "3"
1165title: Legacy job
1166status: open
1167priority: 2
1168created_at: "2025-01-01T00:00:00Z"
1169updated_at: "2025-01-01T00:00:00Z"
1170verify: cargo test
1171"#;
1172 let task: Unit = serde_yml::from_str(job_yaml).unwrap();
1173 assert_eq!(task.kind, UnitType::Task);
1174 }
1175
1176 #[test]
1177 fn round_trip_full_unit() {
1178 let now = Utc::now();
1179 let unit = Unit {
1180 id: "3.2.1".to_string(),
1181 title: "Implement parser".to_string(),
1182 slug: None,
1183 handle: None,
1184 status: Status::InProgress,
1185 priority: 1,
1186 created_at: now,
1187 updated_at: now,
1188 description: Some("Build a robust YAML parser".to_string()),
1189 acceptance: Some("All tests pass".to_string()),
1190 notes: Some("Watch out for edge cases".to_string()),
1191 design: Some("Use serde_yaml".to_string()),
1192 labels: vec!["backend".to_string(), "core".to_string()],
1193 assignee: Some("alice".to_string()),
1194 closed_at: Some(now),
1195 close_reason: Some("Done".to_string()),
1196 parent: Some("3.2".to_string()),
1197 dependencies: vec!["3.1".to_string()],
1198 verify: Some("cargo test unit::check".to_string()),
1199 verify_fast: Some("cargo check -p mana-core".to_string()),
1200 fail_first: false,
1201 checkpoint: None,
1202 verify_hash: None,
1203 attempts: 1,
1204 max_attempts: 5,
1205 claimed_by: Some("agent-7".to_string()),
1206 claimed_at: Some(now),
1207 is_archived: false,
1208 feature: false,
1209 produces: vec!["Parser".to_string()],
1210 requires: vec!["Lexer".to_string()],
1211 on_fail: Some(OnFailAction::Retry {
1212 max: Some(5),
1213 delay_secs: None,
1214 }),
1215 on_close: vec![
1216 OnCloseAction::Run {
1217 command: "echo done".to_string(),
1218 },
1219 OnCloseAction::Notify {
1220 message: "Task complete".to_string(),
1221 },
1222 ],
1223 verify_timeout: None,
1224 history: Vec::new(),
1225 outputs: Some(serde_json::json!({"key": "value"})),
1226 max_loops: None,
1227 kind: UnitType::Task,
1228 unit_type: "task".to_string(),
1229 last_verified: None,
1230 stale_after: None,
1231 paths: Vec::new(),
1232 attempt_log: Vec::new(),
1233 created_by: Some("alice".to_string()),
1234 decisions: vec!["JWT or sessions?".to_string()],
1235 autonomy_disposition: Some(AutonomyDisposition {
1236 kind: AutonomyDispositionKind::Blocked,
1237 blockers: vec![
1238 AutonomyBlockerCode::UnresolvedDecision,
1239 AutonomyBlockerCode::ReviewPending,
1240 ],
1241 review: ReviewState::Pending,
1242 approval: ApprovalState::Pending,
1243 verify: VerifyPosture::Deferred,
1244 visibility: VisibilityState::Satisfied,
1245 attempt_pressure: AttemptPressure::WithinBudget,
1246 risk: RiskBand::Normal,
1247 provenance: AutonomyProvenance::Mixed,
1248 continuation_budget: Some(2),
1249 }),
1250 model: Some("claude-sonnet".to_string()),
1251 };
1252
1253 let yaml = serde_yml::to_string(&unit).unwrap();
1254 assert!(yaml.contains("autonomy_disposition:"));
1255 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1256
1257 assert_eq!(unit, restored);
1258 }
1259
1260 #[test]
1261 fn optional_fields_omitted_when_none() {
1262 let unit = Unit::new("1", "Minimal");
1263 let yaml = serde_yml::to_string(&unit).unwrap();
1264
1265 assert!(!yaml.contains("description:"));
1266 assert!(!yaml.contains("acceptance:"));
1267 assert!(!yaml.contains("notes:"));
1268 assert!(!yaml.contains("design:"));
1269 assert!(!yaml.contains("assignee:"));
1270 assert!(!yaml.contains("closed_at:"));
1271 assert!(!yaml.contains("close_reason:"));
1272 assert!(!yaml.contains("parent:"));
1273 assert!(!yaml.contains("labels:"));
1274 assert!(!yaml.contains("dependencies:"));
1275 assert!(!yaml.contains("verify:"));
1276 assert!(!yaml.contains("verify_fast:"));
1277 assert!(!yaml.contains("attempts:"));
1278 assert!(!yaml.contains("max_attempts:"));
1279 assert!(!yaml.contains("claimed_by:"));
1280 assert!(!yaml.contains("claimed_at:"));
1281 assert!(!yaml.contains("is_archived:"));
1282 assert!(!yaml.contains("on_fail:"));
1283 assert!(!yaml.contains("on_close:"));
1284 assert!(!yaml.contains("history:"));
1285 assert!(!yaml.contains("outputs:"));
1286 assert!(!yaml.contains("autonomy_disposition:"));
1287 }
1288
1289 #[test]
1290 fn timestamps_serialize_as_iso8601() {
1291 let unit = Unit::new("1", "Check timestamps");
1292 let yaml = serde_yml::to_string(&unit).unwrap();
1293
1294 for line in yaml.lines() {
1296 if line.starts_with("created_at:") || line.starts_with("updated_at:") {
1297 let value = line.split_once(':').unwrap().1.trim();
1298 assert!(value.contains('T'), "timestamp should be ISO 8601: {value}");
1299 }
1300 }
1301 }
1302
1303 #[test]
1304 fn file_round_trip() {
1305 let unit = Unit::new("42", "File I/O test");
1306
1307 let tmp = NamedTempFile::new().unwrap();
1308 let path = tmp.path().to_path_buf();
1309
1310 unit.to_file(&path).unwrap();
1312
1313 let restored = Unit::from_file(&path).unwrap();
1315 assert_eq!(unit, restored);
1316
1317 let raw = std::fs::read_to_string(&path).unwrap();
1319 assert!(raw.contains("id: '42'") || raw.contains("id: \"42\""));
1320 assert!(raw.contains("title: File I/O test") || raw.contains("title: 'File I/O test'"));
1321 drop(tmp);
1322 }
1323
1324 #[test]
1325 fn defaults_are_correct() {
1326 let unit = Unit::new("1", "Defaults");
1327 assert_eq!(unit.status, Status::Open);
1328 assert_eq!(unit.priority, 2);
1329 assert_eq!(unit.kind, UnitType::Task);
1330 assert!(unit.labels.is_empty());
1331 assert!(unit.dependencies.is_empty());
1332 assert!(unit.description.is_none());
1333 }
1334
1335 #[test]
1336 fn deserialize_with_missing_optional_fields() {
1337 let yaml = r#"
1338id: "5"
1339title: Sparse unit
1340status: open
1341priority: 3
1342created_at: "2025-01-01T00:00:00Z"
1343updated_at: "2025-01-01T00:00:00Z"
1344"#;
1345 let unit: Unit = serde_yml::from_str(yaml).unwrap();
1346 assert_eq!(unit.id, "5");
1347 assert_eq!(unit.priority, 3);
1348 assert_eq!(unit.kind, UnitType::Epic);
1349 assert!(unit.description.is_none());
1350 assert!(unit.labels.is_empty());
1351 assert!(unit.autonomy_disposition.is_none());
1352 }
1353
1354 #[test]
1355 fn autonomy_disposition_round_trips_on_unit() {
1356 let mut unit = Unit::new("6", "Autonomy-ready unit");
1357 unit.autonomy_disposition = Some(AutonomyDisposition {
1358 kind: AutonomyDispositionKind::Eligible,
1359 blockers: Vec::new(),
1360 review: ReviewState::NotRequired,
1361 approval: ApprovalState::NotRequired,
1362 verify: VerifyPosture::Satisfied,
1363 visibility: VisibilityState::Satisfied,
1364 attempt_pressure: AttemptPressure::WithinBudget,
1365 risk: RiskBand::Low,
1366 provenance: AutonomyProvenance::Mixed,
1367 continuation_budget: Some(3),
1368 });
1369
1370 let yaml = serde_yml::to_string(&unit).unwrap();
1371 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1372
1373 assert_eq!(restored.autonomy_disposition, unit.autonomy_disposition);
1374 assert!(yaml.contains("autonomy_disposition:"));
1375 assert!(yaml.contains("kind: eligible"));
1376 assert!(yaml.contains("continuation_budget: 3"));
1377 }
1378
1379 #[test]
1380 fn validate_priority_accepts_valid_range() {
1381 for priority in 0..=4 {
1382 assert!(
1383 validate_priority(priority).is_ok(),
1384 "Priority {} should be valid",
1385 priority
1386 );
1387 }
1388 }
1389
1390 #[test]
1391 fn validate_priority_rejects_out_of_range() {
1392 assert!(validate_priority(5).is_err());
1393 assert!(validate_priority(10).is_err());
1394 assert!(validate_priority(255).is_err());
1395 }
1396
1397 #[test]
1402 fn test_parse_md_frontmatter() {
1403 let content = r#"---
1404id: 11.1
1405title: Test Unit
1406status: open
1407priority: 2
1408created_at: "2026-01-26T15:00:00Z"
1409updated_at: "2026-01-26T15:00:00Z"
1410---
1411
1412# Description
1413
1414Test markdown body.
1415"#;
1416 let unit = Unit::from_string(content).unwrap();
1417 assert_eq!(unit.id, "11.1");
1418 assert_eq!(unit.title, "Test Unit");
1419 assert_eq!(unit.status, Status::Open);
1420 assert!(unit.description.is_some());
1421 assert!(unit.description.as_ref().unwrap().contains("# Description"));
1422 assert!(unit
1423 .description
1424 .as_ref()
1425 .unwrap()
1426 .contains("Test markdown body"));
1427 }
1428
1429 #[test]
1430 fn test_parse_md_frontmatter_preserves_metadata_fields() {
1431 let content = r#"---
1432id: "2.5"
1433title: Complex Unit
1434status: in_progress
1435priority: 1
1436created_at: "2026-01-01T10:00:00Z"
1437updated_at: "2026-01-26T15:00:00Z"
1438parent: "2"
1439labels:
1440 - backend
1441 - urgent
1442dependencies:
1443 - "2.1"
1444 - "2.2"
1445---
1446
1447## Implementation Notes
1448
1449This is a complex unit with multiple metadata fields.
1450"#;
1451 let unit = Unit::from_string(content).unwrap();
1452 assert_eq!(unit.id, "2.5");
1453 assert_eq!(unit.title, "Complex Unit");
1454 assert_eq!(unit.status, Status::InProgress);
1455 assert_eq!(unit.priority, 1);
1456 assert_eq!(unit.parent, Some("2".to_string()));
1457 assert_eq!(
1458 unit.labels,
1459 vec!["backend".to_string(), "urgent".to_string()]
1460 );
1461 assert_eq!(
1462 unit.dependencies,
1463 vec!["2.1".to_string(), "2.2".to_string()]
1464 );
1465 assert!(unit.description.is_some());
1466 }
1467
1468 #[test]
1469 fn test_parse_md_frontmatter_empty_body() {
1470 let content = r#"---
1471id: "3"
1472title: No Body Unit
1473status: open
1474priority: 2
1475created_at: "2026-01-01T00:00:00Z"
1476updated_at: "2026-01-01T00:00:00Z"
1477---
1478"#;
1479 let unit = Unit::from_string(content).unwrap();
1480 assert_eq!(unit.id, "3");
1481 assert_eq!(unit.title, "No Body Unit");
1482 assert!(unit.description.is_none());
1483 }
1484
1485 #[test]
1486 fn test_parse_md_frontmatter_with_body_containing_dashes() {
1487 let content = r#"---
1488id: "4"
1489title: Dashes in Body
1490status: open
1491priority: 2
1492created_at: "2026-01-01T00:00:00Z"
1493updated_at: "2026-01-01T00:00:00Z"
1494---
1495
1496# Section 1
1497
1498This has --- inside the body, which should not break parsing.
1499
1500---
1501
1502More content after a horizontal rule.
1503"#;
1504 let unit = Unit::from_string(content).unwrap();
1505 assert_eq!(unit.id, "4");
1506 assert!(unit.description.is_some());
1507 let body = unit.description.as_ref().unwrap();
1508 assert!(body.contains("---"));
1509 assert!(body.contains("horizontal rule"));
1510 }
1511
1512 #[test]
1513 fn test_parse_md_frontmatter_with_whitespace_in_body() {
1514 let content = r#"---
1515id: "5"
1516title: Whitespace Test
1517status: open
1518priority: 2
1519created_at: "2026-01-01T00:00:00Z"
1520updated_at: "2026-01-01T00:00:00Z"
1521---
1522
1523
1524 Leading whitespace preserved after trimming newlines.
1525
1526"#;
1527 let unit = Unit::from_string(content).unwrap();
1528 assert_eq!(unit.id, "5");
1529 assert!(unit.description.is_some());
1530 let body = unit.description.as_ref().unwrap();
1531 assert!(body.contains("Leading whitespace"));
1533 }
1534
1535 #[test]
1536 fn test_fallback_to_yaml_parsing() {
1537 let yaml_content = r#"
1538id: "6"
1539title: Pure YAML Unit
1540status: open
1541priority: 3
1542created_at: "2026-01-01T00:00:00Z"
1543updated_at: "2026-01-01T00:00:00Z"
1544description: "This is YAML, not markdown"
1545"#;
1546 let unit = Unit::from_string(yaml_content).unwrap();
1547 assert_eq!(unit.id, "6");
1548 assert_eq!(unit.title, "Pure YAML Unit");
1549 assert_eq!(
1550 unit.description,
1551 Some("This is YAML, not markdown".to_string())
1552 );
1553 }
1554
1555 #[test]
1556 fn test_file_round_trip_with_markdown() {
1557 let content = r#"---
1558id: "7"
1559title: File Markdown Test
1560status: open
1561priority: 2
1562created_at: "2026-01-01T00:00:00Z"
1563updated_at: "2026-01-01T00:00:00Z"
1564---
1565
1566# Markdown Body
1567
1568This is a test of reading markdown from a file.
1569"#;
1570
1571 let dir = tempfile::tempdir().unwrap();
1573 let path = dir.path().join("7-test.md");
1574
1575 std::fs::write(&path, content).unwrap();
1577
1578 let unit = Unit::from_file(&path).unwrap();
1580 assert_eq!(unit.id, "7");
1581 assert_eq!(unit.title, "File Markdown Test");
1582 assert!(unit.description.is_some());
1583 assert!(unit
1584 .description
1585 .as_ref()
1586 .unwrap()
1587 .contains("# Markdown Body"));
1588
1589 unit.to_file(&path).unwrap();
1591
1592 let written = std::fs::read_to_string(&path).unwrap();
1594 assert!(
1595 written.starts_with("---\n"),
1596 "Should start with frontmatter delimiter, got: {}",
1597 &written[..50.min(written.len())]
1598 );
1599 assert!(
1600 written.contains("# Markdown Body"),
1601 "Should contain markdown body"
1602 );
1603 let parts: Vec<&str> = written.splitn(3, "---").collect();
1605 assert!(parts.len() >= 3, "Should have frontmatter delimiters");
1606 let frontmatter_section = parts[1];
1607 assert!(
1608 !frontmatter_section.contains("# Markdown Body"),
1609 "Description should be in body, not frontmatter"
1610 );
1611
1612 let unit2 = Unit::from_file(&path).unwrap();
1614 assert_eq!(unit2.id, unit.id);
1615 assert_eq!(unit2.title, unit.title);
1616 assert_eq!(unit2.description, unit.description);
1617 }
1618
1619 #[test]
1620 fn test_parse_md_frontmatter_missing_closing_delimiter() {
1621 let bad_content = r#"---
1622id: "8"
1623title: Missing Delimiter
1624status: open
1625"#;
1626 let result = Unit::from_string(bad_content);
1627 assert!(result.is_err());
1629 }
1630
1631 #[test]
1632 fn parser_panic_surfaces_as_error_for_invalid_yaml_input() {
1633 let result = Unit::from_string("title: [unterminated");
1634 assert!(result.is_err());
1635 }
1636
1637 #[test]
1638 fn test_parse_md_frontmatter_multiline_fields() {
1639 let content = r#"---
1640id: "9"
1641title: Multiline Test
1642status: open
1643priority: 2
1644created_at: "2026-01-01T00:00:00Z"
1645updated_at: "2026-01-01T00:00:00Z"
1646acceptance: |
1647 - Criterion 1
1648 - Criterion 2
1649 - Criterion 3
1650---
1651
1652# Implementation
1653
1654Start implementing...
1655"#;
1656 let unit = Unit::from_string(content).unwrap();
1657 assert_eq!(unit.id, "9");
1658 assert!(unit.acceptance.is_some());
1659 let acceptance = unit.acceptance.as_ref().unwrap();
1660 assert!(acceptance.contains("Criterion 1"));
1661 assert!(acceptance.contains("Criterion 2"));
1662 assert!(unit.description.is_some());
1663 }
1664
1665 #[test]
1666 fn test_parse_md_with_crlf_line_endings() {
1667 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.";
1668 let unit = Unit::from_string(content).unwrap();
1669 assert_eq!(unit.id, "10");
1670 assert_eq!(unit.title, "CRLF Test");
1671 assert!(unit.description.is_some());
1672 }
1673
1674 #[test]
1675 fn test_parse_md_description_does_not_override_yaml_description() {
1676 let content = r#"---
1677id: "11"
1678title: Override Test
1679status: open
1680priority: 2
1681created_at: "2026-01-01T00:00:00Z"
1682updated_at: "2026-01-01T00:00:00Z"
1683description: "From YAML metadata"
1684---
1685
1686# From Markdown Body
1687
1688This should not override.
1689"#;
1690 let unit = Unit::from_string(content).unwrap();
1691 assert_eq!(unit.description, Some("From YAML metadata".to_string()));
1693 }
1694
1695 #[test]
1700 fn test_hash_consistency() {
1701 let unit1 = Unit::new("1", "Test unit");
1702 let unit2 = unit1.clone();
1703 assert_eq!(unit1.hash(), unit2.hash());
1705 assert_eq!(unit1.hash(), unit1.hash());
1707 }
1708
1709 #[test]
1710 fn test_hash_changes_with_content() {
1711 let unit1 = Unit::new("1", "Test unit");
1712 let unit2 = Unit::new("1", "Different title");
1713 assert_ne!(unit1.hash(), unit2.hash());
1714 }
1715
1716 #[test]
1717 fn test_from_file_with_hash() {
1718 let unit = Unit::new("42", "Hash file test");
1719 let expected_hash = unit.hash();
1720
1721 let tmp = NamedTempFile::new().unwrap();
1722 unit.to_file(tmp.path()).unwrap();
1723
1724 let (loaded, hash) = Unit::from_file_with_hash(tmp.path()).unwrap();
1725 assert_eq!(loaded, unit);
1726 assert_eq!(hash, expected_hash);
1727 }
1728
1729 #[test]
1734 fn on_close_empty_vec_not_serialized() {
1735 let unit = Unit::new("1", "No actions");
1736 let yaml = serde_yml::to_string(&unit).unwrap();
1737 assert!(!yaml.contains("on_close"));
1738 }
1739
1740 #[test]
1741 fn on_close_round_trip_run_action() {
1742 let mut unit = Unit::new("1", "With run");
1743 unit.on_close = vec![OnCloseAction::Run {
1744 command: "echo hi".to_string(),
1745 }];
1746
1747 let yaml = serde_yml::to_string(&unit).unwrap();
1748 assert!(yaml.contains("on_close"));
1749 assert!(yaml.contains("action: run"));
1750 assert!(yaml.contains("echo hi"));
1751
1752 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1753 assert_eq!(restored.on_close, unit.on_close);
1754 }
1755
1756 #[test]
1757 fn on_close_round_trip_notify_action() {
1758 let mut unit = Unit::new("1", "With notify");
1759 unit.on_close = vec![OnCloseAction::Notify {
1760 message: "Done!".to_string(),
1761 }];
1762
1763 let yaml = serde_yml::to_string(&unit).unwrap();
1764 assert!(yaml.contains("action: notify"));
1765 assert!(yaml.contains("Done!"));
1766
1767 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1768 assert_eq!(restored.on_close, unit.on_close);
1769 }
1770
1771 #[test]
1772 fn on_close_round_trip_multiple_actions() {
1773 let mut unit = Unit::new("1", "Multiple actions");
1774 unit.on_close = vec![
1775 OnCloseAction::Run {
1776 command: "make deploy".to_string(),
1777 },
1778 OnCloseAction::Notify {
1779 message: "Deployed".to_string(),
1780 },
1781 OnCloseAction::Run {
1782 command: "echo cleanup".to_string(),
1783 },
1784 ];
1785
1786 let yaml = serde_yml::to_string(&unit).unwrap();
1787 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1788 assert_eq!(restored.on_close.len(), 3);
1789 assert_eq!(restored.on_close, unit.on_close);
1790 }
1791
1792 #[test]
1793 fn on_close_deserialized_from_yaml() {
1794 let yaml = r#"
1795id: "1"
1796title: From YAML
1797status: open
1798priority: 2
1799created_at: "2026-01-01T00:00:00Z"
1800updated_at: "2026-01-01T00:00:00Z"
1801on_close:
1802 - action: run
1803 command: "cargo test"
1804 - action: notify
1805 message: "Tests passed"
1806"#;
1807 let unit: Unit = serde_yml::from_str(yaml).unwrap();
1808 assert_eq!(unit.on_close.len(), 2);
1809 assert_eq!(
1810 unit.on_close[0],
1811 OnCloseAction::Run {
1812 command: "cargo test".to_string()
1813 }
1814 );
1815 assert_eq!(
1816 unit.on_close[1],
1817 OnCloseAction::Notify {
1818 message: "Tests passed".to_string()
1819 }
1820 );
1821 }
1822
1823 #[test]
1828 fn history_empty_not_serialized() {
1829 let unit = Unit::new("1", "No history");
1830 let yaml = serde_yml::to_string(&unit).unwrap();
1831 assert!(!yaml.contains("history:"));
1832 }
1833
1834 #[test]
1835 fn history_round_trip_yaml() {
1836 let now = Utc::now();
1837 let mut unit = Unit::new("1", "With history");
1838 unit.history = vec![
1839 RunRecord {
1840 attempt: 1,
1841 started_at: now,
1842 finished_at: Some(now),
1843 duration_secs: Some(5.2),
1844 agent: Some("agent-1".to_string()),
1845 result: RunResult::Fail,
1846 exit_code: Some(1),
1847 tokens: None,
1848 cost: None,
1849 output_snippet: Some("error: test failed".to_string()),
1850 autonomy_observation: None,
1851 },
1852 RunRecord {
1853 attempt: 2,
1854 started_at: now,
1855 finished_at: Some(now),
1856 duration_secs: Some(3.1),
1857 agent: Some("agent-1".to_string()),
1858 result: RunResult::Pass,
1859 exit_code: Some(0),
1860 tokens: Some(12000),
1861 cost: Some(0.05),
1862 output_snippet: None,
1863 autonomy_observation: None,
1864 },
1865 ];
1866
1867 let yaml = serde_yml::to_string(&unit).unwrap();
1868 assert!(yaml.contains("history:"));
1869
1870 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1871 assert_eq!(restored.history.len(), 2);
1872 assert_eq!(restored.history[0].result, RunResult::Fail);
1873 assert_eq!(restored.history[1].result, RunResult::Pass);
1874 assert_eq!(restored.history[0].attempt, 1);
1875 assert_eq!(restored.history[1].attempt, 2);
1876 assert_eq!(restored.history, unit.history);
1877 }
1878
1879 #[test]
1880 fn history_deserialized_from_yaml() {
1881 let yaml = r#"
1882id: "1"
1883title: From YAML
1884status: open
1885priority: 2
1886created_at: "2026-01-01T00:00:00Z"
1887updated_at: "2026-01-01T00:00:00Z"
1888history:
1889 - attempt: 1
1890 started_at: "2026-01-01T00:01:00Z"
1891 duration_secs: 10.0
1892 result: timeout
1893 exit_code: 124
1894 - attempt: 2
1895 started_at: "2026-01-01T00:05:00Z"
1896 finished_at: "2026-01-01T00:05:03Z"
1897 duration_secs: 3.0
1898 agent: agent-7
1899 result: pass
1900 exit_code: 0
1901"#;
1902 let unit: Unit = serde_yml::from_str(yaml).unwrap();
1903 assert_eq!(unit.history.len(), 2);
1904 assert_eq!(unit.history[0].result, RunResult::Timeout);
1905 assert_eq!(unit.history[0].exit_code, Some(124));
1906 assert_eq!(unit.history[1].result, RunResult::Pass);
1907 assert_eq!(unit.history[1].agent, Some("agent-7".to_string()));
1908 }
1909
1910 #[test]
1915 fn on_fail_none_not_serialized() {
1916 let unit = Unit::new("1", "No fail action");
1917 let yaml = serde_yml::to_string(&unit).unwrap();
1918 assert!(!yaml.contains("on_fail"));
1919 }
1920
1921 #[test]
1922 fn on_fail_retry_round_trip() {
1923 let mut unit = Unit::new("1", "With retry");
1924 unit.on_fail = Some(OnFailAction::Retry {
1925 max: Some(5),
1926 delay_secs: Some(10),
1927 });
1928
1929 let yaml = serde_yml::to_string(&unit).unwrap();
1930 assert!(yaml.contains("on_fail"));
1931 assert!(yaml.contains("action: retry"));
1932 assert!(yaml.contains("max: 5"));
1933 assert!(yaml.contains("delay_secs: 10"));
1934
1935 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1936 assert_eq!(restored.on_fail, unit.on_fail);
1937 }
1938
1939 #[test]
1940 fn on_fail_retry_minimal_round_trip() {
1941 let mut unit = Unit::new("1", "Retry minimal");
1942 unit.on_fail = Some(OnFailAction::Retry {
1943 max: None,
1944 delay_secs: None,
1945 });
1946
1947 let yaml = serde_yml::to_string(&unit).unwrap();
1948 assert!(yaml.contains("action: retry"));
1949 assert!(!yaml.contains("max:"));
1951 assert!(!yaml.contains("delay_secs:"));
1952
1953 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1954 assert_eq!(restored.on_fail, unit.on_fail);
1955 }
1956
1957 #[test]
1958 fn on_fail_escalate_round_trip() {
1959 let mut unit = Unit::new("1", "With escalate");
1960 unit.on_fail = Some(OnFailAction::Escalate {
1961 priority: Some(0),
1962 message: Some("Needs attention".to_string()),
1963 });
1964
1965 let yaml = serde_yml::to_string(&unit).unwrap();
1966 assert!(yaml.contains("action: escalate"));
1967 assert!(yaml.contains("priority: 0"));
1968 assert!(yaml.contains("Needs attention"));
1969
1970 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
1971 assert_eq!(restored.on_fail, unit.on_fail);
1972 }
1973
1974 #[test]
1975 fn on_fail_escalate_minimal_round_trip() {
1976 let mut unit = Unit::new("1", "Escalate minimal");
1977 unit.on_fail = Some(OnFailAction::Escalate {
1978 priority: None,
1979 message: None,
1980 });
1981
1982 let yaml = serde_yml::to_string(&unit).unwrap();
1983 assert!(yaml.contains("action: escalate"));
1984 let on_fail_section = yaml.split("on_fail:").nth(1).unwrap();
1987 let on_fail_end = on_fail_section
1988 .find("\non_close:")
1989 .or_else(|| on_fail_section.find("\nhistory:"))
1990 .unwrap_or(on_fail_section.len());
1991 let on_fail_block = &on_fail_section[..on_fail_end];
1992 assert!(
1993 !on_fail_block.contains("priority:"),
1994 "on_fail block should not contain priority"
1995 );
1996 assert!(
1997 !on_fail_block.contains("message:"),
1998 "on_fail block should not contain message"
1999 );
2000
2001 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
2002 assert_eq!(restored.on_fail, unit.on_fail);
2003 }
2004
2005 #[test]
2006 fn on_fail_deserialized_from_yaml() {
2007 let yaml = r#"
2008id: "1"
2009title: From YAML
2010status: open
2011priority: 2
2012created_at: "2026-01-01T00:00:00Z"
2013updated_at: "2026-01-01T00:00:00Z"
2014on_fail:
2015 action: retry
2016 max: 3
2017 delay_secs: 30
2018"#;
2019 let unit: Unit = serde_yml::from_str(yaml).unwrap();
2020 assert_eq!(
2021 unit.on_fail,
2022 Some(OnFailAction::Retry {
2023 max: Some(3),
2024 delay_secs: Some(30),
2025 })
2026 );
2027 }
2028
2029 #[test]
2030 fn on_fail_escalate_deserialized_from_yaml() {
2031 let yaml = r#"
2032id: "1"
2033title: Escalate YAML
2034status: open
2035priority: 2
2036created_at: "2026-01-01T00:00:00Z"
2037updated_at: "2026-01-01T00:00:00Z"
2038on_fail:
2039 action: escalate
2040 priority: 0
2041 message: "Critical failure"
2042"#;
2043 let unit: Unit = serde_yml::from_str(yaml).unwrap();
2044 assert_eq!(
2045 unit.on_fail,
2046 Some(OnFailAction::Escalate {
2047 priority: Some(0),
2048 message: Some("Critical failure".to_string()),
2049 })
2050 );
2051 }
2052
2053 #[test]
2058 fn outputs_none_not_serialized() {
2059 let unit = Unit::new("1", "No outputs");
2060 let yaml = serde_yml::to_string(&unit).unwrap();
2061 assert!(
2062 !yaml.contains("outputs:"),
2063 "outputs field should be omitted when None, got:\n{yaml}"
2064 );
2065 }
2066
2067 #[test]
2068 fn outputs_round_trip_nested_object() {
2069 let mut unit = Unit::new("1", "With outputs");
2070 unit.outputs = Some(serde_json::json!({
2071 "test_results": {
2072 "passed": 42,
2073 "failed": 0,
2074 "skipped": 3
2075 },
2076 "coverage": 87.5
2077 }));
2078
2079 let yaml = serde_yml::to_string(&unit).unwrap();
2080 assert!(yaml.contains("outputs"));
2081
2082 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
2083 assert_eq!(restored.outputs, unit.outputs);
2084 let out = restored.outputs.unwrap();
2085 assert_eq!(out["test_results"]["passed"], 42);
2086 assert_eq!(out["coverage"], 87.5);
2087 }
2088
2089 #[test]
2090 fn outputs_round_trip_array() {
2091 let mut unit = Unit::new("1", "Array outputs");
2092 unit.outputs = Some(serde_json::json!(["artifact1.tar.gz", "artifact2.zip"]));
2093
2094 let yaml = serde_yml::to_string(&unit).unwrap();
2095 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
2096 assert_eq!(restored.outputs, unit.outputs);
2097 let arr = restored.outputs.unwrap();
2098 assert_eq!(arr.as_array().unwrap().len(), 2);
2099 assert_eq!(arr[0], "artifact1.tar.gz");
2100 }
2101
2102 #[test]
2103 fn outputs_round_trip_simple_values() {
2104 let mut unit = Unit::new("1", "String output");
2106 unit.outputs = Some(serde_json::json!("just a string"));
2107 let yaml = serde_yml::to_string(&unit).unwrap();
2108 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
2109 assert_eq!(restored.outputs, unit.outputs);
2110
2111 unit.outputs = Some(serde_json::json!(42));
2113 let yaml = serde_yml::to_string(&unit).unwrap();
2114 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
2115 assert_eq!(restored.outputs, unit.outputs);
2116
2117 unit.outputs = Some(serde_json::json!(true));
2119 let yaml = serde_yml::to_string(&unit).unwrap();
2120 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
2121 assert_eq!(restored.outputs, unit.outputs);
2122 }
2123
2124 #[test]
2125 fn max_loops_defaults_to_none() {
2126 let unit = Unit::new("1", "No max_loops");
2127 assert_eq!(unit.max_loops, None);
2128 let yaml = serde_yml::to_string(&unit).unwrap();
2129 assert!(!yaml.contains("max_loops:"));
2130 }
2131
2132 #[test]
2133 fn max_loops_overrides_config_when_set() {
2134 let mut unit = Unit::new("1", "With max_loops");
2135 unit.max_loops = Some(5);
2136
2137 let yaml = serde_yml::to_string(&unit).unwrap();
2138 assert!(yaml.contains("max_loops: 5"));
2139
2140 let restored: Unit = serde_yml::from_str(&yaml).unwrap();
2141 assert_eq!(restored.max_loops, Some(5));
2142 }
2143
2144 #[test]
2145 fn max_loops_effective_returns_unit_value_when_set() {
2146 let mut unit = Unit::new("1", "Override");
2147 unit.max_loops = Some(20);
2148 assert_eq!(unit.effective_max_loops(10), 20);
2149 }
2150
2151 #[test]
2152 fn max_loops_effective_returns_config_value_when_none() {
2153 let unit = Unit::new("1", "Default");
2154 assert_eq!(unit.effective_max_loops(10), 10);
2155 assert_eq!(unit.effective_max_loops(42), 42);
2156 }
2157
2158 #[test]
2159 fn max_loops_zero_means_unlimited() {
2160 let mut unit = Unit::new("1", "Unlimited");
2161 unit.max_loops = Some(0);
2162 assert_eq!(unit.effective_max_loops(10), 0);
2163
2164 let unit2 = Unit::new("2", "Config unlimited");
2166 assert_eq!(unit2.effective_max_loops(0), 0);
2167 }
2168
2169 #[test]
2170 fn outputs_deserialized_from_yaml() {
2171 let yaml = r#"
2172id: "1"
2173title: Outputs YAML
2174status: open
2175priority: 2
2176created_at: "2026-01-01T00:00:00Z"
2177updated_at: "2026-01-01T00:00:00Z"
2178outputs:
2179 binary: /tmp/build/app
2180 size_bytes: 1048576
2181 checksums:
2182 sha256: abc123
2183"#;
2184 let unit: Unit = serde_yml::from_str(yaml).unwrap();
2185 assert!(unit.outputs.is_some());
2186 let out = unit.outputs.unwrap();
2187 assert_eq!(out["binary"], "/tmp/build/app");
2188 assert_eq!(out["size_bytes"], 1048576);
2189 assert_eq!(out["checksums"]["sha256"], "abc123");
2190 }
2191}