Skip to main content

mana_core/unit/
mod.rs

1//! Core unit data model.
2//!
3//! A [`Unit`] is the fundamental work item in mana. Units are stored as
4//! Markdown files with YAML frontmatter (`.mana/{id}-{slug}.md`) and carry
5//! everything an agent needs to perform and verify a single piece of work:
6//! title, description, verify command, dependency links, attempt history,
7//! and lifecycle metadata.
8//!
9//! ## File format
10//!
11//! ```text
12//! ---
13//! id: '42'
14//! title: Fix the login bug
15//! status: open
16//! priority: 2
17//! created_at: '2026-01-01T00:00:00Z'
18//! updated_at: '2026-01-01T00:00:00Z'
19//! verify: cargo test --test login
20//! ---
21//!
22//! ## Description
23//!
24//! The login flow fails when the session cookie expires mid-request.
25//! ```
26//!
27//! ## Reading and writing
28//!
29//! ```rust,no_run
30//! use mana_core::unit::Unit;
31//! use std::path::Path;
32//!
33//! // Read from file
34//! let unit = Unit::from_file(Path::new(".mana/42-fix-login-bug.md")).unwrap();
35//!
36//! // Modify and write back
37//! let mut unit = unit;
38//! unit.notes = Some("Root cause: token expiry not checked".to_string());
39//! unit.to_file(Path::new(".mana/42-fix-login-bug.md")).unwrap();
40//! ```
41
42use 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
56// ---------------------------------------------------------------------------
57// Priority Validation
58// ---------------------------------------------------------------------------
59
60/// Validate that priority is in the valid range (0-4, P0-P4).
61pub 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// ---------------------------------------------------------------------------
72// Unit Type
73// ---------------------------------------------------------------------------
74
75/// Explicit type for a unit.
76#[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// ---------------------------------------------------------------------------
103// Unit
104// ---------------------------------------------------------------------------
105
106/// A single unit of work managed by mana.
107///
108/// Units live on disk as Markdown files with YAML frontmatter.
109/// All fields are serializable; optional fields are omitted from YAML
110/// when `None` or empty to keep files readable.
111///
112/// Most callers should construct units via [`Unit::try_new`] and mutate
113/// them through the high-level API functions in [`crate::api`] rather than
114/// building them directly.
115#[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    /// Project-scoped human navigation alias generated from the title.
122    /// This is not canonical identity; `id` remains the stable lookup key.
123    #[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    // -- verification & claim fields --
156    /// Shell command that must exit 0 to close the unit.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub verify: Option<String>,
159    /// Optional fast verify command to run before the full verify gate.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub verify_fast: Option<String>,
162    /// Whether this unit was created with --fail-first (enforced TDD).
163    /// Records that the verify command was proven to fail before creation.
164    #[serde(default, skip_serializing_if = "is_false")]
165    pub fail_first: bool,
166    /// Git commit SHA recorded when work began for the current attempt.
167    /// Used for diff/review baselines and to detect no-op closes when a unit's
168    /// verify command already passed before work started.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub checkpoint: Option<String>,
171    /// SHA-256 hash of the verify command at claim time.
172    /// Used to detect if the judge was changed after work began.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub verify_hash: Option<String>,
175    /// How many times the verify command has been run.
176    #[serde(default, skip_serializing_if = "is_zero")]
177    pub attempts: u32,
178    /// Maximum verify attempts before escalation (default 3).
179    #[serde(
180        default = "default_max_attempts",
181        skip_serializing_if = "is_default_max_attempts"
182    )]
183    pub max_attempts: u32,
184    /// Agent or user currently holding a claim on this unit.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub claimed_by: Option<String>,
187    /// When the claim was acquired.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub claimed_at: Option<DateTime<Utc>>,
190
191    /// Whether this unit has been moved to the archive.
192    #[serde(default, skip_serializing_if = "is_false")]
193    pub is_archived: bool,
194
195    /// Artifacts this unit produces (types, functions, files).
196    /// Used by decompose skill for dependency inference.
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub produces: Vec<String>,
199
200    /// Artifacts this unit requires from other units.
201    /// Maps to dependencies via sibling produces.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub requires: Vec<String>,
204
205    /// Declarative action to execute when verify fails.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub on_fail: Option<OnFailAction>,
208
209    /// Declarative actions to execute when this unit is closed.
210    /// Runs after archive and post-close hook. Failures warn but don't revert.
211    #[serde(default, skip_serializing_if = "Vec::is_empty")]
212    pub on_close: Vec<OnCloseAction>,
213
214    /// Structured history of verification runs.
215    #[serde(default, skip_serializing_if = "Vec::is_empty")]
216    pub history: Vec<RunRecord>,
217
218    /// Structured output from verify commands (arbitrary JSON).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub outputs: Option<serde_json::Value>,
221
222    /// Maximum agent loops for this unit (overrides config default, 0 = unlimited).
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub max_loops: Option<u32>,
225
226    /// Timeout in seconds for the verify command (overrides config default).
227    /// If the verify command exceeds this limit, it is killed and treated as failure.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub verify_timeout: Option<u64>,
230
231    // -- Memory system fields --
232    /// Explicit type for public mana vocabulary. Serialized as `kind` for compatibility.
233    pub kind: UnitType,
234
235    /// Unit type: 'task' (default) or 'fact' (verified knowledge).
236    #[serde(
237        default = "default_unit_type",
238        skip_serializing_if = "is_default_unit_type"
239    )]
240    pub unit_type: String,
241
242    /// Unix timestamp of last successful verify (for staleness detection).
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub last_verified: Option<DateTime<Utc>>,
245
246    /// When this fact becomes stale (created_at + TTL). Only meaningful for facts.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub stale_after: Option<DateTime<Utc>>,
249
250    /// File paths this unit is relevant to (for context relevance scoring).
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub paths: Vec<String>,
253
254    /// Structured attempt tracking: [{num, outcome, notes}].
255    /// Tracks claim→close cycles for episodic memory.
256    #[serde(default, skip_serializing_if = "Vec::is_empty")]
257    pub attempt_log: Vec<AttemptRecord>,
258
259    /// Identity of who created this unit (resolved from config/git/env).
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub created_by: Option<String>,
262
263    /// Whether this unit is a feature (product-level goal, human-only close).
264    #[serde(default, skip_serializing_if = "is_false")]
265    pub feature: bool,
266
267    /// Unresolved decisions that block autonomous execution.
268    /// Each entry is a question that must be answered before an agent starts work.
269    /// Empty list means no blocking decisions.
270    #[serde(default, skip_serializing_if = "Vec::is_empty")]
271    pub decisions: Vec<String>,
272
273    /// Current derived scheduler-facing autonomy disposition.
274    /// This stores the canonical durable answer without duplicating raw confidence.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub autonomy_disposition: Option<AutonomyDisposition>,
277    /// Override model for this unit. Takes precedence over config-level model settings.
278    /// Used as `{model}` substitution in command templates.
279    #[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    /// Create a new unit with sensible defaults.
549    /// Returns an error if the ID is invalid.
550    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    /// Fill in a generated human handle when this unit does not already have one.
611    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    /// Create a new unit with sensible defaults.
622    /// Panics if the ID is invalid. Prefer `try_new` for fallible construction.
623    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    /// Recompute the scheduler-facing autonomy disposition from current durable unit state.
628    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    /// Get effective max_loops (per-unit override or config default).
854    /// A value of 0 means unlimited.
855    pub fn effective_max_loops(&self, config_max: u32) -> u32 {
856        self.max_loops.unwrap_or(config_max)
857    }
858
859    /// Get effective verify_timeout: unit-level override, then config default, then None.
860    pub fn effective_verify_timeout(&self, config_timeout: Option<u64>) -> Option<u64> {
861        self.verify_timeout.or(config_timeout)
862    }
863
864    /// Parse YAML frontmatter and markdown body.
865    /// Expects format:
866    /// ```text
867    /// ---
868    /// id: 1
869    /// title: Example
870    /// status: open
871    /// ...
872    /// ---
873    /// # Markdown body here
874    /// ```
875    fn parse_frontmatter(content: &str) -> Result<(String, Option<String>)> {
876        // Check if content starts with ---
877        if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
878            // Not frontmatter format, try pure YAML
879            return Err(anyhow::anyhow!("Not markdown frontmatter format"));
880        }
881
882        // Find the second --- delimiter
883        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        // Skip the closing --- and any whitespace to get the body
898        let body_start = second_delimiter_pos + 3;
899        let body_raw = &after_first_delimiter[body_start..];
900
901        // Trim leading/trailing whitespace from body
902        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    /// Find the closing `---` delimiter at the start of a line.
909    /// A naive `find("---")` matches inside YAML values, corrupting the parse.
910    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    /// Parse a unit from a string (either YAML or Markdown with YAML frontmatter).
931    pub fn from_string(content: &str) -> Result<Self> {
932        // Try frontmatter format first
933        match Self::parse_frontmatter(content) {
934            Ok((frontmatter, body)) => {
935                // Parse frontmatter as YAML
936                let mut unit: Unit = yaml::from_str(&frontmatter)?;
937
938                // If there's a body and no description yet, set it
939                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                // Fallback: treat entire content as YAML
949                let unit: Unit = yaml::from_str(content)?;
950                Ok(unit)
951            }
952        }
953    }
954
955    /// Read a unit from a file (supports both YAML and Markdown with YAML frontmatter).
956    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    /// Write this unit to a file.
962    /// For `.md` files, writes markdown frontmatter format (YAML between `---` delimiters
963    /// with description as the markdown body). For other extensions, writes pure YAML.
964    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            // Always write frontmatter format for .md files: ---\nYAML\n---\nbody
970            let mut frontmatter_unit = self.clone();
971            let description = frontmatter_unit.description.take(); // Remove from YAML
972            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    /// Calculate SHA256 hash of canonical form.
992    ///
993    /// Used for optimistic locking. The hash is calculated from a canonical
994    /// JSON representation with transient fields cleared.
995    pub fn hash(&self) -> String {
996        use sha2::{Digest, Sha256};
997        let canonical = self.clone();
998
999        // Serialize to JSON (deterministic)
1000        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    /// Load unit with version hash for optimistic locking.
1008    ///
1009    /// Returns the unit and its content hash as a tuple. The hash can be
1010    /// compared before saving to detect concurrent modifications.
1011    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    /// Apply a JSON-serialized value to a field by name.
1018    ///
1019    /// Used by conflict resolution to set a field to a chosen value.
1020    /// The value should be JSON-serialized (e.g., `"\"hello\""` for a string).
1021    ///
1022    /// # Arguments
1023    /// * `field` - The field name to update
1024    /// * `json_value` - JSON-serialized value to apply
1025    ///
1026    /// # Returns
1027    /// * `Ok(())` on success
1028    /// * `Err` if field is unknown or value cannot be deserialized
1029    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// ---------------------------------------------------------------------------
1066// Tests
1067// ---------------------------------------------------------------------------
1068
1069#[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        // Serialize
1079        let yaml = serde_yml::to_string(&unit).unwrap();
1080
1081        // Deserialize
1082        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        // ISO 8601 timestamps contain 'T' between date and time
1295        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        // Write
1311        unit.to_file(&path).unwrap();
1312
1313        // Read back
1314        let restored = Unit::from_file(&path).unwrap();
1315        assert_eq!(unit, restored);
1316
1317        // Verify the file is valid YAML we can also read raw
1318        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    // =====================================================================
1398    // Tests for Markdown Frontmatter Parsing
1399    // =====================================================================
1400
1401    #[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        // Leading newlines trimmed, but content preserved
1532        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        // Use a .md extension to trigger frontmatter write
1572        let dir = tempfile::tempdir().unwrap();
1573        let path = dir.path().join("7-test.md");
1574
1575        // Write markdown content
1576        std::fs::write(&path, content).unwrap();
1577
1578        // Read back as unit
1579        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        // Write it back — should preserve frontmatter format for .md files
1590        unit.to_file(&path).unwrap();
1591
1592        // Verify the file still has frontmatter format
1593        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        // Description should NOT be in the YAML frontmatter section
1604        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        // Read back one more time to verify full round-trip
1613        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        // Should fail because no closing ---
1628        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        // Description from YAML should take precedence
1692        assert_eq!(unit.description, Some("From YAML metadata".to_string()));
1693    }
1694
1695    // =====================================================================
1696    // Tests for Unit hash methods
1697    // =====================================================================
1698
1699    #[test]
1700    fn test_hash_consistency() {
1701        let unit1 = Unit::new("1", "Test unit");
1702        let unit2 = unit1.clone();
1703        // Same content produces same hash
1704        assert_eq!(unit1.hash(), unit2.hash());
1705        // Hash is deterministic
1706        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    // =====================================================================
1730    // on_close serialization tests
1731    // =====================================================================
1732
1733    #[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    // =====================================================================
1824    // RunResult / RunRecord / history tests
1825    // =====================================================================
1826
1827    #[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    // =====================================================================
1911    // on_fail serialization tests
1912    // =====================================================================
1913
1914    #[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        // Optional fields should be omitted
1950        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        // The on_fail block should not contain priority or message
1985        // (the unit itself has a top-level priority field, so check within on_fail)
1986        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    // =====================================================================
2054    // outputs field tests
2055    // =====================================================================
2056
2057    #[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        // String value
2105        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        // Number value
2112        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        // Boolean value
2118        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        // Config-level zero also works
2165        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}