1use anyhow::{Context, Result};
5use std::path::Path;
6
7use crate::category::TargetCategory;
8use crate::parser::target::parse_target;
9
10pub struct TargetEntry {
12 pub name: String,
13 pub category: TargetCategory,
14 pub path: Option<String>,
16 pub delta_count: usize,
17 pub tags: Vec<String>,
18}
19
20#[derive(Default)]
22pub struct TargetIndex {
23 entries: Vec<TargetEntry>,
24}
25
26impl TargetIndex {
27 pub fn new() -> Self {
28 Self::default()
29 }
30
31 pub fn add(&mut self, entry: TargetEntry) {
32 self.entries.push(entry);
33 }
34
35 pub fn len(&self) -> usize {
36 self.entries.len()
37 }
38
39 pub fn is_empty(&self) -> bool {
40 self.entries.is_empty()
41 }
42
43 pub fn by_category(&self, cat: &TargetCategory) -> Vec<&TargetEntry> {
45 self.entries.iter().filter(|e| &e.category == cat).collect()
46 }
47
48 pub fn search(&self, query: &str) -> Vec<&TargetEntry> {
50 let q = query.to_lowercase();
51 self.entries
52 .iter()
53 .filter(|e| {
54 e.name.to_lowercase().contains(&q)
55 || e.tags.iter().any(|t| t.to_lowercase().contains(&q))
56 })
57 .collect()
58 }
59
60 pub fn by_name(&self, name: &str) -> Option<&TargetEntry> {
62 self.entries.iter().find(|e| e.name == name)
63 }
64
65 pub fn all(&self) -> &[TargetEntry] {
66 &self.entries
67 }
68
69 pub fn scan_dir(&mut self, dir: &Path) -> Result<usize> {
74 if !dir.exists() {
75 anyhow::bail!("directory does not exist: {}", dir.display());
76 }
77
78 let mut added = 0usize;
79 for entry in walkdir(dir)? {
80 let path = entry?;
81 if path.extension().and_then(|e| e.to_str()) != Some("target") {
82 continue;
83 }
84
85 let cat_name = path
87 .parent()
88 .and_then(|p| p.file_name())
89 .and_then(|n| n.to_str())
90 .unwrap_or("other");
91 let category = TargetCategory::from_str(cat_name);
92
93 let stem = path
94 .file_stem()
95 .and_then(|s| s.to_str())
96 .unwrap_or("unknown")
97 .to_string();
98
99 let src = std::fs::read_to_string(&path)
100 .with_context(|| format!("reading {}", path.display()))?;
101 let tf =
102 parse_target(&stem, &src).with_context(|| format!("parsing {}", path.display()))?;
103
104 self.entries.push(TargetEntry {
105 name: stem,
106 category,
107 path: Some(path.to_string_lossy().into_owned()),
108 delta_count: tf.deltas.len(),
109 tags: Vec::new(),
110 });
111 added += 1;
112 }
113 Ok(added)
114 }
115
116 pub fn from_dir(dir: &Path) -> Result<Self> {
118 let scanner = TargetScanner::new(dir)?;
119 Ok(scanner.collect_all())
120 }
121
122 pub fn to_manifest_targets(&self) -> Vec<String> {
124 self.entries.iter().map(|e| e.name.clone()).collect()
125 }
126}
127
128pub struct TargetScanner {
139 pending: Vec<std::path::PathBuf>,
141 done: usize,
143 total_estimate: usize,
145}
146
147impl TargetScanner {
148 pub fn new(dir: &Path) -> Result<Self> {
154 if !dir.exists() {
155 anyhow::bail!("directory does not exist: {}", dir.display());
156 }
157
158 let pending: Vec<std::path::PathBuf> = walkdir(dir)?
160 .filter_map(|r| r.ok())
161 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("target"))
162 .collect();
163
164 let total_estimate = pending.len();
165 Ok(Self {
166 pending,
167 done: 0,
168 total_estimate,
169 })
170 }
171
172 pub fn done(&self) -> usize {
174 self.done
175 }
176
177 pub fn total(&self) -> usize {
179 self.total_estimate
180 }
181
182 pub fn progress(&self) -> f32 {
185 if self.total_estimate == 0 {
186 return 1.0;
187 }
188 self.done as f32 / self.total_estimate as f32
189 }
190
191 pub fn is_done(&self) -> bool {
193 self.pending.is_empty()
194 }
195
196 pub fn next_entry(&mut self) -> Option<TargetEntry> {
201 loop {
202 let path = self.pending.pop()?;
203 self.done += 1;
204
205 let cat_name = path
206 .parent()
207 .and_then(|p| p.file_name())
208 .and_then(|n| n.to_str())
209 .unwrap_or("other");
210 let category = TargetCategory::from_str(cat_name);
211
212 let stem = path
213 .file_stem()
214 .and_then(|s| s.to_str())
215 .unwrap_or("unknown")
216 .to_string();
217
218 let src = match std::fs::read_to_string(&path) {
219 Ok(s) => s,
220 Err(_) => continue,
221 };
222 let tf = match parse_target(&stem, &src) {
223 Ok(t) => t,
224 Err(_) => continue,
225 };
226
227 return Some(TargetEntry {
228 name: stem,
229 category,
230 path: Some(path.to_string_lossy().into_owned()),
231 delta_count: tf.deltas.len(),
232 tags: Vec::new(),
233 });
234 }
235 }
236
237 pub fn collect_all(mut self) -> TargetIndex {
239 let mut idx = TargetIndex::new();
240 while let Some(entry) = self.next_entry() {
241 idx.add(entry);
242 }
243 idx
244 }
245}
246
247fn walkdir(dir: &Path) -> Result<impl Iterator<Item = Result<std::path::PathBuf>>> {
253 let mut stack: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
254 let mut files: Vec<std::path::PathBuf> = Vec::new();
255
256 while let Some(current) = stack.pop() {
257 for entry in std::fs::read_dir(¤t)
258 .with_context(|| format!("reading dir {}", current.display()))?
259 {
260 let entry = entry.with_context(|| format!("dir entry in {}", current.display()))?;
261 let path = entry.path();
262 if path.is_dir() {
263 stack.push(path);
264 } else {
265 files.push(path);
266 }
267 }
268 }
269
270 Ok(files.into_iter().map(Ok))
271}
272
273#[cfg(test)]
278mod tests {
279 use super::*;
280 use std::fs;
281 use std::io::Write;
282
283 fn make_entry(name: &str, cat: TargetCategory, tags: Vec<&str>) -> TargetEntry {
284 TargetEntry {
285 name: name.to_string(),
286 category: cat,
287 path: None,
288 delta_count: 0,
289 tags: tags.into_iter().map(|s| s.to_string()).collect(),
290 }
291 }
292
293 #[test]
298 fn new_index_is_empty() {
299 let idx = TargetIndex::new();
300 assert!(idx.is_empty());
301 assert_eq!(idx.len(), 0);
302 }
303
304 #[test]
305 fn add_increases_len() {
306 let mut idx = TargetIndex::new();
307 assert!(idx.is_empty());
308 idx.add(make_entry("foo", TargetCategory::Height, vec![]));
309 assert!(!idx.is_empty());
310 assert_eq!(idx.len(), 1);
311 idx.add(make_entry("bar", TargetCategory::Weight, vec![]));
312 assert_eq!(idx.len(), 2);
313 }
314
315 #[test]
320 fn by_category_returns_correct_subset() {
321 let mut idx = TargetIndex::new();
322 idx.add(make_entry("h1", TargetCategory::Height, vec![]));
323 idx.add(make_entry("h2", TargetCategory::Height, vec![]));
324 idx.add(make_entry("w1", TargetCategory::Weight, vec![]));
325
326 let heights = idx.by_category(&TargetCategory::Height);
327 assert_eq!(heights.len(), 2);
328 assert!(heights.iter().all(|e| e.category == TargetCategory::Height));
329
330 let weights = idx.by_category(&TargetCategory::Weight);
331 assert_eq!(weights.len(), 1);
332 }
333
334 #[test]
335 fn by_category_no_match_returns_empty() {
336 let mut idx = TargetIndex::new();
337 idx.add(make_entry("h1", TargetCategory::Height, vec![]));
338 let muscles = idx.by_category(&TargetCategory::Muscle);
339 assert!(muscles.is_empty());
340 }
341
342 #[test]
347 fn search_is_case_insensitive() {
348 let mut idx = TargetIndex::new();
349 idx.add(make_entry("FaceSmile", TargetCategory::Expression, vec![]));
350
351 assert_eq!(idx.search("facesmile").len(), 1);
352 assert_eq!(idx.search("FACESMILE").len(), 1);
353 assert_eq!(idx.search("FaceSmile").len(), 1);
354 }
355
356 #[test]
357 fn search_matches_name_prefix() {
358 let mut idx = TargetIndex::new();
359 idx.add(make_entry("height-up", TargetCategory::Height, vec![]));
360 idx.add(make_entry("height-down", TargetCategory::Height, vec![]));
361 idx.add(make_entry("weight-high", TargetCategory::Weight, vec![]));
362
363 let res = idx.search("height");
364 assert_eq!(res.len(), 2);
365 }
366
367 #[test]
368 fn search_matches_name_substring() {
369 let mut idx = TargetIndex::new();
370 idx.add(make_entry(
371 "brow-inner-up",
372 TargetCategory::Eyebrows,
373 vec![],
374 ));
375 idx.add(make_entry(
376 "brow-outer-up",
377 TargetCategory::Eyebrows,
378 vec![],
379 ));
380 idx.add(make_entry("chin-round", TargetCategory::Chin, vec![]));
381
382 let res = idx.search("inner");
383 assert_eq!(res.len(), 1);
384 assert_eq!(res[0].name, "brow-inner-up");
385 }
386
387 #[test]
388 fn search_matches_tags() {
389 let mut idx = TargetIndex::new();
390 idx.add(make_entry(
391 "arm-long",
392 TargetCategory::ArmsLegs,
393 vec!["elongation", "limb"],
394 ));
395 idx.add(make_entry(
396 "leg-long",
397 TargetCategory::ArmsLegs,
398 vec!["elongation", "limb"],
399 ));
400 idx.add(make_entry("chin-sharp", TargetCategory::Chin, vec!["face"]));
401
402 let res = idx.search("elongation");
403 assert_eq!(res.len(), 2);
404
405 let res2 = idx.search("face");
406 assert_eq!(res2.len(), 1);
407 }
408
409 #[test]
410 fn search_empty_index_returns_empty() {
411 let idx = TargetIndex::new();
412 assert!(idx.search("anything").is_empty());
413 }
414
415 #[test]
416 fn search_no_match_returns_empty() {
417 let mut idx = TargetIndex::new();
418 idx.add(make_entry("height-up", TargetCategory::Height, vec![]));
419 assert!(idx.search("zzznomatch").is_empty());
420 }
421
422 #[test]
427 fn by_name_found() {
428 let mut idx = TargetIndex::new();
429 idx.add(make_entry("chin-round", TargetCategory::Chin, vec![]));
430 let e = idx.by_name("chin-round");
431 assert!(e.is_some());
432 assert_eq!(e.expect("should succeed").name, "chin-round");
433 }
434
435 #[test]
436 fn by_name_not_found() {
437 let idx = TargetIndex::new();
438 assert!(idx.by_name("nonexistent").is_none());
439 }
440
441 #[test]
446 fn to_manifest_targets_returns_all_names() {
447 let mut idx = TargetIndex::new();
448 idx.add(make_entry("a", TargetCategory::Height, vec![]));
449 idx.add(make_entry("b", TargetCategory::Weight, vec![]));
450 idx.add(make_entry("c", TargetCategory::Muscle, vec![]));
451
452 let names = idx.to_manifest_targets();
453 assert_eq!(names.len(), 3);
454 assert!(names.contains(&"a".to_string()));
455 assert!(names.contains(&"b".to_string()));
456 assert!(names.contains(&"c".to_string()));
457 }
458
459 fn write_target_file(path: &std::path::Path, n: usize) {
465 let mut out = String::new();
466 for i in 0..n {
467 out.push_str(&format!("{} 0.1 0.2 0.3\n", i));
468 }
469 let mut f = fs::File::create(path).expect("failed to create target file");
470 f.write_all(out.as_bytes())
471 .expect("failed to write target file");
472 }
473
474 #[test]
475 fn scan_dir_finds_three_target_files() {
476 let tmp = tempdir();
477 let height_dir = tmp.join("height");
479 let weight_dir = tmp.join("weight");
480 fs::create_dir_all(&height_dir).expect("should succeed");
481 fs::create_dir_all(&weight_dir).expect("should succeed");
482
483 write_target_file(&height_dir.join("height-up.target"), 5);
484 write_target_file(&height_dir.join("height-down.target"), 3);
485 write_target_file(&weight_dir.join("weight-high.target"), 7);
486
487 let mut idx = TargetIndex::new();
488 let added = idx.scan_dir(&tmp).expect("should succeed");
489 assert_eq!(added, 3);
490 assert_eq!(idx.len(), 3);
491 }
492
493 #[test]
494 fn scan_dir_parses_category_from_dir_name() {
495 let tmp = tempdir();
496 let age_dir = tmp.join("age");
497 fs::create_dir_all(&age_dir).expect("should succeed");
498 write_target_file(&age_dir.join("young.target"), 2);
499
500 let mut idx = TargetIndex::new();
501 idx.scan_dir(&tmp).expect("should succeed");
502
503 let entry = idx.by_name("young").expect("should succeed");
504 assert_eq!(entry.category, TargetCategory::Age);
505 }
506
507 #[test]
508 fn scan_dir_counts_deltas_correctly() {
509 let tmp = tempdir();
510 let dir = tmp.join("height");
511 fs::create_dir_all(&dir).expect("should succeed");
512 write_target_file(&dir.join("test.target"), 8);
513
514 let mut idx = TargetIndex::new();
515 idx.scan_dir(&tmp).expect("should succeed");
516
517 let entry = idx.by_name("test").expect("should succeed");
518 assert_eq!(entry.delta_count, 8);
519 }
520
521 #[test]
522 fn scan_dir_nonexistent_returns_error() {
523 let mut idx = TargetIndex::new();
524 let result = idx.scan_dir(std::path::Path::new("/tmp/this_does_not_exist_oxihuman"));
525 assert!(result.is_err());
526 }
527
528 fn setup_scanner_dir() -> std::path::PathBuf {
534 let tmp = tempdir();
535 let height_dir = tmp.join("height");
536 let weight_dir = tmp.join("weight");
537 fs::create_dir_all(&height_dir).expect("failed to create height dir");
538 fs::create_dir_all(&weight_dir).expect("failed to create weight dir");
539 write_target_file(&height_dir.join("height-up.target"), 4);
540 write_target_file(&height_dir.join("height-down.target"), 2);
541 write_target_file(&weight_dir.join("weight-high.target"), 6);
542 tmp
543 }
544
545 #[test]
546 fn scanner_new_on_valid_dir_succeeds() {
547 let tmp = setup_scanner_dir();
548 let scanner = TargetScanner::new(&tmp);
549 assert!(
550 scanner.is_ok(),
551 "TargetScanner::new should succeed on valid dir"
552 );
553 }
554
555 #[test]
556 fn scanner_total_returns_three() {
557 let tmp = setup_scanner_dir();
558 let scanner = TargetScanner::new(&tmp).expect("should succeed");
559 assert_eq!(scanner.total(), 3, "total() should report 3 .target files");
560 }
561
562 #[test]
563 fn scanner_progress_is_zero_initially() {
564 let tmp = setup_scanner_dir();
565 let scanner = TargetScanner::new(&tmp).expect("should succeed");
566 assert!(
567 (scanner.progress() - 0.0).abs() < f32::EPSILON,
568 "progress() should be 0.0 before any processing"
569 );
570 }
571
572 #[test]
573 fn scanner_progress_is_one_after_collect_all() {
574 let tmp = setup_scanner_dir();
575 let scanner = TargetScanner::new(&tmp).expect("should succeed");
576 let idx = scanner.collect_all();
577 assert_eq!(idx.len(), 3);
579 }
580
581 #[test]
582 fn scanner_next_entry_yields_some_then_none() {
583 let tmp = setup_scanner_dir();
584 let mut scanner = TargetScanner::new(&tmp).expect("should succeed");
585 let mut count = 0usize;
586 while scanner.next_entry().is_some() {
587 count += 1;
588 }
589 assert_eq!(count, 3, "next_entry() should yield exactly 3 entries");
590 assert!(
591 scanner.next_entry().is_none(),
592 "next_entry() should return None after all files processed"
593 );
594 }
595
596 #[test]
597 fn scanner_collect_all_returns_index_with_three_entries() {
598 let tmp = setup_scanner_dir();
599 let scanner = TargetScanner::new(&tmp).expect("should succeed");
600 let idx = scanner.collect_all();
601 assert_eq!(
602 idx.len(),
603 3,
604 "collect_all() should produce index with 3 entries"
605 );
606 }
607
608 #[test]
609 fn scanner_is_done_after_collect_all_via_next_entry() {
610 let tmp = setup_scanner_dir();
611 let mut scanner = TargetScanner::new(&tmp).expect("should succeed");
612 while scanner.next_entry().is_some() {}
613 assert!(
614 scanner.is_done(),
615 "is_done() should be true after all entries consumed"
616 );
617 }
618
619 #[test]
620 fn target_index_from_dir_matches_scan_dir() {
621 let tmp = setup_scanner_dir();
622 let idx_from_dir = TargetIndex::from_dir(&tmp).expect("should succeed");
624 let mut idx_scan = TargetIndex::new();
626 idx_scan.scan_dir(&tmp).expect("should succeed");
627
628 assert_eq!(
629 idx_from_dir.len(),
630 idx_scan.len(),
631 "from_dir and scan_dir should find the same number of entries"
632 );
633 for entry in idx_scan.all() {
635 assert!(
636 idx_from_dir.by_name(&entry.name).is_some(),
637 "from_dir should contain entry '{}'",
638 entry.name
639 );
640 }
641 }
642
643 #[test]
644 fn scanner_new_on_nonexistent_dir_returns_err() {
645 let result = TargetScanner::new(std::path::Path::new("/tmp/no_such_dir_oxihuman_scanner"));
646 assert!(
647 result.is_err(),
648 "TargetScanner::new should error on missing dir"
649 );
650 }
651
652 fn tempdir() -> std::path::PathBuf {
656 use std::sync::atomic::{AtomicU64, Ordering};
657 use std::time::{SystemTime, UNIX_EPOCH};
658 static COUNTER: AtomicU64 = AtomicU64::new(0);
659 let nanos = SystemTime::now()
660 .duration_since(UNIX_EPOCH)
661 .map(|d| d.as_nanos())
662 .unwrap_or(0);
663 let pid = std::process::id();
664 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
665 let path = std::path::PathBuf::from(format!(
666 "/tmp/oxihuman_target_index_test_{}_{}_{}",
667 nanos, pid, seq
668 ));
669 fs::create_dir_all(&path).expect("failed to create temp dir");
670 path
671 }
672}