Skip to main content

oxihuman_core/
target_index.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{Context, Result};
5use std::path::Path;
6
7use crate::category::TargetCategory;
8use crate::parser::target::parse_target;
9
10/// A single entry in the target search index.
11pub struct TargetEntry {
12    pub name: String,
13    pub category: TargetCategory,
14    /// Optional filesystem path to the .target file.
15    pub path: Option<String>,
16    pub delta_count: usize,
17    pub tags: Vec<String>,
18}
19
20/// Searchable in-memory index of morph targets.
21#[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    /// Return all entries that belong to the given category.
44    pub fn by_category(&self, cat: &TargetCategory) -> Vec<&TargetEntry> {
45        self.entries.iter().filter(|e| &e.category == cat).collect()
46    }
47
48    /// Case-insensitive substring search on name or tags.
49    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    /// Look up an entry by exact name (case-sensitive).
61    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    /// Walk `dir`, add one `TargetEntry` per `.target` file found.
70    ///
71    /// The category is parsed from the name of the directory that directly
72    /// contains the file.  Returns the number of entries added.
73    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            // Category comes from the parent directory name.
86            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    /// Build a `TargetIndex` by scanning a directory with a [`TargetScanner`].
117    pub fn from_dir(dir: &Path) -> Result<Self> {
118        let scanner = TargetScanner::new(dir)?;
119        Ok(scanner.collect_all())
120    }
121
122    /// Return just the entry names (for use in `AssetManifest::allowed_targets`).
123    pub fn to_manifest_targets(&self) -> Vec<String> {
124        self.entries.iter().map(|e| e.name.clone()).collect()
125    }
126}
127
128// ---------------------------------------------------------------------------
129// TargetScanner — streaming directory walker
130// ---------------------------------------------------------------------------
131
132/// Streaming target scanner — walks a directory incrementally and yields entries.
133///
134/// Call [`TargetScanner::new`] to initialise the scanner (performs a shallow
135/// directory enumeration to build the pending list), then drive it with
136/// [`next_entry`][TargetScanner::next_entry] or drain everything at once with
137/// [`collect_all`][TargetScanner::collect_all].
138pub struct TargetScanner {
139    /// Files yet to be parsed (`.target` extension only).
140    pending: Vec<std::path::PathBuf>,
141    /// Count of files processed so far.
142    done: usize,
143    /// Best-guess total derived from the initial directory walk.
144    total_estimate: usize,
145}
146
147impl TargetScanner {
148    /// Create a scanner from a directory path.
149    ///
150    /// Immediately performs a recursive walk of `dir` to collect all `.target`
151    /// file paths into the pending list — no file parsing happens here.
152    /// Returns an error if `dir` does not exist.
153    pub fn new(dir: &Path) -> Result<Self> {
154        if !dir.exists() {
155            anyhow::bail!("directory does not exist: {}", dir.display());
156        }
157
158        // Collect all .target files via the internal recursive walker.
159        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    /// Number of files processed so far.
173    pub fn done(&self) -> usize {
174        self.done
175    }
176
177    /// Estimated total number of `.target` files found during initialisation.
178    pub fn total(&self) -> usize {
179        self.total_estimate
180    }
181
182    /// Progress in `[0.0, 1.0]`.  Returns `1.0` when there are no files or
183    /// when all files have been processed.
184    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    /// Returns `true` when all files have been processed.
192    pub fn is_done(&self) -> bool {
193        self.pending.is_empty()
194    }
195
196    /// Process the next pending file and return a [`TargetEntry`].
197    ///
198    /// Returns `None` when all files have been processed.  Files that fail
199    /// to parse are silently skipped (the counter is still incremented).
200    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    /// Drain all remaining entries into a [`TargetIndex`].
238    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
247// ---------------------------------------------------------------------------
248// Internal helper: a minimal recursive directory walker so we avoid pulling in
249// the `walkdir` crate as a new dependency.
250// ---------------------------------------------------------------------------
251
252fn 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(&current)
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// ---------------------------------------------------------------------------
274// Tests
275// ---------------------------------------------------------------------------
276
277#[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    // ------------------------------------------------------------------
294    // add / len / is_empty
295    // ------------------------------------------------------------------
296
297    #[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    // ------------------------------------------------------------------
316    // by_category
317    // ------------------------------------------------------------------
318
319    #[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    // ------------------------------------------------------------------
343    // search — case-insensitive, name & tags
344    // ------------------------------------------------------------------
345
346    #[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    // ------------------------------------------------------------------
423    // by_name
424    // ------------------------------------------------------------------
425
426    #[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    // ------------------------------------------------------------------
442    // to_manifest_targets
443    // ------------------------------------------------------------------
444
445    #[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    // ------------------------------------------------------------------
460    // scan_dir
461    // ------------------------------------------------------------------
462
463    /// Create a minimal .target file with `n` delta lines.
464    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        // Create sub-dirs that name the categories.
478        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    // ------------------------------------------------------------------
529    // TargetScanner tests (8 new tests)
530    // ------------------------------------------------------------------
531
532    /// Set up a temp dir with 3 .target files in subdirectories.
533    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        // The scanner is consumed; verify the index has 3 entries as a proxy.
578        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        // from_dir
623        let idx_from_dir = TargetIndex::from_dir(&tmp).expect("should succeed");
624        // scan_dir
625        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        // Verify every name found by scan_dir also appears in from_dir.
634        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    // ------------------------------------------------------------------
653    // Helper: create a unique temp directory (no extra crate needed).
654    // ------------------------------------------------------------------
655    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}