1use 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}