Skip to main content

chant/
spec_group.rs

1//! Spec group/driver orchestration logic.
2//!
3//! This module manages spec membership and group completion tracking for driver specs.
4//! Driver specs can have member specs identified by numeric suffixes (e.g., `.1`, `.2`).
5//! This module handles relationships between drivers and their members.
6
7use crate::spec::{Spec, SpecStatus};
8use anyhow::Result;
9use std::path::Path;
10
11/// Check if `member_id` is a group member of `driver_id`.
12///
13/// Member IDs have format: `DRIVER_ID.N` or `DRIVER_ID.N.M` where N and M are numbers.
14/// For example: `2026-01-25-00y-abc.1` is a member of `2026-01-25-00y-abc`.
15///
16/// # Examples
17///
18/// ```ignore
19/// assert!(is_member_of("2026-01-25-00y-abc.1", "2026-01-25-00y-abc"));
20/// assert!(is_member_of("2026-01-25-00y-abc.2.1", "2026-01-25-00y-abc"));
21/// assert!(!is_member_of("2026-01-25-00y-abc", "2026-01-25-00y-abc")); // Not a member
22/// assert!(!is_member_of("2026-01-25-00x-xyz", "2026-01-25-00y-abc")); // Different driver
23/// ```
24pub fn is_member_of(member_id: &str, driver_id: &str) -> bool {
25    // Member IDs have format: DRIVER_ID.N or DRIVER_ID.N.M
26    if !member_id.starts_with(driver_id) {
27        return false;
28    }
29
30    let suffix = &member_id[driver_id.len()..];
31    suffix.starts_with('.') && suffix.len() > 1
32}
33
34/// Get all member specs of a driver spec.
35///
36/// Returns a vector of references to all spec members of the given driver.
37/// If the driver has no members, returns an empty vector.
38///
39/// # Arguments
40///
41/// * `driver_id` - The ID of the driver spec
42/// * `specs` - All available specs to search
43///
44/// # Examples
45///
46/// ```ignore
47/// let members = get_members("2026-01-25-00y-abc", &specs);
48/// ```
49pub fn get_members<'a>(driver_id: &str, specs: &'a [Spec]) -> Vec<&'a Spec> {
50    specs
51        .iter()
52        .filter(|s| is_member_of(&s.id, driver_id))
53        .collect()
54}
55
56/// Check if all members of a driver spec are completed.
57///
58/// Returns true if:
59/// - The driver has no members, or
60/// - All members have status `Completed` or `Cancelled`
61///
62/// # Arguments
63///
64/// * `driver_id` - The ID of the driver spec
65/// * `specs` - All available specs
66///
67/// # Examples
68///
69/// ```ignore
70/// if all_members_completed("2026-01-25-00y-abc", &specs) {
71///     println!("All members are done!");
72/// }
73/// ```
74pub fn all_members_completed(driver_id: &str, specs: &[Spec]) -> bool {
75    let members = get_members(driver_id, specs);
76    if members.is_empty() {
77        return true; // No members, so all are "completed"
78    }
79    members.iter().all(|m| {
80        m.frontmatter.status == SpecStatus::Completed
81            || m.frontmatter.status == SpecStatus::Cancelled
82    })
83}
84
85/// Get list of incomplete member spec IDs for a driver spec.
86///
87/// Returns a vector of IDs for all members that are not in `Completed` status.
88/// Returns an empty vector if the spec is not a driver or has no incomplete members.
89///
90/// # Arguments
91///
92/// * `driver_id` - The ID of the driver spec
93/// * `all_specs` - All available specs
94///
95/// # Examples
96///
97/// ```ignore
98/// let incomplete = get_incomplete_members("2026-01-25-00y-abc", &specs);
99/// for member_id in incomplete {
100///     println!("Incomplete member: {}", member_id);
101/// }
102/// ```
103pub fn get_incomplete_members(driver_id: &str, all_specs: &[Spec]) -> Vec<String> {
104    get_members(driver_id, all_specs)
105        .into_iter()
106        .filter(|m| m.frontmatter.status != SpecStatus::Completed)
107        .map(|m| m.id.clone())
108        .collect()
109}
110
111/// Extract the driver ID from a member ID.
112///
113/// For member specs with numeric suffixes, returns the base driver ID.
114/// For non-member specs, returns None.
115///
116/// # Examples
117///
118/// ```ignore
119/// assert_eq!(extract_driver_id("2026-01-25-00y-abc.1"), Some("2026-01-25-00y-abc".to_string()));
120/// assert_eq!(extract_driver_id("2026-01-25-00y-abc.3.2"), Some("2026-01-25-00y-abc".to_string()));
121/// assert_eq!(extract_driver_id("2026-01-25-00y-abc"), None);
122/// assert_eq!(extract_driver_id("2026-01-25-00y-abc.abc"), None);
123/// ```
124pub fn extract_driver_id(member_id: &str) -> Option<String> {
125    // Member IDs have format: DRIVER_ID.N or DRIVER_ID.N.M
126    if let Some(pos) = member_id.find('.') {
127        let (prefix, suffix) = member_id.split_at(pos);
128        // Check that what follows the dot is numeric (at least up to the first non-digit)
129        if suffix.len() > 1
130            && suffix[1..]
131                .chars()
132                .next()
133                .is_some_and(|c| c.is_ascii_digit())
134        {
135            return Some(prefix.to_string());
136        }
137    }
138    None
139}
140
141/// Extract the member number from a member ID.
142///
143/// For member specs with format `DRIVER_ID.N` or `DRIVER_ID.N.M`, extracts `N`.
144/// For non-member specs, returns None.
145///
146/// # Examples
147///
148/// ```ignore
149/// assert_eq!(extract_member_number("2026-01-25-00y-abc.1"), Some(1));
150/// assert_eq!(extract_member_number("2026-01-25-00y-abc.3"), Some(3));
151/// assert_eq!(extract_member_number("2026-01-25-00y-abc.10"), Some(10));
152/// assert_eq!(extract_member_number("2026-01-25-00y-abc.3.2"), Some(3));
153/// assert_eq!(extract_member_number("2026-01-25-00y-abc"), None);
154/// assert_eq!(extract_member_number("2026-01-25-00y-abc.abc"), None);
155/// ```
156pub fn extract_member_number(member_id: &str) -> Option<u32> {
157    if let Some(pos) = member_id.find('.') {
158        let suffix = &member_id[pos + 1..];
159        // Extract just the first numeric part after the dot
160        let num_str: String = suffix.chars().take_while(|c| c.is_ascii_digit()).collect();
161        if !num_str.is_empty() {
162            return num_str.parse::<u32>().ok();
163        }
164    }
165    None
166}
167
168/// Compare two spec IDs with numeric sorting for member specs and base36 sequences.
169///
170/// This function provides a natural sort order where member spec numbers and base36
171/// sequence portions are compared numerically rather than lexicographically. This ensures:
172/// - Specs like `2026-01-25-00y-abc.10` sort after `2026-01-25-00y-abc.2`
173/// - Specs like `2026-01-25-010-xxx` sort after `2026-01-25-00z-yyy`
174///
175/// # Sorting behavior
176///
177/// - For non-member specs, parses date/sequence/suffix and compares sequence numerically
178/// - For member specs (with `.N` suffix), compares the base ID using date/sequence/suffix
179///   parsing first, then compares member numbers numerically
180/// - Mixed member/non-member specs: non-members sort before members with the same base
181///
182/// # Examples
183///
184/// ```ignore
185/// use std::cmp::Ordering;
186/// assert_eq!(compare_spec_ids("2026-01-25-00y-abc.2", "2026-01-25-00y-abc.10"), Ordering::Less);
187/// assert_eq!(compare_spec_ids("2026-01-25-00y-abc.10", "2026-01-25-00y-abc.2"), Ordering::Greater);
188/// assert_eq!(compare_spec_ids("2026-01-25-00y-abc", "2026-01-25-00y-def"), Ordering::Less);
189/// assert_eq!(compare_spec_ids("2026-01-25-00y-abc", "2026-01-25-00y-abc.1"), Ordering::Less);
190/// assert_eq!(compare_spec_ids("2026-01-25-010-xxx", "2026-01-25-00z-yyy"), Ordering::Greater);
191/// ```
192pub fn compare_spec_ids(a: &str, b: &str) -> std::cmp::Ordering {
193    use std::cmp::Ordering;
194
195    // Try to extract driver IDs and member numbers
196    let a_driver = extract_driver_id(a);
197    let b_driver = extract_driver_id(b);
198
199    match (a_driver, b_driver) {
200        (Some(a_base), Some(b_base)) => {
201            // Both are member specs
202            // First compare the base IDs with sequence parsing
203            match compare_base_ids(&a_base, &b_base) {
204                Ordering::Equal => {
205                    // Same base ID, compare member numbers numerically
206                    let a_num = extract_member_number(a).unwrap_or(u32::MAX);
207                    let b_num = extract_member_number(b).unwrap_or(u32::MAX);
208                    a_num.cmp(&b_num)
209                }
210                other => other,
211            }
212        }
213        (Some(a_base), None) => {
214            // a is a member, b is not
215            // Compare a's base with b using sequence parsing
216            match compare_base_ids(&a_base, b) {
217                Ordering::Equal => {
218                    // b is the driver of a, so b comes first
219                    Ordering::Greater
220                }
221                other => other,
222            }
223        }
224        (None, Some(b_base)) => {
225            // a is not a member, b is
226            // Compare a with b's base using sequence parsing
227            match compare_base_ids(a, &b_base) {
228                Ordering::Equal => {
229                    // a is the driver of b, so a comes first
230                    Ordering::Less
231                }
232                other => other,
233            }
234        }
235        (None, None) => {
236            // Neither are member specs, use sequence parsing
237            compare_base_ids(a, b)
238        }
239    }
240}
241
242/// Compare two base spec IDs by parsing date, sequence, and suffix.
243///
244/// Spec IDs have format: YYYY-MM-DD-SSS-XXX where:
245/// - YYYY-MM-DD is the date (compared lexicographically)
246/// - SSS is a base36 sequence (compared numerically)
247/// - XXX is a random base36 suffix (compared lexicographically as tiebreaker)
248fn compare_base_ids(a: &str, b: &str) -> std::cmp::Ordering {
249    use std::cmp::Ordering;
250
251    // Parse both IDs into (date, sequence, suffix)
252    let a_parts = parse_spec_id_parts(a);
253    let b_parts = parse_spec_id_parts(b);
254
255    match (a_parts, b_parts) {
256        (Some((a_date, a_seq, a_suffix)), Some((b_date, b_seq, b_suffix))) => {
257            // Compare date lexicographically
258            match a_date.cmp(b_date) {
259                Ordering::Equal => {
260                    // Same date, compare sequence numerically
261                    match a_seq.cmp(&b_seq) {
262                        Ordering::Equal => {
263                            // Same sequence, compare suffix lexicographically
264                            a_suffix.cmp(b_suffix)
265                        }
266                        other => other,
267                    }
268                }
269                other => other,
270            }
271        }
272        // If parsing fails, fall back to lexicographic comparison
273        _ => a.cmp(b),
274    }
275}
276
277/// Parse a spec ID into (date, sequence_number, suffix).
278/// Returns None if the ID doesn't match the expected format.
279fn parse_spec_id_parts(id: &str) -> Option<(&str, u32, &str)> {
280    let parts: Vec<&str> = id.split('-').collect();
281
282    // Expected format: YYYY-MM-DD-SSS-XXX (5 parts minimum)
283    if parts.len() < 5 {
284        return None;
285    }
286
287    // Date is parts[0..3] joined: YYYY-MM-DD
288    let date = &id[..10]; // "YYYY-MM-DD" is always 10 chars
289
290    // Sequence is parts[3], parse from base36
291    let seq = crate::id::parse_base36(parts[3])?;
292
293    // Suffix is parts[4]
294    let suffix = parts[4];
295
296    Some((date, seq, suffix))
297}
298
299/// Check if all prior siblings of a member spec are completed.
300///
301/// For a member spec like `DRIVER_ID.3`, checks that `DRIVER_ID.1` and `DRIVER_ID.2`
302/// are both in `Completed` status. For `DRIVER_ID.1`, returns true (no prior siblings).
303/// For non-member specs, returns true (sibling check doesn't apply).
304///
305/// # Arguments
306///
307/// * `member_id` - The ID of the member spec to check
308/// * `all_specs` - All available specs
309///
310/// # Examples
311///
312/// ```ignore
313/// // For a spec DRIVER_ID.3, checks that DRIVER_ID.1 and DRIVER_ID.2 are completed
314/// assert!(all_prior_siblings_completed("2026-01-25-00y-abc.3", &specs));
315/// ```
316pub fn all_prior_siblings_completed(member_id: &str, all_specs: &[Spec]) -> bool {
317    // Find the current member spec
318    if let Some(member_spec) = all_specs.iter().find(|s| s.id == member_id) {
319        // If member has explicit depends_on, skip sequential check (use DAG dependencies instead)
320        if member_spec.frontmatter.depends_on.is_some() {
321            return true;
322        }
323    }
324
325    // Fall back to sequential ordering if no explicit dependencies
326    if let Some(driver_id) = extract_driver_id(member_id) {
327        if let Some(member_num) = extract_member_number(member_id) {
328            // Check all specs with numbers less than member_num
329            for i in 1..member_num {
330                let sibling_id = format!("{}.{}", driver_id, i);
331                let sibling = all_specs.iter().find(|s| s.id == sibling_id);
332                if let Some(s) = sibling {
333                    if s.frontmatter.status != SpecStatus::Completed
334                        && s.frontmatter.status != SpecStatus::Cancelled
335                    {
336                        return false;
337                    }
338                }
339                // Missing siblings are skipped — don't block on deleted/nonexistent specs
340            }
341            return true;
342        }
343    }
344    // Not a member spec, so this check doesn't apply
345    true
346}
347
348/// Mark the driver spec as in_progress if the current spec is a member.
349///
350/// When a member spec begins execution, its driver spec should transition from
351/// `Pending` to `InProgress` (if not already). This function handles that transition.
352///
353/// # Arguments
354///
355/// * `specs_dir` - Path to the specs directory
356/// * `member_id` - The ID of the member spec that is starting
357///
358/// # Returns
359///
360/// Returns `Ok(())` if successful or the driver doesn't exist.
361/// Returns `Err` if file I/O fails.
362///
363/// # Examples
364///
365/// ```ignore
366/// mark_driver_in_progress(&specs_dir, "2026-01-25-00y-abc.1")?;
367/// ```
368/// Mark a driver spec as in_progress when one of its members starts work.
369///
370/// This creates a "phantom" in_progress status for the driver that serves as a placeholder
371/// until all members complete. The driver will be auto-completed when the last member finishes.
372///
373/// Note: This means that during member execution, both the driver AND the active member
374/// will show as in_progress. This is by design but can be confusing in status displays.
375///
376/// Set `skip` to true to avoid marking the driver (useful in chain mode where we want
377/// only one spec to show as in_progress at a time).
378pub fn mark_driver_in_progress_conditional(
379    specs_dir: &Path,
380    member_id: &str,
381    skip: bool,
382) -> Result<()> {
383    use crate::spec::TransitionBuilder;
384
385    if skip {
386        return Ok(());
387    }
388
389    if let Some(driver_id) = extract_driver_id(member_id) {
390        // Try to load the driver spec
391        let driver_path = specs_dir.join(format!("{}.md", driver_id));
392        if driver_path.exists() {
393            let mut driver = Spec::load(&driver_path)?;
394            if driver.frontmatter.status == SpecStatus::Pending {
395                TransitionBuilder::new(&mut driver).to(SpecStatus::InProgress)?;
396                driver.save(&driver_path)?;
397            }
398        }
399    }
400    Ok(())
401}
402
403/// Mark a driver spec as in_progress when one of its members starts work.
404///
405/// Convenience wrapper that always marks the driver. For conditional marking,
406/// use `mark_driver_in_progress_conditional`.
407pub fn mark_driver_in_progress(specs_dir: &Path, member_id: &str) -> Result<()> {
408    mark_driver_in_progress_conditional(specs_dir, member_id, false)
409}
410
411/// Auto-complete a driver spec if all its members are now completed.
412///
413/// When a member spec completes, check if all other members are also completed.
414/// If so, and the driver is in `InProgress` status, automatically mark the driver
415/// as `Completed` with completion timestamp.
416///
417/// # Arguments
418///
419/// * `member_id` - The ID of the member spec that just completed
420/// * `all_specs` - All available specs
421/// * `specs_dir` - Path to the specs directory
422///
423/// # Returns
424///
425/// Returns `Ok(true)` if the driver was auto-completed.
426/// Returns `Ok(false)` if the driver was not ready for completion.
427/// Returns `Err` if file I/O fails.
428///
429/// # Examples
430///
431/// ```ignore
432/// if auto_complete_driver_if_ready("2026-01-25-00y-abc.2", &specs, &specs_dir)? {
433///     println!("Driver was auto-completed!");
434/// }
435/// ```
436pub fn auto_complete_driver_if_ready(
437    member_id: &str,
438    all_specs: &[Spec],
439    specs_dir: &Path,
440) -> Result<bool> {
441    // Only member specs can trigger driver auto-completion
442    let Some(driver_id) = extract_driver_id(member_id) else {
443        return Ok(false);
444    };
445
446    // Find the driver spec
447    let Some(driver_spec) = all_specs.iter().find(|s| s.id == driver_id) else {
448        return Ok(false);
449    };
450
451    // Only auto-complete if driver is in_progress or pending
452    // (Pending is allowed for chain mode where drivers aren't marked InProgress)
453    if driver_spec.frontmatter.status != SpecStatus::InProgress
454        && driver_spec.frontmatter.status != SpecStatus::Pending
455    {
456        return Ok(false);
457    }
458
459    // Check if all members are completed
460    if !all_members_completed(&driver_id, all_specs) {
461        return Ok(false);
462    }
463
464    // All members are completed, so auto-complete the driver
465    let driver_path = specs_dir.join(format!("{}.md", driver_id));
466    let mut driver = Spec::load(&driver_path)?;
467
468    use crate::spec::TransitionBuilder;
469    // Use force() to allow Pending→Completed transition in chain mode
470    TransitionBuilder::new(&mut driver)
471        .force()
472        .to(SpecStatus::Completed)?;
473    driver.frontmatter.completed_at = Some(crate::utc_now_iso());
474    driver.frontmatter.model = Some("auto-completed".to_string());
475
476    driver.save(&driver_path)?;
477
478    Ok(true)
479}
480
481/// Mark a driver spec as failed when one of its members fails.
482///
483/// When a member spec fails during chain execution, the driver spec should be marked
484/// as `Failed` to indicate partial group failure and prevent it from appearing ready.
485///
486/// # Arguments
487///
488/// * `member_id` - The ID of the member spec that failed
489/// * `specs_dir` - Path to the specs directory
490///
491/// # Returns
492///
493/// Returns `Ok(true)` if the driver was marked as failed.
494/// Returns `Ok(false)` if there is no driver or driver doesn't need updating.
495/// Returns `Err` if file I/O fails.
496pub fn mark_driver_failed_on_member_failure(member_id: &str, specs_dir: &Path) -> Result<bool> {
497    // Only member specs can trigger driver failure
498    let Some(driver_id) = extract_driver_id(member_id) else {
499        return Ok(false);
500    };
501
502    // Try to load the driver spec
503    let driver_path = specs_dir.join(format!("{}.md", driver_id));
504    if !driver_path.exists() {
505        return Ok(false);
506    }
507
508    let mut driver = Spec::load(&driver_path)?;
509
510    // Only mark as failed if driver is InProgress or Pending
511    // (already failed drivers should stay failed)
512    if driver.frontmatter.status != SpecStatus::InProgress
513        && driver.frontmatter.status != SpecStatus::Pending
514    {
515        return Ok(false);
516    }
517
518    // Mark driver as failed
519    use crate::spec::TransitionBuilder;
520    TransitionBuilder::new(&mut driver)
521        .force()
522        .to(SpecStatus::Failed)?;
523
524    driver.save(&driver_path)?;
525
526    Ok(true)
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn test_is_member_of() {
535        assert!(is_member_of("2026-01-22-001-x7m.1", "2026-01-22-001-x7m"));
536        assert!(is_member_of("2026-01-22-001-x7m.2.1", "2026-01-22-001-x7m"));
537        assert!(!is_member_of("2026-01-22-001-x7m", "2026-01-22-001-x7m"));
538        assert!(!is_member_of("2026-01-22-002-y8n", "2026-01-22-001-x7m"));
539    }
540
541    #[test]
542    fn test_extract_driver_id() {
543        assert_eq!(
544            extract_driver_id("2026-01-22-001-x7m.1"),
545            Some("2026-01-22-001-x7m".to_string())
546        );
547        assert_eq!(
548            extract_driver_id("2026-01-22-001-x7m.2.1"),
549            Some("2026-01-22-001-x7m".to_string())
550        );
551        assert_eq!(extract_driver_id("2026-01-22-001-x7m"), None);
552        assert_eq!(extract_driver_id("2026-01-22-001-x7m.abc"), None);
553    }
554
555    #[test]
556    fn test_extract_member_number() {
557        assert_eq!(extract_member_number("2026-01-24-001-abc.1"), Some(1));
558        assert_eq!(extract_member_number("2026-01-24-001-abc.3"), Some(3));
559        assert_eq!(extract_member_number("2026-01-24-001-abc.10"), Some(10));
560        assert_eq!(extract_member_number("2026-01-24-001-abc.3.2"), Some(3));
561        assert_eq!(extract_member_number("2026-01-24-001-abc"), None);
562        assert_eq!(extract_member_number("2026-01-24-001-abc.abc"), None);
563    }
564
565    #[test]
566    fn test_all_prior_siblings_completed() {
567        // Test spec for member .1 with no prior siblings
568        let spec1 = Spec::parse(
569            "2026-01-24-001-abc.1",
570            r#"---
571status: pending
572---
573# Test
574"#,
575        )
576        .unwrap();
577
578        // Should be ready since it has no prior siblings
579        assert!(all_prior_siblings_completed(&spec1.id, &[]));
580
581        // Test spec for member .3 with completed prior siblings
582        let spec_prior_1 = Spec::parse(
583            "2026-01-24-001-abc.1",
584            r#"---
585status: completed
586---
587# Test
588"#,
589        )
590        .unwrap();
591
592        let spec_prior_2 = Spec::parse(
593            "2026-01-24-001-abc.2",
594            r#"---
595status: completed
596---
597# Test
598"#,
599        )
600        .unwrap();
601
602        let spec3 = Spec::parse(
603            "2026-01-24-001-abc.3",
604            r#"---
605status: pending
606---
607# Test
608"#,
609        )
610        .unwrap();
611
612        let all_specs = vec![spec_prior_1, spec_prior_2, spec3.clone()];
613        assert!(all_prior_siblings_completed(&spec3.id, &all_specs));
614    }
615
616    #[test]
617    fn test_all_prior_siblings_completed_missing_skipped() {
618        // Missing siblings should not block later members
619        let spec_prior_1 = Spec::parse(
620            "2026-01-24-001-abc.1",
621            r#"---
622status: completed
623---
624# Test
625"#,
626        )
627        .unwrap();
628
629        let spec3 = Spec::parse(
630            "2026-01-24-001-abc.3",
631            r#"---
632status: pending
633---
634# Test
635"#,
636        )
637        .unwrap();
638
639        // Only spec .1 exists, .2 is missing — should still pass
640        let all_specs = vec![spec_prior_1, spec3.clone()];
641        assert!(all_prior_siblings_completed(&spec3.id, &all_specs));
642    }
643
644    #[test]
645    fn test_all_prior_siblings_completed_cancelled() {
646        // Cancelled siblings should not block later members
647        let spec_prior_1 = Spec::parse(
648            "2026-01-24-001-abc.1",
649            r#"---
650status: cancelled
651---
652# Test
653"#,
654        )
655        .unwrap();
656
657        let spec2 = Spec::parse(
658            "2026-01-24-001-abc.2",
659            r#"---
660status: pending
661---
662# Test
663"#,
664        )
665        .unwrap();
666
667        let all_specs = vec![spec_prior_1, spec2.clone()];
668        assert!(all_prior_siblings_completed(&spec2.id, &all_specs));
669    }
670
671    #[test]
672    fn test_all_prior_siblings_completed_not_completed() {
673        // Test spec for member .2 with incomplete prior sibling
674        let spec_prior_1 = Spec::parse(
675            "2026-01-24-001-abc.1",
676            r#"---
677status: pending
678---
679# Test
680"#,
681        )
682        .unwrap();
683
684        let spec2 = Spec::parse(
685            "2026-01-24-001-abc.2",
686            r#"---
687status: pending
688---
689# Test
690"#,
691        )
692        .unwrap();
693
694        let all_specs = vec![spec_prior_1, spec2.clone()];
695        assert!(!all_prior_siblings_completed(&spec2.id, &all_specs));
696    }
697
698    #[test]
699    fn test_mark_driver_in_progress_when_member_starts() {
700        use tempfile::TempDir;
701
702        let temp_dir = TempDir::new().unwrap();
703        let specs_dir = temp_dir.path();
704
705        // Create a driver spec that is pending
706        let driver_spec = Spec {
707            id: "2026-01-24-001-abc".to_string(),
708            frontmatter: crate::spec::SpecFrontmatter {
709                status: SpecStatus::Pending,
710                ..Default::default()
711            },
712            title: Some("Driver spec".to_string()),
713            body: "# Driver spec\n\nBody content.".to_string(),
714        };
715
716        let driver_path = specs_dir.join("2026-01-24-001-abc.md");
717        driver_spec.save(&driver_path).unwrap();
718
719        // Mark driver as in_progress when member starts
720        mark_driver_in_progress(specs_dir, "2026-01-24-001-abc.1").unwrap();
721
722        // Verify driver status was updated to in_progress
723        let updated_driver = Spec::load(&driver_path).unwrap();
724        assert_eq!(updated_driver.frontmatter.status, SpecStatus::InProgress);
725    }
726
727    #[test]
728    fn test_mark_driver_in_progress_skips_if_already_in_progress() {
729        use tempfile::TempDir;
730
731        let temp_dir = TempDir::new().unwrap();
732        let specs_dir = temp_dir.path();
733
734        // Create a driver spec that is already in_progress
735        let driver_spec = Spec {
736            id: "2026-01-24-002-def".to_string(),
737            frontmatter: crate::spec::SpecFrontmatter {
738                status: SpecStatus::InProgress,
739                ..Default::default()
740            },
741            title: Some("Driver spec".to_string()),
742            body: "# Driver spec\n\nBody content.".to_string(),
743        };
744
745        let driver_path = specs_dir.join("2026-01-24-002-def.md");
746        driver_spec.save(&driver_path).unwrap();
747
748        // Try to mark driver as in_progress
749        mark_driver_in_progress(specs_dir, "2026-01-24-002-def.1").unwrap();
750
751        // Verify driver status is still in_progress (not changed)
752        let updated_driver = Spec::load(&driver_path).unwrap();
753        assert_eq!(updated_driver.frontmatter.status, SpecStatus::InProgress);
754    }
755
756    #[test]
757    fn test_mark_driver_in_progress_nonexistent_driver() {
758        use tempfile::TempDir;
759
760        let temp_dir = TempDir::new().unwrap();
761        let specs_dir = temp_dir.path();
762
763        // Try to mark driver as in_progress when driver doesn't exist
764        // Should not error, just skip
765        mark_driver_in_progress(specs_dir, "2026-01-24-003-ghi.1").unwrap();
766    }
767
768    #[test]
769    fn test_get_incomplete_members() {
770        // Driver with multiple incomplete members
771        let driver = Spec::parse(
772            "2026-01-24-005-mno",
773            r#"---
774status: in_progress
775---
776# Driver
777"#,
778        )
779        .unwrap();
780
781        let member1 = Spec::parse(
782            "2026-01-24-005-mno.1",
783            r#"---
784status: completed
785---
786# Member 1
787"#,
788        )
789        .unwrap();
790
791        let member2 = Spec::parse(
792            "2026-01-24-005-mno.2",
793            r#"---
794status: pending
795---
796# Member 2
797"#,
798        )
799        .unwrap();
800
801        let member3 = Spec::parse(
802            "2026-01-24-005-mno.3",
803            r#"---
804status: in_progress
805---
806# Member 3
807"#,
808        )
809        .unwrap();
810
811        let all_specs = vec![driver.clone(), member1, member2, member3];
812        let incomplete = get_incomplete_members(&driver.id, &all_specs);
813        assert_eq!(incomplete.len(), 2);
814        assert!(incomplete.contains(&"2026-01-24-005-mno.2".to_string()));
815        assert!(incomplete.contains(&"2026-01-24-005-mno.3".to_string()));
816    }
817
818    #[test]
819    fn test_auto_complete_driver_not_member_spec() {
820        use tempfile::TempDir;
821
822        let temp_dir = TempDir::new().unwrap();
823        let specs_dir = temp_dir.path();
824
825        // A non-member spec should not trigger auto-completion
826        let driver_spec = Spec::parse(
827            "2026-01-24-006-pqr",
828            r#"---
829status: in_progress
830---
831# Driver spec
832"#,
833        )
834        .unwrap();
835
836        let result =
837            auto_complete_driver_if_ready("2026-01-24-006-pqr", &[driver_spec], specs_dir).unwrap();
838        assert!(
839            !result,
840            "Non-member spec should not trigger auto-completion"
841        );
842    }
843
844    #[test]
845    fn test_auto_complete_driver_when_already_completed() {
846        use tempfile::TempDir;
847
848        let temp_dir = TempDir::new().unwrap();
849        let specs_dir = temp_dir.path();
850
851        // Create a driver spec that is already completed
852        let driver_spec = Spec::parse(
853            "2026-01-24-007-stu",
854            r#"---
855status: completed
856---
857# Driver spec
858"#,
859        )
860        .unwrap();
861
862        let member_spec = Spec::parse(
863            "2026-01-24-007-stu.1",
864            r#"---
865status: completed
866---
867# Member 1
868"#,
869        )
870        .unwrap();
871
872        let all_specs = vec![driver_spec, member_spec];
873        let result =
874            auto_complete_driver_if_ready("2026-01-24-007-stu.1", &all_specs, specs_dir).unwrap();
875        assert!(
876            !result,
877            "Driver already completed should not be re-completed"
878        );
879    }
880
881    #[test]
882    fn test_auto_complete_driver_from_pending() {
883        use std::fs;
884        use tempfile::TempDir;
885
886        let temp_dir = TempDir::new().unwrap();
887        let specs_dir = temp_dir.path();
888
889        // Create driver spec file (pending status)
890        let driver_content = r#"---
891status: pending
892---
893# Driver spec
894"#;
895        fs::write(specs_dir.join("2026-01-24-009-xyz.md"), driver_content).unwrap();
896
897        // Parse specs for all_specs
898        let driver_spec = Spec::parse("2026-01-24-009-xyz", driver_content).unwrap();
899
900        let member_spec = Spec::parse(
901            "2026-01-24-009-xyz.1",
902            r#"---
903status: completed
904---
905# Member 1
906"#,
907        )
908        .unwrap();
909
910        let all_specs = vec![driver_spec, member_spec];
911
912        // Auto-complete should succeed for pending driver (chain mode scenario)
913        let result =
914            auto_complete_driver_if_ready("2026-01-24-009-xyz.1", &all_specs, specs_dir).unwrap();
915        assert!(
916            result,
917            "Pending driver should be auto-completed when all members are done (chain mode)"
918        );
919
920        // Verify driver is now completed
921        let updated_driver = Spec::load(&specs_dir.join("2026-01-24-009-xyz.md")).unwrap();
922        assert_eq!(updated_driver.frontmatter.status, SpecStatus::Completed);
923    }
924
925    #[test]
926    fn test_auto_complete_driver_incomplete_members() {
927        use tempfile::TempDir;
928
929        let temp_dir = TempDir::new().unwrap();
930        let specs_dir = temp_dir.path();
931
932        // Create a driver spec that is in_progress
933        let driver_spec = Spec {
934            id: "2026-01-24-008-vwx".to_string(),
935            frontmatter: crate::spec::SpecFrontmatter {
936                status: SpecStatus::InProgress,
937                ..Default::default()
938            },
939            title: Some("Driver".to_string()),
940            body: "# Driver\n\nBody.".to_string(),
941        };
942
943        let driver_path = specs_dir.join("2026-01-24-008-vwx.md");
944        driver_spec.save(&driver_path).unwrap();
945
946        // Create member specs where not all are completed
947        let member1 = Spec::parse(
948            "2026-01-24-008-vwx.1",
949            r#"---
950status: completed
951---
952# Member 1
953"#,
954        )
955        .unwrap();
956
957        let member2 = Spec::parse(
958            "2026-01-24-008-vwx.2",
959            r#"---
960status: in_progress
961---
962# Member 2
963"#,
964        )
965        .unwrap();
966
967        let all_specs = vec![driver_spec, member1, member2];
968        let result =
969            auto_complete_driver_if_ready("2026-01-24-008-vwx.1", &all_specs, specs_dir).unwrap();
970        assert!(
971            !result,
972            "Driver should not complete when members are incomplete"
973        );
974    }
975
976    #[test]
977    fn test_auto_complete_driver_success() {
978        use tempfile::TempDir;
979
980        let temp_dir = TempDir::new().unwrap();
981        let specs_dir = temp_dir.path();
982
983        // Create a driver spec that is in_progress
984        let driver_spec = Spec {
985            id: "2026-01-24-009-yz0".to_string(),
986            frontmatter: crate::spec::SpecFrontmatter {
987                status: SpecStatus::InProgress,
988                ..Default::default()
989            },
990            title: Some("Driver".to_string()),
991            body: "# Driver\n\nBody.".to_string(),
992        };
993
994        let driver_path = specs_dir.join("2026-01-24-009-yz0.md");
995        driver_spec.save(&driver_path).unwrap();
996
997        // Create member specs where all are completed
998        let member1 = Spec::parse(
999            "2026-01-24-009-yz0.1",
1000            r#"---
1001status: completed
1002---
1003# Member 1
1004"#,
1005        )
1006        .unwrap();
1007
1008        let member2 = Spec::parse(
1009            "2026-01-24-009-yz0.2",
1010            r#"---
1011status: completed
1012---
1013# Member 2
1014"#,
1015        )
1016        .unwrap();
1017
1018        let all_specs = vec![driver_spec, member1, member2];
1019
1020        // Auto-complete should succeed
1021        let result =
1022            auto_complete_driver_if_ready("2026-01-24-009-yz0.2", &all_specs, specs_dir).unwrap();
1023        assert!(
1024            result,
1025            "Driver should be auto-completed when all members are completed"
1026        );
1027
1028        // Verify driver was updated
1029        let updated_driver = Spec::load(&driver_path).unwrap();
1030        assert_eq!(updated_driver.frontmatter.status, SpecStatus::Completed);
1031        assert_eq!(
1032            updated_driver.frontmatter.model,
1033            Some("auto-completed".to_string())
1034        );
1035        assert!(updated_driver.frontmatter.completed_at.is_some());
1036    }
1037
1038    #[test]
1039    fn test_auto_complete_driver_nonexistent_driver() {
1040        use tempfile::TempDir;
1041
1042        let temp_dir = TempDir::new().unwrap();
1043        let specs_dir = temp_dir.path();
1044
1045        // Try to auto-complete when driver doesn't exist
1046        let all_specs = vec![];
1047        let result =
1048            auto_complete_driver_if_ready("2026-01-24-010-abc.1", &all_specs, specs_dir).unwrap();
1049        assert!(
1050            !result,
1051            "Should return false when driver spec doesn't exist"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_auto_complete_driver_single_member() {
1057        use tempfile::TempDir;
1058
1059        let temp_dir = TempDir::new().unwrap();
1060        let specs_dir = temp_dir.path();
1061
1062        // Driver with single member
1063        let driver_spec = Spec {
1064            id: "2026-01-24-011-def".to_string(),
1065            frontmatter: crate::spec::SpecFrontmatter {
1066                status: SpecStatus::InProgress,
1067                ..Default::default()
1068            },
1069            title: Some("Driver".to_string()),
1070            body: "# Driver\n\nBody.".to_string(),
1071        };
1072
1073        let driver_path = specs_dir.join("2026-01-24-011-def.md");
1074        driver_spec.save(&driver_path).unwrap();
1075
1076        // Single member
1077        let member = Spec::parse(
1078            "2026-01-24-011-def.1",
1079            r#"---
1080status: completed
1081---
1082# Member 1
1083"#,
1084        )
1085        .unwrap();
1086
1087        let all_specs = vec![driver_spec, member];
1088
1089        // Auto-complete should succeed
1090        let result =
1091            auto_complete_driver_if_ready("2026-01-24-011-def.1", &all_specs, specs_dir).unwrap();
1092        assert!(
1093            result,
1094            "Driver should be auto-completed when single member completes"
1095        );
1096
1097        // Verify driver was updated
1098        let updated_driver = Spec::load(&driver_path).unwrap();
1099        assert_eq!(updated_driver.frontmatter.status, SpecStatus::Completed);
1100        assert_eq!(
1101            updated_driver.frontmatter.model,
1102            Some("auto-completed".to_string())
1103        );
1104    }
1105
1106    #[test]
1107    fn test_compare_spec_ids_member_numeric_sort() {
1108        use std::cmp::Ordering;
1109
1110        // Test numeric sorting for member specs
1111        assert_eq!(
1112            compare_spec_ids("2026-01-25-00y-abc.2", "2026-01-25-00y-abc.10"),
1113            Ordering::Less
1114        );
1115        assert_eq!(
1116            compare_spec_ids("2026-01-25-00y-abc.10", "2026-01-25-00y-abc.2"),
1117            Ordering::Greater
1118        );
1119        assert_eq!(
1120            compare_spec_ids("2026-01-25-00y-abc.1", "2026-01-25-00y-abc.1"),
1121            Ordering::Equal
1122        );
1123
1124        // Test with larger numbers
1125        assert_eq!(
1126            compare_spec_ids("2026-01-25-00y-abc.99", "2026-01-25-00y-abc.100"),
1127            Ordering::Less
1128        );
1129    }
1130
1131    #[test]
1132    fn test_compare_spec_ids_different_drivers() {
1133        use std::cmp::Ordering;
1134
1135        // Different driver IDs should use lexicographic comparison
1136        assert_eq!(
1137            compare_spec_ids("2026-01-25-00y-abc.1", "2026-01-25-00y-def.1"),
1138            Ordering::Less
1139        );
1140        assert_eq!(
1141            compare_spec_ids("2026-01-25-00y-def.1", "2026-01-25-00y-abc.1"),
1142            Ordering::Greater
1143        );
1144    }
1145
1146    #[test]
1147    fn test_compare_spec_ids_non_member_specs() {
1148        use std::cmp::Ordering;
1149
1150        // Non-member specs should use lexicographic comparison
1151        assert_eq!(
1152            compare_spec_ids("2026-01-25-00y-abc", "2026-01-25-00y-def"),
1153            Ordering::Less
1154        );
1155        assert_eq!(
1156            compare_spec_ids("2026-01-25-00y-def", "2026-01-25-00y-abc"),
1157            Ordering::Greater
1158        );
1159    }
1160
1161    #[test]
1162    fn test_compare_spec_ids_driver_vs_member() {
1163        use std::cmp::Ordering;
1164
1165        // Driver should come before its members
1166        assert_eq!(
1167            compare_spec_ids("2026-01-25-00y-abc", "2026-01-25-00y-abc.1"),
1168            Ordering::Less
1169        );
1170        assert_eq!(
1171            compare_spec_ids("2026-01-25-00y-abc.1", "2026-01-25-00y-abc"),
1172            Ordering::Greater
1173        );
1174    }
1175
1176    #[test]
1177    fn test_compare_spec_ids_sorting_list() {
1178        // Test sorting a list of specs with mixed member numbers
1179        let mut ids = vec![
1180            "2026-01-25-00y-abc.10",
1181            "2026-01-25-00y-abc.2",
1182            "2026-01-25-00y-abc.1",
1183            "2026-01-25-00y-abc",
1184            "2026-01-25-00y-abc.3",
1185        ];
1186
1187        ids.sort_by(|a, b| compare_spec_ids(a, b));
1188
1189        assert_eq!(
1190            ids,
1191            vec![
1192                "2026-01-25-00y-abc",
1193                "2026-01-25-00y-abc.1",
1194                "2026-01-25-00y-abc.2",
1195                "2026-01-25-00y-abc.3",
1196                "2026-01-25-00y-abc.10",
1197            ]
1198        );
1199    }
1200
1201    #[test]
1202    fn test_compare_spec_ids_base36_sequence_rollover() {
1203        use std::cmp::Ordering;
1204
1205        // Test that base36 sequence 010 (decimal 36) sorts after 00z (decimal 35)
1206        assert_eq!(
1207            compare_spec_ids("2026-01-25-010-xxx", "2026-01-25-00z-yyy"),
1208            Ordering::Greater
1209        );
1210        assert_eq!(
1211            compare_spec_ids("2026-01-25-00z-yyy", "2026-01-25-010-xxx"),
1212            Ordering::Less
1213        );
1214
1215        // Test sorting a list with base36 rollover
1216        let mut ids = vec![
1217            "2026-01-25-010-aaa",
1218            "2026-01-25-00a-bbb",
1219            "2026-01-25-00z-ccc",
1220            "2026-01-25-001-ddd",
1221            "2026-01-25-011-eee",
1222        ];
1223
1224        ids.sort_by(|a, b| compare_spec_ids(a, b));
1225
1226        assert_eq!(
1227            ids,
1228            vec![
1229                "2026-01-25-001-ddd", // 1
1230                "2026-01-25-00a-bbb", // 10
1231                "2026-01-25-00z-ccc", // 35
1232                "2026-01-25-010-aaa", // 36
1233                "2026-01-25-011-eee", // 37
1234            ]
1235        );
1236    }
1237
1238    #[test]
1239    fn test_driver_auto_completion_with_two_members() {
1240        use tempfile::TempDir;
1241
1242        let temp_dir = TempDir::new().unwrap();
1243        let specs_dir = temp_dir.path();
1244
1245        // Create a driver spec that starts as pending
1246        let driver_spec = Spec {
1247            id: "2026-01-24-012-ghi".to_string(),
1248            frontmatter: crate::spec::SpecFrontmatter {
1249                status: SpecStatus::Pending,
1250                ..Default::default()
1251            },
1252            title: Some("Driver spec with 2 members".to_string()),
1253            body: "# Driver\n\nBody.".to_string(),
1254        };
1255
1256        let driver_path = specs_dir.join("2026-01-24-012-ghi.md");
1257        driver_spec.save(&driver_path).unwrap();
1258
1259        // Create first member (initially pending)
1260        let _member1 = Spec::parse(
1261            "2026-01-24-012-ghi.1",
1262            r#"---
1263status: pending
1264---
1265# Member 1
1266"#,
1267        )
1268        .unwrap();
1269
1270        // Create second member (initially pending)
1271        let member2 = Spec::parse(
1272            "2026-01-24-012-ghi.2",
1273            r#"---
1274status: pending
1275---
1276# Member 2
1277"#,
1278        )
1279        .unwrap();
1280
1281        // Step 1: First member starts - should mark driver as in_progress
1282        mark_driver_in_progress(specs_dir, "2026-01-24-012-ghi.1").unwrap();
1283
1284        let updated_driver = Spec::load(&driver_path).unwrap();
1285        assert_eq!(
1286            updated_driver.frontmatter.status,
1287            SpecStatus::InProgress,
1288            "Driver should be in_progress after first member starts"
1289        );
1290
1291        // Step 2: First member completes - driver should NOT complete yet
1292        let member1_completed = Spec::parse(
1293            "2026-01-24-012-ghi.1",
1294            r#"---
1295status: completed
1296---
1297# Member 1
1298"#,
1299        )
1300        .unwrap();
1301
1302        let all_specs = vec![
1303            updated_driver.clone(),
1304            member1_completed.clone(),
1305            member2.clone(),
1306        ];
1307        let result =
1308            auto_complete_driver_if_ready("2026-01-24-012-ghi.1", &all_specs, specs_dir).unwrap();
1309        assert!(
1310            !result,
1311            "Driver should NOT auto-complete when first member is done but second is pending"
1312        );
1313
1314        let still_in_progress = Spec::load(&driver_path).unwrap();
1315        assert_eq!(
1316            still_in_progress.frontmatter.status,
1317            SpecStatus::InProgress,
1318            "Driver should still be in_progress"
1319        );
1320
1321        // Step 3: Second member completes - driver SHOULD auto-complete
1322        let member2_completed = Spec::parse(
1323            "2026-01-24-012-ghi.2",
1324            r#"---
1325status: completed
1326---
1327# Member 2
1328"#,
1329        )
1330        .unwrap();
1331
1332        let all_specs = vec![
1333            still_in_progress.clone(),
1334            member1_completed.clone(),
1335            member2_completed.clone(),
1336        ];
1337        let result =
1338            auto_complete_driver_if_ready("2026-01-24-012-ghi.2", &all_specs, specs_dir).unwrap();
1339        assert!(
1340            result,
1341            "Driver should auto-complete when all members are completed"
1342        );
1343
1344        let final_driver = Spec::load(&driver_path).unwrap();
1345        assert_eq!(
1346            final_driver.frontmatter.status,
1347            SpecStatus::Completed,
1348            "Driver should be completed after all members complete"
1349        );
1350        assert_eq!(
1351            final_driver.frontmatter.model,
1352            Some("auto-completed".to_string()),
1353            "Driver should have auto-completed model"
1354        );
1355        assert!(
1356            final_driver.frontmatter.completed_at.is_some(),
1357            "Driver should have completed_at timestamp"
1358        );
1359    }
1360}