1use std::collections::HashMap;
4use std::fmt::Write as _;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, bail};
8
9use crate::team::parity::{
10 ParityMetadata, ParityReport, ParityRow, ParityStatus, VerificationStatus,
11};
12
13const SPEC_ROOT: &str = "specs";
14const SPEC_FILENAME: &str = "SPEC.md";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct BehaviorSpec {
18 pub behavior: String,
19 pub relative_path: PathBuf,
20 pub content: String,
21}
22
23pub fn load_behavior_specs(project_root: &Path) -> Result<Vec<BehaviorSpec>> {
24 let specs_root = project_root.join(SPEC_ROOT);
25 if !specs_root.is_dir() {
26 return Ok(Vec::new());
27 }
28
29 let mut files = Vec::new();
30 collect_spec_files(&specs_root, &mut files)?;
31 files.sort();
32
33 let mut specs = Vec::new();
34 for path in files {
35 let content = std::fs::read_to_string(&path)
36 .with_context(|| format!("failed to read {}", path.display()))?;
37 validate_spec_content(&content)
38 .with_context(|| format!("invalid clean-room spec {}", path.display()))?;
39 let behavior = extract_behavior_name(&content)
40 .with_context(|| format!("missing behavior heading in {}", path.display()))?;
41 let relative_path = path
42 .strip_prefix(project_root)
43 .with_context(|| format!("{} is not under {}", path.display(), project_root.display()))?
44 .to_path_buf();
45 specs.push(BehaviorSpec {
46 behavior,
47 relative_path,
48 content,
49 });
50 }
51
52 Ok(specs)
53}
54
55pub fn sync_specs_to_parity(project_root: &Path, specs: &[BehaviorSpec]) -> Result<bool> {
56 if specs.is_empty() {
57 return Ok(false);
58 }
59
60 let parity_path = project_root.join("PARITY.md");
61 let existing = std::fs::read_to_string(&parity_path)
62 .with_context(|| format!("failed to read {}", parity_path.display()))?;
63 let mut report = ParityReport::parse(&existing)
64 .with_context(|| format!("failed to parse {}", parity_path.display()))?;
65
66 let mut changed = false;
67 let mut row_index: HashMap<String, usize> = report
68 .rows
69 .iter()
70 .enumerate()
71 .map(|(index, row)| (row.behavior.clone(), index))
72 .collect();
73
74 for spec in specs {
75 if let Some(index) = row_index.get(&spec.behavior).copied() {
76 let row = &mut report.rows[index];
77 if row.spec != ParityStatus::Complete {
78 row.spec = ParityStatus::Complete;
79 changed = true;
80 }
81 let note = format!("spec: {}", spec.relative_path.display());
82 if row.notes != note {
83 row.notes = note;
84 changed = true;
85 }
86 continue;
87 }
88
89 report.rows.push(ParityRow {
90 behavior: spec.behavior.clone(),
91 spec: ParityStatus::Complete,
92 test: ParityStatus::NotStarted,
93 implementation: ParityStatus::NotStarted,
94 verified: VerificationStatus::NotStarted,
95 notes: format!("spec: {}", spec.relative_path.display()),
96 });
97 let index = report.rows.len() - 1;
98 row_index.insert(report.rows[index].behavior.clone(), index);
99 changed = true;
100 }
101
102 if !changed {
103 return Ok(false);
104 }
105
106 let updated = render_parity_report(&report);
107 std::fs::write(&parity_path, updated)
108 .with_context(|| format!("failed to write {}", parity_path.display()))?;
109 Ok(true)
110}
111
112pub fn validate_spec_content(content: &str) -> Result<()> {
113 let behavior = extract_behavior_name(content)?;
114 if behavior.trim().is_empty() {
115 bail!("behavior heading must not be empty");
116 }
117
118 let required_sections = [
119 "## Purpose",
120 "## Inputs",
121 "## Outputs",
122 "## State Transitions",
123 "## Edge Cases",
124 "## Acceptance Criteria",
125 ];
126 for section in required_sections {
127 if !content.contains(section) {
128 bail!("missing required section '{section}'");
129 }
130 }
131
132 let lower = content.to_ascii_lowercase();
133 for forbidden in [
134 "register",
135 "opcode",
136 "instruction",
137 "address",
138 "memory address",
139 "decompiled",
140 "disassembly",
141 "0x",
142 ] {
143 if lower.contains(forbidden) {
144 bail!("contains forbidden implementation detail '{forbidden}'");
145 }
146 }
147
148 for register in ["AF", "BC", "DE", "HL", "IX", "IY", "SP", "PC"] {
149 if content
150 .split(|ch: char| !ch.is_ascii_alphanumeric())
151 .any(|token| token == register)
152 {
153 bail!("contains forbidden register name '{register}'");
154 }
155 }
156
157 Ok(())
158}
159
160fn extract_behavior_name(content: &str) -> Result<String> {
161 let heading = content
162 .lines()
163 .find_map(|line| line.strip_prefix("# Behavior:"))
164 .map(str::trim)
165 .filter(|line| !line.is_empty())
166 .context("spec must start with '# Behavior: <name>'")?;
167 Ok(heading.to_string())
168}
169
170fn collect_spec_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
171 for entry in
172 std::fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))?
173 {
174 let entry = entry?;
175 let path = entry.path();
176 if path.is_dir() {
177 collect_spec_files(&path, files)?;
178 continue;
179 }
180 if path.file_name().and_then(|name| name.to_str()) == Some(SPEC_FILENAME) {
181 files.push(path);
182 }
183 }
184 Ok(())
185}
186
187fn render_parity_report(report: &ParityReport) -> String {
188 let mut output = String::new();
189 output.push_str("---\n");
190 render_metadata(&mut output, &report.metadata);
191 output.push_str("---\n\n");
192 output.push_str("| Behavior | Spec | Test | Implementation | Verified | Notes |\n");
193 output.push_str("| --- | --- | --- | --- | --- | --- |\n");
194 for row in &report.rows {
195 let _ = writeln!(
196 output,
197 "| {} | {} | {} | {} | {} | {} |",
198 row.behavior, row.spec, row.test, row.implementation, row.verified, row.notes
199 );
200 }
201 output
202}
203
204fn render_metadata(output: &mut String, metadata: &ParityMetadata) {
205 let _ = writeln!(output, "project: {}", metadata.project);
206 let _ = writeln!(output, "target: {}", metadata.target);
207 let _ = writeln!(output, "source_platform: {}", metadata.source_platform);
208 let _ = writeln!(output, "target_language: {}", metadata.target_language);
209 let _ = writeln!(output, "last_verified: {}", metadata.last_verified);
210 if let Some(overall_parity) = &metadata.overall_parity {
211 let _ = writeln!(output, "overall_parity: {}", overall_parity);
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 const VALID_SPEC: &str = r#"# Behavior: Player movement
220
221## Purpose
222
223Describe how the player moves in response to directional input.
224
225## Inputs
226
227- Directional control input.
228
229## Outputs
230
231- The visible player position changes on screen.
232
233## State Transitions
234
235- Movement begins after input and stops when input ends.
236
237## Edge Cases
238
239- Movement is blocked by solid obstacles.
240
241## Acceptance Criteria
242
243- Given a move input, the player advances exactly one walk step per tick.
244"#;
245
246 const PARITY: &str = r#"---
247project: manic-miner
248target: analysis-artifacts
249source_platform: zx-spectrum
250target_language: rust
251last_verified: pending
252overall_parity: 0%
253---
254
255| Behavior | Spec | Test | Implementation | Verified | Notes |
256| --- | --- | --- | --- | --- | --- |
257| Startup behavior | -- | -- | -- | -- | |
258| Player movement | draft | -- | -- | -- | stale |
259"#;
260
261 #[test]
262 fn validate_spec_rejects_implementation_leaks() {
263 let leaked = VALID_SPEC.replace(
264 "Describe how the player moves in response to directional input.",
265 "Describe how the player moves using register HL and opcode 0x7E.",
266 );
267 let err = validate_spec_content(&leaked).unwrap_err().to_string();
268 assert!(err.contains("forbidden"));
269 }
270
271 #[test]
272 fn load_behavior_specs_discovers_nested_spec_files() {
273 let tmp = tempfile::tempdir().unwrap();
274 let spec_path = tmp.path().join("specs/player-movement/SPEC.md");
275 std::fs::create_dir_all(spec_path.parent().unwrap()).unwrap();
276 std::fs::write(&spec_path, VALID_SPEC).unwrap();
277
278 let specs = load_behavior_specs(tmp.path()).unwrap();
279 assert_eq!(specs.len(), 1);
280 assert_eq!(specs[0].behavior, "Player movement");
281 assert_eq!(
282 specs[0].relative_path,
283 PathBuf::from("specs/player-movement/SPEC.md")
284 );
285 }
286
287 #[test]
288 fn sync_specs_to_parity_marks_specs_complete_and_adds_rows() {
289 let tmp = tempfile::tempdir().unwrap();
290 std::fs::write(tmp.path().join("PARITY.md"), PARITY).unwrap();
291
292 let specs = vec![
293 BehaviorSpec {
294 behavior: "Player movement".to_string(),
295 relative_path: PathBuf::from("specs/player-movement/SPEC.md"),
296 content: VALID_SPEC.to_string(),
297 },
298 BehaviorSpec {
299 behavior: "Collision detection".to_string(),
300 relative_path: PathBuf::from("specs/collision-detection/SPEC.md"),
301 content: VALID_SPEC.replace("Player movement", "Collision detection"),
302 },
303 ];
304
305 let changed = sync_specs_to_parity(tmp.path(), &specs).unwrap();
306 assert!(changed);
307
308 let updated = std::fs::read_to_string(tmp.path().join("PARITY.md")).unwrap();
309 let report = ParityReport::parse(&updated).unwrap();
310 let movement = report
311 .rows
312 .iter()
313 .find(|row| row.behavior == "Player movement")
314 .unwrap();
315 assert_eq!(movement.spec, ParityStatus::Complete);
316 assert_eq!(movement.notes, "spec: specs/player-movement/SPEC.md");
317
318 let collision = report
319 .rows
320 .iter()
321 .find(|row| row.behavior == "Collision detection")
322 .unwrap();
323 assert_eq!(collision.spec, ParityStatus::Complete);
324 assert_eq!(collision.test, ParityStatus::NotStarted);
325 }
326}