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