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                        return false;
335                    }
336                } else {
337                    // Sibling doesn't exist, so it's not completed
338                    return false;
339                }
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() {
618        // Test spec for member .3 with missing prior sibling
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
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_not_completed() {
646        // Test spec for member .2 with incomplete prior sibling
647        let spec_prior_1 = Spec::parse(
648            "2026-01-24-001-abc.1",
649            r#"---
650status: pending
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_mark_driver_in_progress_when_member_starts() {
673        use tempfile::TempDir;
674
675        let temp_dir = TempDir::new().unwrap();
676        let specs_dir = temp_dir.path();
677
678        // Create a driver spec that is pending
679        let driver_spec = Spec {
680            id: "2026-01-24-001-abc".to_string(),
681            frontmatter: crate::spec::SpecFrontmatter {
682                status: SpecStatus::Pending,
683                ..Default::default()
684            },
685            title: Some("Driver spec".to_string()),
686            body: "# Driver spec\n\nBody content.".to_string(),
687        };
688
689        let driver_path = specs_dir.join("2026-01-24-001-abc.md");
690        driver_spec.save(&driver_path).unwrap();
691
692        // Mark driver as in_progress when member starts
693        mark_driver_in_progress(specs_dir, "2026-01-24-001-abc.1").unwrap();
694
695        // Verify driver status was updated to in_progress
696        let updated_driver = Spec::load(&driver_path).unwrap();
697        assert_eq!(updated_driver.frontmatter.status, SpecStatus::InProgress);
698    }
699
700    #[test]
701    fn test_mark_driver_in_progress_skips_if_already_in_progress() {
702        use tempfile::TempDir;
703
704        let temp_dir = TempDir::new().unwrap();
705        let specs_dir = temp_dir.path();
706
707        // Create a driver spec that is already in_progress
708        let driver_spec = Spec {
709            id: "2026-01-24-002-def".to_string(),
710            frontmatter: crate::spec::SpecFrontmatter {
711                status: SpecStatus::InProgress,
712                ..Default::default()
713            },
714            title: Some("Driver spec".to_string()),
715            body: "# Driver spec\n\nBody content.".to_string(),
716        };
717
718        let driver_path = specs_dir.join("2026-01-24-002-def.md");
719        driver_spec.save(&driver_path).unwrap();
720
721        // Try to mark driver as in_progress
722        mark_driver_in_progress(specs_dir, "2026-01-24-002-def.1").unwrap();
723
724        // Verify driver status is still in_progress (not changed)
725        let updated_driver = Spec::load(&driver_path).unwrap();
726        assert_eq!(updated_driver.frontmatter.status, SpecStatus::InProgress);
727    }
728
729    #[test]
730    fn test_mark_driver_in_progress_nonexistent_driver() {
731        use tempfile::TempDir;
732
733        let temp_dir = TempDir::new().unwrap();
734        let specs_dir = temp_dir.path();
735
736        // Try to mark driver as in_progress when driver doesn't exist
737        // Should not error, just skip
738        mark_driver_in_progress(specs_dir, "2026-01-24-003-ghi.1").unwrap();
739    }
740
741    #[test]
742    fn test_get_incomplete_members() {
743        // Driver with multiple incomplete members
744        let driver = Spec::parse(
745            "2026-01-24-005-mno",
746            r#"---
747status: in_progress
748---
749# Driver
750"#,
751        )
752        .unwrap();
753
754        let member1 = Spec::parse(
755            "2026-01-24-005-mno.1",
756            r#"---
757status: completed
758---
759# Member 1
760"#,
761        )
762        .unwrap();
763
764        let member2 = Spec::parse(
765            "2026-01-24-005-mno.2",
766            r#"---
767status: pending
768---
769# Member 2
770"#,
771        )
772        .unwrap();
773
774        let member3 = Spec::parse(
775            "2026-01-24-005-mno.3",
776            r#"---
777status: in_progress
778---
779# Member 3
780"#,
781        )
782        .unwrap();
783
784        let all_specs = vec![driver.clone(), member1, member2, member3];
785        let incomplete = get_incomplete_members(&driver.id, &all_specs);
786        assert_eq!(incomplete.len(), 2);
787        assert!(incomplete.contains(&"2026-01-24-005-mno.2".to_string()));
788        assert!(incomplete.contains(&"2026-01-24-005-mno.3".to_string()));
789    }
790
791    #[test]
792    fn test_auto_complete_driver_not_member_spec() {
793        use tempfile::TempDir;
794
795        let temp_dir = TempDir::new().unwrap();
796        let specs_dir = temp_dir.path();
797
798        // A non-member spec should not trigger auto-completion
799        let driver_spec = Spec::parse(
800            "2026-01-24-006-pqr",
801            r#"---
802status: in_progress
803---
804# Driver spec
805"#,
806        )
807        .unwrap();
808
809        let result =
810            auto_complete_driver_if_ready("2026-01-24-006-pqr", &[driver_spec], specs_dir).unwrap();
811        assert!(
812            !result,
813            "Non-member spec should not trigger auto-completion"
814        );
815    }
816
817    #[test]
818    fn test_auto_complete_driver_when_already_completed() {
819        use tempfile::TempDir;
820
821        let temp_dir = TempDir::new().unwrap();
822        let specs_dir = temp_dir.path();
823
824        // Create a driver spec that is already completed
825        let driver_spec = Spec::parse(
826            "2026-01-24-007-stu",
827            r#"---
828status: completed
829---
830# Driver spec
831"#,
832        )
833        .unwrap();
834
835        let member_spec = Spec::parse(
836            "2026-01-24-007-stu.1",
837            r#"---
838status: completed
839---
840# Member 1
841"#,
842        )
843        .unwrap();
844
845        let all_specs = vec![driver_spec, member_spec];
846        let result =
847            auto_complete_driver_if_ready("2026-01-24-007-stu.1", &all_specs, specs_dir).unwrap();
848        assert!(
849            !result,
850            "Driver already completed should not be re-completed"
851        );
852    }
853
854    #[test]
855    fn test_auto_complete_driver_from_pending() {
856        use std::fs;
857        use tempfile::TempDir;
858
859        let temp_dir = TempDir::new().unwrap();
860        let specs_dir = temp_dir.path();
861
862        // Create driver spec file (pending status)
863        let driver_content = r#"---
864status: pending
865---
866# Driver spec
867"#;
868        fs::write(specs_dir.join("2026-01-24-009-xyz.md"), driver_content).unwrap();
869
870        // Parse specs for all_specs
871        let driver_spec = Spec::parse("2026-01-24-009-xyz", driver_content).unwrap();
872
873        let member_spec = Spec::parse(
874            "2026-01-24-009-xyz.1",
875            r#"---
876status: completed
877---
878# Member 1
879"#,
880        )
881        .unwrap();
882
883        let all_specs = vec![driver_spec, member_spec];
884
885        // Auto-complete should succeed for pending driver (chain mode scenario)
886        let result =
887            auto_complete_driver_if_ready("2026-01-24-009-xyz.1", &all_specs, specs_dir).unwrap();
888        assert!(
889            result,
890            "Pending driver should be auto-completed when all members are done (chain mode)"
891        );
892
893        // Verify driver is now completed
894        let updated_driver = Spec::load(&specs_dir.join("2026-01-24-009-xyz.md")).unwrap();
895        assert_eq!(updated_driver.frontmatter.status, SpecStatus::Completed);
896    }
897
898    #[test]
899    fn test_auto_complete_driver_incomplete_members() {
900        use tempfile::TempDir;
901
902        let temp_dir = TempDir::new().unwrap();
903        let specs_dir = temp_dir.path();
904
905        // Create a driver spec that is in_progress
906        let driver_spec = Spec {
907            id: "2026-01-24-008-vwx".to_string(),
908            frontmatter: crate::spec::SpecFrontmatter {
909                status: SpecStatus::InProgress,
910                ..Default::default()
911            },
912            title: Some("Driver".to_string()),
913            body: "# Driver\n\nBody.".to_string(),
914        };
915
916        let driver_path = specs_dir.join("2026-01-24-008-vwx.md");
917        driver_spec.save(&driver_path).unwrap();
918
919        // Create member specs where not all are completed
920        let member1 = Spec::parse(
921            "2026-01-24-008-vwx.1",
922            r#"---
923status: completed
924---
925# Member 1
926"#,
927        )
928        .unwrap();
929
930        let member2 = Spec::parse(
931            "2026-01-24-008-vwx.2",
932            r#"---
933status: in_progress
934---
935# Member 2
936"#,
937        )
938        .unwrap();
939
940        let all_specs = vec![driver_spec, member1, member2];
941        let result =
942            auto_complete_driver_if_ready("2026-01-24-008-vwx.1", &all_specs, specs_dir).unwrap();
943        assert!(
944            !result,
945            "Driver should not complete when members are incomplete"
946        );
947    }
948
949    #[test]
950    fn test_auto_complete_driver_success() {
951        use tempfile::TempDir;
952
953        let temp_dir = TempDir::new().unwrap();
954        let specs_dir = temp_dir.path();
955
956        // Create a driver spec that is in_progress
957        let driver_spec = Spec {
958            id: "2026-01-24-009-yz0".to_string(),
959            frontmatter: crate::spec::SpecFrontmatter {
960                status: SpecStatus::InProgress,
961                ..Default::default()
962            },
963            title: Some("Driver".to_string()),
964            body: "# Driver\n\nBody.".to_string(),
965        };
966
967        let driver_path = specs_dir.join("2026-01-24-009-yz0.md");
968        driver_spec.save(&driver_path).unwrap();
969
970        // Create member specs where all are completed
971        let member1 = Spec::parse(
972            "2026-01-24-009-yz0.1",
973            r#"---
974status: completed
975---
976# Member 1
977"#,
978        )
979        .unwrap();
980
981        let member2 = Spec::parse(
982            "2026-01-24-009-yz0.2",
983            r#"---
984status: completed
985---
986# Member 2
987"#,
988        )
989        .unwrap();
990
991        let all_specs = vec![driver_spec, member1, member2];
992
993        // Auto-complete should succeed
994        let result =
995            auto_complete_driver_if_ready("2026-01-24-009-yz0.2", &all_specs, specs_dir).unwrap();
996        assert!(
997            result,
998            "Driver should be auto-completed when all members are completed"
999        );
1000
1001        // Verify driver was updated
1002        let updated_driver = Spec::load(&driver_path).unwrap();
1003        assert_eq!(updated_driver.frontmatter.status, SpecStatus::Completed);
1004        assert_eq!(
1005            updated_driver.frontmatter.model,
1006            Some("auto-completed".to_string())
1007        );
1008        assert!(updated_driver.frontmatter.completed_at.is_some());
1009    }
1010
1011    #[test]
1012    fn test_auto_complete_driver_nonexistent_driver() {
1013        use tempfile::TempDir;
1014
1015        let temp_dir = TempDir::new().unwrap();
1016        let specs_dir = temp_dir.path();
1017
1018        // Try to auto-complete when driver doesn't exist
1019        let all_specs = vec![];
1020        let result =
1021            auto_complete_driver_if_ready("2026-01-24-010-abc.1", &all_specs, specs_dir).unwrap();
1022        assert!(
1023            !result,
1024            "Should return false when driver spec doesn't exist"
1025        );
1026    }
1027
1028    #[test]
1029    fn test_auto_complete_driver_single_member() {
1030        use tempfile::TempDir;
1031
1032        let temp_dir = TempDir::new().unwrap();
1033        let specs_dir = temp_dir.path();
1034
1035        // Driver with single member
1036        let driver_spec = Spec {
1037            id: "2026-01-24-011-def".to_string(),
1038            frontmatter: crate::spec::SpecFrontmatter {
1039                status: SpecStatus::InProgress,
1040                ..Default::default()
1041            },
1042            title: Some("Driver".to_string()),
1043            body: "# Driver\n\nBody.".to_string(),
1044        };
1045
1046        let driver_path = specs_dir.join("2026-01-24-011-def.md");
1047        driver_spec.save(&driver_path).unwrap();
1048
1049        // Single member
1050        let member = Spec::parse(
1051            "2026-01-24-011-def.1",
1052            r#"---
1053status: completed
1054---
1055# Member 1
1056"#,
1057        )
1058        .unwrap();
1059
1060        let all_specs = vec![driver_spec, member];
1061
1062        // Auto-complete should succeed
1063        let result =
1064            auto_complete_driver_if_ready("2026-01-24-011-def.1", &all_specs, specs_dir).unwrap();
1065        assert!(
1066            result,
1067            "Driver should be auto-completed when single member completes"
1068        );
1069
1070        // Verify driver was updated
1071        let updated_driver = Spec::load(&driver_path).unwrap();
1072        assert_eq!(updated_driver.frontmatter.status, SpecStatus::Completed);
1073        assert_eq!(
1074            updated_driver.frontmatter.model,
1075            Some("auto-completed".to_string())
1076        );
1077    }
1078
1079    #[test]
1080    fn test_compare_spec_ids_member_numeric_sort() {
1081        use std::cmp::Ordering;
1082
1083        // Test numeric sorting for member specs
1084        assert_eq!(
1085            compare_spec_ids("2026-01-25-00y-abc.2", "2026-01-25-00y-abc.10"),
1086            Ordering::Less
1087        );
1088        assert_eq!(
1089            compare_spec_ids("2026-01-25-00y-abc.10", "2026-01-25-00y-abc.2"),
1090            Ordering::Greater
1091        );
1092        assert_eq!(
1093            compare_spec_ids("2026-01-25-00y-abc.1", "2026-01-25-00y-abc.1"),
1094            Ordering::Equal
1095        );
1096
1097        // Test with larger numbers
1098        assert_eq!(
1099            compare_spec_ids("2026-01-25-00y-abc.99", "2026-01-25-00y-abc.100"),
1100            Ordering::Less
1101        );
1102    }
1103
1104    #[test]
1105    fn test_compare_spec_ids_different_drivers() {
1106        use std::cmp::Ordering;
1107
1108        // Different driver IDs should use lexicographic comparison
1109        assert_eq!(
1110            compare_spec_ids("2026-01-25-00y-abc.1", "2026-01-25-00y-def.1"),
1111            Ordering::Less
1112        );
1113        assert_eq!(
1114            compare_spec_ids("2026-01-25-00y-def.1", "2026-01-25-00y-abc.1"),
1115            Ordering::Greater
1116        );
1117    }
1118
1119    #[test]
1120    fn test_compare_spec_ids_non_member_specs() {
1121        use std::cmp::Ordering;
1122
1123        // Non-member specs should use lexicographic comparison
1124        assert_eq!(
1125            compare_spec_ids("2026-01-25-00y-abc", "2026-01-25-00y-def"),
1126            Ordering::Less
1127        );
1128        assert_eq!(
1129            compare_spec_ids("2026-01-25-00y-def", "2026-01-25-00y-abc"),
1130            Ordering::Greater
1131        );
1132    }
1133
1134    #[test]
1135    fn test_compare_spec_ids_driver_vs_member() {
1136        use std::cmp::Ordering;
1137
1138        // Driver should come before its members
1139        assert_eq!(
1140            compare_spec_ids("2026-01-25-00y-abc", "2026-01-25-00y-abc.1"),
1141            Ordering::Less
1142        );
1143        assert_eq!(
1144            compare_spec_ids("2026-01-25-00y-abc.1", "2026-01-25-00y-abc"),
1145            Ordering::Greater
1146        );
1147    }
1148
1149    #[test]
1150    fn test_compare_spec_ids_sorting_list() {
1151        // Test sorting a list of specs with mixed member numbers
1152        let mut ids = vec![
1153            "2026-01-25-00y-abc.10",
1154            "2026-01-25-00y-abc.2",
1155            "2026-01-25-00y-abc.1",
1156            "2026-01-25-00y-abc",
1157            "2026-01-25-00y-abc.3",
1158        ];
1159
1160        ids.sort_by(|a, b| compare_spec_ids(a, b));
1161
1162        assert_eq!(
1163            ids,
1164            vec![
1165                "2026-01-25-00y-abc",
1166                "2026-01-25-00y-abc.1",
1167                "2026-01-25-00y-abc.2",
1168                "2026-01-25-00y-abc.3",
1169                "2026-01-25-00y-abc.10",
1170            ]
1171        );
1172    }
1173
1174    #[test]
1175    fn test_compare_spec_ids_base36_sequence_rollover() {
1176        use std::cmp::Ordering;
1177
1178        // Test that base36 sequence 010 (decimal 36) sorts after 00z (decimal 35)
1179        assert_eq!(
1180            compare_spec_ids("2026-01-25-010-xxx", "2026-01-25-00z-yyy"),
1181            Ordering::Greater
1182        );
1183        assert_eq!(
1184            compare_spec_ids("2026-01-25-00z-yyy", "2026-01-25-010-xxx"),
1185            Ordering::Less
1186        );
1187
1188        // Test sorting a list with base36 rollover
1189        let mut ids = vec![
1190            "2026-01-25-010-aaa",
1191            "2026-01-25-00a-bbb",
1192            "2026-01-25-00z-ccc",
1193            "2026-01-25-001-ddd",
1194            "2026-01-25-011-eee",
1195        ];
1196
1197        ids.sort_by(|a, b| compare_spec_ids(a, b));
1198
1199        assert_eq!(
1200            ids,
1201            vec![
1202                "2026-01-25-001-ddd", // 1
1203                "2026-01-25-00a-bbb", // 10
1204                "2026-01-25-00z-ccc", // 35
1205                "2026-01-25-010-aaa", // 36
1206                "2026-01-25-011-eee", // 37
1207            ]
1208        );
1209    }
1210
1211    #[test]
1212    fn test_driver_auto_completion_with_two_members() {
1213        use tempfile::TempDir;
1214
1215        let temp_dir = TempDir::new().unwrap();
1216        let specs_dir = temp_dir.path();
1217
1218        // Create a driver spec that starts as pending
1219        let driver_spec = Spec {
1220            id: "2026-01-24-012-ghi".to_string(),
1221            frontmatter: crate::spec::SpecFrontmatter {
1222                status: SpecStatus::Pending,
1223                ..Default::default()
1224            },
1225            title: Some("Driver spec with 2 members".to_string()),
1226            body: "# Driver\n\nBody.".to_string(),
1227        };
1228
1229        let driver_path = specs_dir.join("2026-01-24-012-ghi.md");
1230        driver_spec.save(&driver_path).unwrap();
1231
1232        // Create first member (initially pending)
1233        let _member1 = Spec::parse(
1234            "2026-01-24-012-ghi.1",
1235            r#"---
1236status: pending
1237---
1238# Member 1
1239"#,
1240        )
1241        .unwrap();
1242
1243        // Create second member (initially pending)
1244        let member2 = Spec::parse(
1245            "2026-01-24-012-ghi.2",
1246            r#"---
1247status: pending
1248---
1249# Member 2
1250"#,
1251        )
1252        .unwrap();
1253
1254        // Step 1: First member starts - should mark driver as in_progress
1255        mark_driver_in_progress(specs_dir, "2026-01-24-012-ghi.1").unwrap();
1256
1257        let updated_driver = Spec::load(&driver_path).unwrap();
1258        assert_eq!(
1259            updated_driver.frontmatter.status,
1260            SpecStatus::InProgress,
1261            "Driver should be in_progress after first member starts"
1262        );
1263
1264        // Step 2: First member completes - driver should NOT complete yet
1265        let member1_completed = Spec::parse(
1266            "2026-01-24-012-ghi.1",
1267            r#"---
1268status: completed
1269---
1270# Member 1
1271"#,
1272        )
1273        .unwrap();
1274
1275        let all_specs = vec![
1276            updated_driver.clone(),
1277            member1_completed.clone(),
1278            member2.clone(),
1279        ];
1280        let result =
1281            auto_complete_driver_if_ready("2026-01-24-012-ghi.1", &all_specs, specs_dir).unwrap();
1282        assert!(
1283            !result,
1284            "Driver should NOT auto-complete when first member is done but second is pending"
1285        );
1286
1287        let still_in_progress = Spec::load(&driver_path).unwrap();
1288        assert_eq!(
1289            still_in_progress.frontmatter.status,
1290            SpecStatus::InProgress,
1291            "Driver should still be in_progress"
1292        );
1293
1294        // Step 3: Second member completes - driver SHOULD auto-complete
1295        let member2_completed = Spec::parse(
1296            "2026-01-24-012-ghi.2",
1297            r#"---
1298status: completed
1299---
1300# Member 2
1301"#,
1302        )
1303        .unwrap();
1304
1305        let all_specs = vec![
1306            still_in_progress.clone(),
1307            member1_completed.clone(),
1308            member2_completed.clone(),
1309        ];
1310        let result =
1311            auto_complete_driver_if_ready("2026-01-24-012-ghi.2", &all_specs, specs_dir).unwrap();
1312        assert!(
1313            result,
1314            "Driver should auto-complete when all members are completed"
1315        );
1316
1317        let final_driver = Spec::load(&driver_path).unwrap();
1318        assert_eq!(
1319            final_driver.frontmatter.status,
1320            SpecStatus::Completed,
1321            "Driver should be completed after all members complete"
1322        );
1323        assert_eq!(
1324            final_driver.frontmatter.model,
1325            Some("auto-completed".to_string()),
1326            "Driver should have auto-completed model"
1327        );
1328        assert!(
1329            final_driver.frontmatter.completed_at.is_some(),
1330            "Driver should have completed_at timestamp"
1331        );
1332    }
1333}