Skip to main content

batty_cli/team/
parity.rs

1//! PARITY.md parsing and reporting.
2
3use std::fmt;
4use std::path::Path;
5
6use anyhow::{Context, Result, bail};
7use serde::Deserialize;
8
9const PARITY_FILE: &str = "PARITY.md";
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ParityReport {
13    pub metadata: ParityMetadata,
14    pub rows: Vec<ParityRow>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
18pub struct ParityMetadata {
19    pub project: String,
20    pub target: String,
21    pub source_platform: String,
22    pub target_language: String,
23    pub last_verified: String,
24    #[serde(default)]
25    pub overall_parity: Option<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ParityRow {
30    pub behavior: String,
31    pub spec: ParityStatus,
32    pub test: ParityStatus,
33    pub implementation: ParityStatus,
34    pub verified: VerificationStatus,
35    pub notes: String,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ParityStatus {
40    NotStarted,
41    Draft,
42    Complete,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum VerificationStatus {
47    NotStarted,
48    Pass,
49    Fail,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ParitySummary {
54    pub total_behaviors: usize,
55    pub spec_complete: usize,
56    pub tests_complete: usize,
57    pub implementation_complete: usize,
58    pub verified_pass: usize,
59    pub verified_fail: usize,
60    pub overall_parity_pct: usize,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct GapTaskSpec {
65    pub title: String,
66    pub body: String,
67}
68
69impl ParityReport {
70    pub fn load(project_root: &Path) -> Result<Self> {
71        let path = project_root.join(PARITY_FILE);
72        let content = std::fs::read_to_string(&path)
73            .with_context(|| format!("failed to read {}", path.display()))?;
74        Self::parse(&content).with_context(|| format!("failed to parse {}", path.display()))
75    }
76
77    pub fn parse(input: &str) -> Result<Self> {
78        let (frontmatter, body) = split_frontmatter(input)?;
79        let metadata: ParityMetadata =
80            serde_yaml::from_str(frontmatter).context("failed to parse PARITY.md frontmatter")?;
81        let rows = parse_table_rows(body)?;
82        if rows.is_empty() {
83            bail!("PARITY.md must include at least one behavior row");
84        }
85        Ok(Self { metadata, rows })
86    }
87
88    pub fn summary(&self) -> ParitySummary {
89        let total_behaviors = self.rows.len();
90        let spec_complete = self
91            .rows
92            .iter()
93            .filter(|row| row.spec == ParityStatus::Complete)
94            .count();
95        let tests_complete = self
96            .rows
97            .iter()
98            .filter(|row| row.test == ParityStatus::Complete)
99            .count();
100        let implementation_complete = self
101            .rows
102            .iter()
103            .filter(|row| row.implementation == ParityStatus::Complete)
104            .count();
105        let verified_pass = self
106            .rows
107            .iter()
108            .filter(|row| row.verified == VerificationStatus::Pass)
109            .count();
110        let verified_fail = self
111            .rows
112            .iter()
113            .filter(|row| row.verified == VerificationStatus::Fail)
114            .count();
115        let overall_parity_pct = if total_behaviors == 0 {
116            0
117        } else {
118            (verified_pass * 100) / total_behaviors
119        };
120
121        ParitySummary {
122            total_behaviors,
123            spec_complete,
124            tests_complete,
125            implementation_complete,
126            verified_pass,
127            verified_fail,
128            overall_parity_pct,
129        }
130    }
131
132    pub fn gaps(&self) -> Vec<&ParityRow> {
133        self.rows
134            .iter()
135            .filter(|row| {
136                row.spec != ParityStatus::NotStarted
137                    && (row.test == ParityStatus::NotStarted
138                        || row.implementation == ParityStatus::NotStarted)
139            })
140            .collect()
141    }
142
143    pub fn update_verification(
144        &mut self,
145        behavior: &str,
146        verified: VerificationStatus,
147        notes: &str,
148    ) -> Result<()> {
149        let mut updated = false;
150        for row in &mut self.rows {
151            if row.behavior == behavior {
152                row.verified = verified;
153                row.notes = notes.to_string();
154                updated = true;
155                break;
156            }
157        }
158        if !updated {
159            bail!("behavior `{behavior}` not found in PARITY.md");
160        }
161        self.metadata.last_verified = chrono::Utc::now().date_naive().to_string();
162        self.metadata.overall_parity = Some(format!("{}%", self.summary().overall_parity_pct));
163        Ok(())
164    }
165
166    pub fn render(&self) -> String {
167        let mut out = String::new();
168        out.push_str("---\n");
169        out.push_str(&format!("project: {}\n", self.metadata.project));
170        out.push_str(&format!("target: {}\n", self.metadata.target));
171        out.push_str(&format!(
172            "source_platform: {}\n",
173            self.metadata.source_platform
174        ));
175        out.push_str(&format!(
176            "target_language: {}\n",
177            self.metadata.target_language
178        ));
179        out.push_str(&format!("last_verified: {}\n", self.metadata.last_verified));
180        if let Some(overall_parity) = &self.metadata.overall_parity {
181            out.push_str(&format!("overall_parity: {}\n", overall_parity));
182        }
183        out.push_str("---\n\n");
184        out.push_str("| Behavior | Spec | Test | Implementation | Verified | Notes |\n");
185        out.push_str("| --- | --- | --- | --- | --- | --- |\n");
186        for row in &self.rows {
187            out.push_str(&format!(
188                "| {} | {} | {} | {} | {} | {} |\n",
189                row.behavior, row.spec, row.test, row.implementation, row.verified, row.notes
190            ));
191        }
192        out
193    }
194}
195
196impl fmt::Display for ParityStatus {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        let text = match self {
199            Self::NotStarted => "--",
200            Self::Draft => "draft",
201            Self::Complete => "complete",
202        };
203        f.write_str(text)
204    }
205}
206
207impl fmt::Display for VerificationStatus {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        let text = match self {
210            Self::NotStarted => "--",
211            Self::Pass => "PASS",
212            Self::Fail => "FAIL",
213        };
214        f.write_str(text)
215    }
216}
217
218pub fn show_parity(project_root: &Path, detail: bool, gaps_only: bool) -> Result<()> {
219    let report = ParityReport::load(project_root)?;
220    let summary = report.summary();
221
222    println!("Project: {}", report.metadata.project);
223    println!("Target: {}", report.metadata.target);
224    println!("Source platform: {}", report.metadata.source_platform);
225    println!("Target language: {}", report.metadata.target_language);
226    println!("Last verified: {}", report.metadata.last_verified);
227    println!("Total behaviors: {}", summary.total_behaviors);
228    println!("Spec complete: {}", summary.spec_complete);
229    println!("Tests complete: {}", summary.tests_complete);
230    println!(
231        "Implementation complete: {}",
232        summary.implementation_complete
233    );
234    println!("Verified PASS: {}", summary.verified_pass);
235    println!("Verified FAIL: {}", summary.verified_fail);
236    println!("Overall parity: {}%", summary.overall_parity_pct);
237
238    if detail || gaps_only {
239        println!();
240        print_parity_table(&report, gaps_only);
241    }
242
243    Ok(())
244}
245
246pub fn sync_gap_tasks(project_root: &Path) -> Result<Vec<String>> {
247    let report = match ParityReport::load(project_root) {
248        Ok(report) => report,
249        Err(_) => return Ok(Vec::new()),
250    };
251
252    let board_dir = crate::team::team_config_dir(project_root).join("board");
253    let tasks_dir = board_dir.join("tasks");
254    let existing_tasks = if tasks_dir.is_dir() {
255        crate::task::load_tasks_from_dir(&tasks_dir)?
256    } else {
257        Vec::new()
258    };
259
260    let specs = report.missing_gap_task_specs(&existing_tasks);
261    let mut created = Vec::new();
262    for spec in specs {
263        crate::team::board_cmd::create_task(
264            &board_dir,
265            &spec.title,
266            &spec.body,
267            Some("medium"),
268            Some("parity,clean-room"),
269            None,
270        )
271        .with_context(|| format!("failed to create board task '{}'", spec.title))?;
272        created.push(spec.title);
273    }
274    Ok(created)
275}
276
277pub fn update_parity_verification(
278    project_root: &Path,
279    behavior: &str,
280    verified: VerificationStatus,
281    notes: &str,
282) -> Result<()> {
283    let path = project_root.join(PARITY_FILE);
284    let mut report = ParityReport::load(project_root)?;
285    report.update_verification(behavior, verified, notes)?;
286    std::fs::write(&path, report.render())
287        .with_context(|| format!("failed to write {}", path.display()))?;
288    Ok(())
289}
290
291fn print_parity_table(report: &ParityReport, gaps_only: bool) {
292    let rows: Vec<&ParityRow> = if gaps_only {
293        report.gaps()
294    } else {
295        report.rows.iter().collect()
296    };
297
298    if rows.is_empty() {
299        println!("No parity gaps found.");
300        return;
301    }
302
303    println!(
304        "{:<28} {:<10} {:<10} {:<16} {:<10} NOTES",
305        "BEHAVIOR", "SPEC", "TEST", "IMPLEMENTATION", "VERIFIED"
306    );
307    println!("{}", "-".repeat(96));
308    for row in rows {
309        println!(
310            "{:<28} {:<10} {:<10} {:<16} {:<10} {}",
311            truncate(&row.behavior, 28),
312            row.spec,
313            row.test,
314            row.implementation,
315            row.verified,
316            row.notes
317        );
318    }
319}
320
321fn truncate(input: &str, width: usize) -> String {
322    let count = input.chars().count();
323    if count <= width {
324        return input.to_string();
325    }
326    let mut out: String = input.chars().take(width.saturating_sub(3)).collect();
327    out.push_str("...");
328    out
329}
330
331fn split_frontmatter(input: &str) -> Result<(&str, &str)> {
332    let mut lines = input.lines();
333    if lines.next() != Some("---") {
334        bail!("PARITY.md must start with YAML frontmatter");
335    }
336
337    let after_start = input
338        .find("\n")
339        .map(|idx| idx + 1)
340        .context("PARITY.md frontmatter is malformed")?;
341    let rest = &input[after_start..];
342    let end_offset = rest
343        .find("\n---")
344        .context("PARITY.md frontmatter is missing closing delimiter")?;
345    let frontmatter = &rest[..end_offset];
346    let body_start = after_start + end_offset + "\n---".len();
347    let body = input
348        .get(body_start..)
349        .unwrap_or("")
350        .trim_start_matches('\n')
351        .trim();
352    Ok((frontmatter, body))
353}
354
355fn parse_table_rows(body: &str) -> Result<Vec<ParityRow>> {
356    let table_lines: Vec<&str> = body
357        .lines()
358        .map(str::trim)
359        .filter(|line| line.starts_with('|'))
360        .collect();
361
362    if table_lines.len() < 3 {
363        bail!("PARITY.md table must include a header, separator, and at least one row");
364    }
365
366    let header = split_table_line(table_lines[0]);
367    let expected = [
368        "Behavior",
369        "Spec",
370        "Test",
371        "Implementation",
372        "Verified",
373        "Notes",
374    ];
375    if header != expected {
376        bail!("PARITY.md table columns must be: {}", expected.join(" | "));
377    }
378
379    let mut rows = Vec::new();
380    for line in table_lines.iter().skip(2) {
381        let cols = split_table_line(line);
382        if cols.len() != 6 {
383            bail!("PARITY.md rows must have exactly 6 columns");
384        }
385        rows.push(ParityRow {
386            behavior: cols[0].to_string(),
387            spec: ParityStatus::parse(cols[1])?,
388            test: ParityStatus::parse(cols[2])?,
389            implementation: ParityStatus::parse(cols[3])?,
390            verified: VerificationStatus::parse(cols[4])?,
391            notes: cols[5].to_string(),
392        });
393    }
394    Ok(rows)
395}
396
397fn split_table_line(line: &str) -> Vec<&str> {
398    line.trim_matches('|').split('|').map(str::trim).collect()
399}
400
401impl ParityStatus {
402    fn parse(input: &str) -> Result<Self> {
403        match input {
404            "--" => Ok(Self::NotStarted),
405            "draft" => Ok(Self::Draft),
406            "complete" => Ok(Self::Complete),
407            other => bail!("invalid parity status '{other}'"),
408        }
409    }
410}
411
412impl VerificationStatus {
413    fn parse(input: &str) -> Result<Self> {
414        match input {
415            "--" => Ok(Self::NotStarted),
416            "PASS" => Ok(Self::Pass),
417            "FAIL" => Ok(Self::Fail),
418            other => bail!("invalid verification status '{other}'"),
419        }
420    }
421}
422
423impl ParityReport {
424    fn missing_gap_task_specs(&self, existing_tasks: &[crate::task::Task]) -> Vec<GapTaskSpec> {
425        let existing_titles: std::collections::HashSet<&str> = existing_tasks
426            .iter()
427            .map(|task| task.title.as_str())
428            .collect();
429
430        self.gaps()
431            .into_iter()
432            .map(GapTaskSpec::from_row)
433            .filter(|spec| !existing_titles.contains(spec.title.as_str()))
434            .collect()
435    }
436}
437
438impl GapTaskSpec {
439    fn from_row(row: &ParityRow) -> Self {
440        let title = format!("Parity gap: {}", row.behavior);
441        let body = format!(
442            "Close the clean-room parity gap for `{}`.\n\nCurrent parity row:\n- Spec: {}\n- Test: {}\n- Implementation: {}\n- Verified: {}\n- Notes: {}\n",
443            row.behavior,
444            row.spec,
445            row.test,
446            row.implementation,
447            row.verified,
448            if row.notes.is_empty() {
449                "(none)"
450            } else {
451                row.notes.as_str()
452            }
453        );
454        Self { title, body }
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    const SAMPLE: &str = r#"---
463project: manic-miner
464target: original-binary.z80
465source_platform: zx-spectrum-z80
466target_language: rust
467last_verified: 2026-04-05
468overall_parity: 73%
469---
470
471| Behavior | Spec | Test | Implementation | Verified | Notes |
472| --- | --- | --- | --- | --- | --- |
473| Input handling | complete | complete | complete | PASS | parity matched |
474| Enemy AI | complete | -- | draft | -- | tests pending |
475| Sound timing | draft | -- | -- | FAIL | timing drift |
476"#;
477
478    #[test]
479    fn parse_report_extracts_frontmatter_and_rows() {
480        let report = ParityReport::parse(SAMPLE).unwrap();
481        assert_eq!(report.metadata.project, "manic-miner");
482        assert_eq!(report.rows.len(), 3);
483        assert_eq!(report.rows[1].behavior, "Enemy AI");
484        assert_eq!(report.rows[1].test, ParityStatus::NotStarted);
485        assert_eq!(report.rows[2].verified, VerificationStatus::Fail);
486    }
487
488    #[test]
489    fn summary_counts_completed_and_verified_rows() {
490        let report = ParityReport::parse(SAMPLE).unwrap();
491        let summary = report.summary();
492        assert_eq!(summary.total_behaviors, 3);
493        assert_eq!(summary.spec_complete, 2);
494        assert_eq!(summary.tests_complete, 1);
495        assert_eq!(summary.implementation_complete, 1);
496        assert_eq!(summary.verified_pass, 1);
497        assert_eq!(summary.verified_fail, 1);
498        assert_eq!(summary.overall_parity_pct, 33);
499    }
500
501    #[test]
502    fn gaps_only_returns_specified_rows_missing_tests_or_implementation() {
503        let report = ParityReport::parse(SAMPLE).unwrap();
504        let gaps = report.gaps();
505        assert_eq!(gaps.len(), 2);
506        assert_eq!(gaps[0].behavior, "Enemy AI");
507        assert_eq!(gaps[1].behavior, "Sound timing");
508    }
509
510    #[test]
511    fn parse_rejects_invalid_status_values() {
512        let bad = SAMPLE.replace(
513            "| Enemy AI | complete | -- | draft | -- | tests pending |",
514            "| Enemy AI | started | -- | draft | -- | tests pending |",
515        );
516        let err = ParityReport::parse(&bad).unwrap_err().to_string();
517        assert!(err.contains("invalid parity status"));
518    }
519
520    #[test]
521    fn load_reads_project_parity_file() {
522        let tmp = tempfile::tempdir().unwrap();
523        std::fs::write(tmp.path().join(PARITY_FILE), SAMPLE).unwrap();
524        let report = ParityReport::load(tmp.path()).unwrap();
525        assert_eq!(report.metadata.project, "manic-miner");
526    }
527
528    #[test]
529    fn parse_sample_parity_md_produces_expected_summary() {
530        let report = ParityReport::parse(SAMPLE).unwrap();
531        let summary = report.summary();
532
533        assert_eq!(summary.total_behaviors, 3);
534        assert_eq!(summary.spec_complete, 2);
535        assert_eq!(summary.tests_complete, 1);
536        assert_eq!(summary.implementation_complete, 1);
537        assert_eq!(summary.verified_pass, 1);
538        assert_eq!(summary.verified_fail, 1);
539    }
540
541    #[test]
542    fn missing_gap_task_specs_skips_existing_titles() {
543        let report = ParityReport::parse(SAMPLE).unwrap();
544        let existing = vec![crate::task::Task {
545            id: 1,
546            title: "Parity gap: Enemy AI".to_string(),
547            status: "todo".to_string(),
548            priority: "medium".to_string(),
549            claimed_by: None,
550            blocked: None,
551            tags: Vec::new(),
552            depends_on: Vec::new(),
553            review_owner: None,
554            blocked_on: None,
555            worktree_path: None,
556            branch: None,
557            commit: None,
558            artifacts: Vec::new(),
559            next_action: None,
560            scheduled_for: None,
561            cron_schedule: None,
562            cron_last_run: None,
563            completed: None,
564            description: String::new(),
565            batty_config: None,
566            source_path: std::path::PathBuf::new(),
567        }];
568
569        let specs = report.missing_gap_task_specs(&existing);
570        assert_eq!(specs.len(), 1);
571        assert_eq!(specs[0].title, "Parity gap: Sound timing");
572        assert!(specs[0].body.contains("Implementation: --"));
573    }
574
575    #[test]
576    fn update_parity_verification_marks_behavior_and_recomputes_summary() {
577        let tmp = tempfile::tempdir().unwrap();
578        std::fs::write(tmp.path().join(PARITY_FILE), SAMPLE).unwrap();
579
580        update_parity_verification(
581            tmp.path(),
582            "Enemy AI",
583            VerificationStatus::Pass,
584            "matching_frames=12, divergent_frames=0, timing_difference=0",
585        )
586        .unwrap();
587
588        let updated = ParityReport::load(tmp.path()).unwrap();
589        let row = updated
590            .rows
591            .iter()
592            .find(|row| row.behavior == "Enemy AI")
593            .unwrap();
594        assert_eq!(row.verified, VerificationStatus::Pass);
595        assert!(row.notes.contains("matching_frames=12"));
596        assert_eq!(updated.metadata.overall_parity.as_deref(), Some("66%"));
597    }
598}