1use std::collections::BTreeMap;
37use std::path::Path;
38
39use serde::{Deserialize, Serialize};
40use thiserror::Error;
41
42#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
44pub struct FixtureDecl {
45 #[serde(rename = "testID")]
49 pub test_id: String,
50 pub signal: SignalMatcher,
52 #[serde(rename = "timeoutMs")]
54 pub timeout_ms: u64,
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
60pub struct SignalMatcher {
61 pub regex: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub level: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
68struct RegistryFile {
69 #[serde(default)]
70 #[allow(dead_code)]
71 version: u32,
72 fixtures: BTreeMap<String, FixtureDecl>,
73}
74
75#[derive(Clone, Debug)]
77pub struct FixtureRegistry {
78 fixtures: BTreeMap<String, FixtureDecl>,
79}
80
81#[derive(Debug, Error)]
82pub enum RegistryError {
83 #[error("read {path}: {source}")]
84 Io {
85 path: String,
86 #[source]
87 source: std::io::Error,
88 },
89 #[error("parse {path}: {detail}")]
90 Malformed { path: String, detail: String },
91 #[error("fixture not found: {id} (registered: {known:?})")]
92 NotFound { id: String, known: Vec<String> },
93}
94
95impl FixtureRegistry {
96 pub fn load(path: &Path) -> Result<Self, RegistryError> {
103 let text = std::fs::read_to_string(path).map_err(|source| RegistryError::Io {
104 path: path.display().to_string(),
105 source,
106 })?;
107 let ext = path
108 .extension()
109 .and_then(|s| s.to_str())
110 .map(|s| s.to_ascii_lowercase());
111 match ext.as_deref() {
112 Some("ts") | Some("tsx") => Self::load_from_ts_source(&text, path),
113 Some("json") => Self::load_from_json(&text, path),
114 _ => {
115 Self::load_from_json(&text, path)
117 .or_else(|_| Self::load_from_ts_source(&text, path))
118 }
119 }
120 }
121
122 fn load_from_json(text: &str, path: &Path) -> Result<Self, RegistryError> {
123 let file: RegistryFile =
124 serde_json::from_str(text).map_err(|e| RegistryError::Malformed {
125 path: path.display().to_string(),
126 detail: e.to_string(),
127 })?;
128 Ok(Self {
129 fixtures: file.fixtures,
130 })
131 }
132
133 fn load_from_ts_source(text: &str, path: &Path) -> Result<Self, RegistryError> {
159 let cleaned = strip_ts_comments(text);
161 let start = cleaned
163 .find("FIXTURES")
164 .ok_or_else(|| RegistryError::Malformed {
165 path: path.display().to_string(),
166 detail: "no `FIXTURES` binding found; use JSON registry or codegen".into(),
167 })?;
168 let after = &cleaned[start..];
169 let eq = after.find('=').ok_or_else(|| RegistryError::Malformed {
171 path: path.display().to_string(),
172 detail: "no `=` after FIXTURES binding".into(),
173 })?;
174 let brace_start = after[eq..]
175 .find('{')
176 .ok_or_else(|| RegistryError::Malformed {
177 path: path.display().to_string(),
178 detail: "no `{` in FIXTURES value".into(),
179 })?
180 + eq;
181 let object_lit = extract_balanced_braces(&after[brace_start..]).ok_or_else(|| {
182 RegistryError::Malformed {
183 path: path.display().to_string(),
184 detail: "unbalanced braces in FIXTURES value".into(),
185 }
186 })?;
187 let json_ish = normalize_ts_object_to_json(&object_lit);
189 let fixtures: BTreeMap<String, FixtureDecl> =
191 serde_json::from_str(&json_ish).map_err(|e| RegistryError::Malformed {
192 path: path.display().to_string(),
193 detail: format!("TS-to-JSON parse: {e}; normalized text: {json_ish}"),
194 })?;
195 Ok(Self { fixtures })
196 }
197
198 pub fn get(&self, id: &str) -> Option<&FixtureDecl> {
199 self.fixtures.get(id)
200 }
201
202 pub fn require(&self, id: &str) -> Result<&FixtureDecl, RegistryError> {
203 self.get(id).ok_or_else(|| RegistryError::NotFound {
204 id: id.to_string(),
205 known: self.fixtures.keys().cloned().collect(),
206 })
207 }
208
209 pub fn ids(&self) -> impl Iterator<Item = &String> {
210 self.fixtures.keys()
211 }
212
213 pub fn from_map(fixtures: BTreeMap<String, FixtureDecl>) -> Self {
216 Self { fixtures }
217 }
218}
219
220fn strip_ts_comments(text: &str) -> String {
222 let mut out = String::with_capacity(text.len());
223 let bytes = text.as_bytes();
224 let mut i = 0;
225 let mut in_string: Option<u8> = None;
226 while i < bytes.len() {
227 let c = bytes[i];
228 if let Some(q) = in_string {
230 out.push(c as char);
231 if c == q && (i == 0 || bytes[i - 1] != b'\\') {
232 in_string = None;
233 }
234 i += 1;
235 continue;
236 }
237 if c == b'"' || c == b'\'' || c == b'`' {
238 in_string = Some(c);
239 out.push(c as char);
240 i += 1;
241 continue;
242 }
243 if c == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
245 while i < bytes.len() && bytes[i] != b'\n' {
246 i += 1;
247 }
248 continue;
249 }
250 if c == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
252 i += 2;
253 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
254 i += 1;
255 }
256 i += 2;
257 continue;
258 }
259 out.push(c as char);
260 i += 1;
261 }
262 out
263}
264
265fn extract_balanced_braces(s: &str) -> Option<String> {
268 let bytes = s.as_bytes();
269 let mut depth = 0i32;
270 let mut in_string: Option<u8> = None;
271 let mut end = 0;
272 for (i, &c) in bytes.iter().enumerate() {
273 if let Some(q) = in_string {
274 if c == q && (i == 0 || bytes[i - 1] != b'\\') {
275 in_string = None;
276 }
277 continue;
278 }
279 if c == b'"' || c == b'\'' || c == b'`' {
280 in_string = Some(c);
281 continue;
282 }
283 if c == b'{' {
284 depth += 1;
285 } else if c == b'}' {
286 depth -= 1;
287 if depth == 0 {
288 end = i + 1;
289 break;
290 }
291 }
292 }
293 if end == 0 {
294 None
295 } else {
296 Some(s[..end].to_string())
297 }
298}
299
300fn normalize_ts_object_to_json(s: &str) -> String {
310 let mut out = String::with_capacity(s.len());
311 let bytes = s.as_bytes();
312 let mut i = 0;
313 let mut expect_key = false;
316 while i < bytes.len() {
317 let c = bytes[i];
318 if c.is_ascii_whitespace() {
320 out.push(c as char);
321 i += 1;
322 continue;
323 }
324 if c == b'{' || c == b',' {
325 out.push(c as char);
326 expect_key = c == b'{';
327 i += 1;
328 let mut j = i;
331 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
332 j += 1;
333 }
334 if c == b',' && j < bytes.len() && bytes[j] == b'}' {
335 out.pop();
337 i = j;
338 continue;
339 }
340 if c == b',' {
341 expect_key = true;
342 }
343 continue;
344 }
345 if c == b'\'' {
347 out.push('"');
348 i += 1;
349 while i < bytes.len() && bytes[i] != b'\'' {
350 if bytes[i] == b'"' {
351 out.push_str("\\\"");
352 } else {
353 out.push(bytes[i] as char);
354 }
355 i += 1;
356 }
357 out.push('"');
358 i += 1; expect_key = false;
360 continue;
361 }
362 if c == b'/'
365 && !expect_key
366 && i + 1 < bytes.len()
367 && bytes[i + 1] != b'/'
368 && bytes[i + 1] != b'*'
369 {
370 i += 1;
371 let mut pat = String::new();
372 while i < bytes.len() && bytes[i] != b'/' {
373 if bytes[i] == b'\\' && i + 1 < bytes.len() {
378 pat.push('\\');
381 pat.push('\\');
382 pat.push(bytes[i + 1] as char);
383 i += 2;
384 continue;
385 }
386 if bytes[i] == b'"' {
387 pat.push_str("\\\"");
388 i += 1;
389 continue;
390 }
391 pat.push(bytes[i] as char);
392 i += 1;
393 }
394 i += 1; while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
397 i += 1;
398 }
399 out.push('"');
400 out.push_str(&pat);
401 out.push('"');
402 expect_key = false;
403 continue;
404 }
405 if expect_key && (c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'.') {
407 let mut key = String::new();
408 while i < bytes.len()
409 && (bytes[i].is_ascii_alphanumeric()
410 || bytes[i] == b'_'
411 || bytes[i] == b'-'
412 || bytes[i] == b'.')
413 {
414 key.push(bytes[i] as char);
415 i += 1;
416 }
417 out.push('"');
418 out.push_str(&key);
419 out.push('"');
420 expect_key = false;
421 continue;
422 }
423 if c == b'"' {
425 out.push('"');
426 i += 1;
427 while i < bytes.len() && bytes[i] != b'"' {
428 if bytes[i] == b'\\' && i + 1 < bytes.len() {
429 out.push(bytes[i] as char);
430 out.push(bytes[i + 1] as char);
431 i += 2;
432 continue;
433 }
434 out.push(bytes[i] as char);
435 i += 1;
436 }
437 out.push('"');
438 i += 1;
439 expect_key = false;
440 continue;
441 }
442 if c == b':' {
443 out.push(':');
444 i += 1;
445 expect_key = false;
446 continue;
447 }
448 out.push(c as char);
450 i += 1;
451 }
452 out
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use tempfile::TempDir;
459
460 fn write(tmp: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
461 let p = tmp.path().join(name);
462 std::fs::write(&p, content).unwrap();
463 p
464 }
465
466 #[test]
467 fn load_single_fixture() {
468 let tmp = TempDir::new().unwrap();
469 let path = write(
470 &tmp,
471 "reg.json",
472 r#"{
473 "version": 1,
474 "fixtures": {
475 "prime-search-history": {
476 "testID": "qa-chip-prime-search-history",
477 "signal": {
478 "regex": "\\[fixture\\] prime-search-history: seeded (\\d+) rows",
479 "level": "log"
480 },
481 "timeoutMs": 8000
482 }
483 }
484 }"#,
485 );
486 let reg = FixtureRegistry::load(&path).unwrap();
487 let f = reg.require("prime-search-history").unwrap();
488 assert_eq!(f.test_id, "qa-chip-prime-search-history");
489 assert_eq!(f.timeout_ms, 8000);
490 assert_eq!(f.signal.level.as_deref(), Some("log"));
491 }
492
493 #[test]
494 fn not_found_lists_available() {
495 let tmp = TempDir::new().unwrap();
496 let path = write(
497 &tmp,
498 "reg.json",
499 r#"{"fixtures":{
500 "a":{"testID":"t-a","signal":{"regex":"a"},"timeoutMs":100},
501 "b":{"testID":"t-b","signal":{"regex":"b"},"timeoutMs":100}
502 }}"#,
503 );
504 let reg = FixtureRegistry::load(&path).unwrap();
505 let err = reg.require("nope").unwrap_err();
506 match err {
507 RegistryError::NotFound { known, .. } => {
508 assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
509 }
510 other => panic!("expected NotFound, got {other:?}"),
511 }
512 }
513
514 #[test]
515 fn malformed_json_surfaces_error() {
516 let tmp = TempDir::new().unwrap();
517 let path = write(&tmp, "reg.json", "not json");
518 let err = FixtureRegistry::load(&path).unwrap_err();
519 matches!(err, RegistryError::Malformed { .. });
520 }
521
522 #[test]
523 fn multi_fixture_ids_iterate_deterministic() {
524 let tmp = TempDir::new().unwrap();
525 let path = write(
526 &tmp,
527 "reg.json",
528 r#"{"fixtures":{
529 "z":{"testID":"z","signal":{"regex":"."},"timeoutMs":100},
530 "a":{"testID":"a","signal":{"regex":"."},"timeoutMs":100},
531 "m":{"testID":"m","signal":{"regex":"."},"timeoutMs":100}
532 }}"#,
533 );
534 let reg = FixtureRegistry::load(&path).unwrap();
535 let ids: Vec<&String> = reg.ids().collect();
536 assert_eq!(ids, vec!["a", "m", "z"]);
537 }
538}
539
540#[cfg(test)]
541mod ts_reader_tests {
542 use super::*;
543 use std::io::Write;
544 use tempfile::TempDir;
545
546 fn write_ts(tmp: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
547 let p = tmp.path().join(name);
548 std::fs::File::create(&p)
549 .unwrap()
550 .write_all(content.as_bytes())
551 .unwrap();
552 p
553 }
554
555 #[test]
556 fn ts_registry_basic() {
557 let tmp = TempDir::new().unwrap();
558 let path = write_ts(
559 &tmp,
560 "registry.ts",
561 r#"
562export const FIXTURES: Record<string, any> = {
563 'prime-search-history': {
564 testID: 'qa-chip-prime-search-history',
565 signal: { regex: /\[fixture\] prime-search-history: seeded (\d+) rows/, level: 'log' },
566 timeoutMs: 8000,
567 },
568};
569"#,
570 );
571 let reg = FixtureRegistry::load(&path).unwrap();
572 let f = reg.get("prime-search-history").unwrap();
573 assert_eq!(f.test_id, "qa-chip-prime-search-history");
574 assert_eq!(f.timeout_ms, 8000);
575 assert!(f.signal.regex.contains("fixture"));
576 }
577
578 #[test]
579 fn ts_registry_multi_fixtures() {
580 let tmp = TempDir::new().unwrap();
581 let path = write_ts(
582 &tmp,
583 "registry.ts",
584 r#"
585export const FIXTURES = {
586 'foo': { testID: 't-foo', signal: { regex: 'foo-ready' }, timeoutMs: 5000 },
587 'bar': { testID: 't-bar', signal: { regex: 'bar-ready' }, timeoutMs: 3000 },
588};
589"#,
590 );
591 let reg = FixtureRegistry::load(&path).unwrap();
592 assert!(reg.get("foo").is_some());
593 assert!(reg.get("bar").is_some());
594 }
595}