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