Skip to main content

fixture_tree/
lib.rs

1#![allow(clippy::needless_doctest_main)]
2//! # fixture-tree
3//!
4//! Generate Rust source code that mirrors a filesystem directory as a module tree,
5//! providing zero-cost accessors for file paths and contents at compile time.
6//!
7//! `fixture-tree` is intended for use from **build scripts** (`build.rs`). It walks a
8//! directory of fixture files (test data, configs, model weights, etc.) and emits a
9//! `.rs` file where every directory becomes a `mod` and every file becomes a function
10//! returning its path and, optionally, its contents via `include_str!` or
11//! `include_bytes!`.
12//!
13//! This can be particularly useful if you find yourself using a lot of static files for testing
14//! specific use-cases. This allows you to use code linting to easily traverse your fixture
15//! file structure and will result in a compilation error if you mix around file paths.
16//!
17//! ## Quick start
18//!
19//! ```rust,no_run
20//! // build.rs
21//! fn main() {
22//!     fixture_tree::Config::new()
23//!         .from_path("fixtures")           // relative to CARGO_MANIFEST_DIR
24//!         .with_ext("json")                // only include .json files
25//!         .with_ext_as_string("json")       // embed contents via include_str!
26//!         .build()
27//!         .unwrap()
28//!         .generate_fixtures()
29//!         .unwrap();
30//! }
31//! ```
32//!
33//! Then in your library or test code:
34//!
35//! ```rust,ignore
36//! include!(concat!(env!("OUT_DIR"), "/fixture_tree_autogen.rs"));
37//!
38//! #[test]
39//! fn read_fixture() {
40//!     let (path, contents) = configs::pass::basic();
41//!     assert!(path.exists());
42//!     assert!(contents.contains("ok"));
43//! }
44//! ```
45//!
46//! ## Path handling
47//!
48//! When the source directory is under `CARGO_MANIFEST_DIR` (the typical case),
49//! generated paths use `concat!(env!("CARGO_MANIFEST_DIR"), "/...")` so they
50//! resolve correctly on any machine. If the source directory is outside the
51//! manifest (e.g. a system temp directory), absolute paths are emitted instead.
52//!
53//! ## Filtering
54//!
55//! Files can be filtered in two ways:
56//!
57//! - **By extension** — [`Config::with_ext`] / [`Config::with_exts`] restrict
58//!   which file extensions are walked. An empty list means "all extensions".
59//! - **By regex** *(requires the `regex` feature)* -
60//!   [`Config::with_allow_pattern`] / [`Config::with_allow_patterns`]
61//!   match against the file's path relative to the source root. When at least one
62//!   regex is configured a file must match *any* of them to be included.
63//!
64//! Entire subtrees can be excluded with [`Config::without_path`] /
65//! [`Config::without_paths`], which compare against the directory's relative path.
66//!
67//! ## Generated code shape
68//!
69//! For each directory a `path()` function is emitted. For each matched file a
70//! function named after the file stem (lowercased, dashes replaced with
71//! underscores) is emitted. Files registered as string extensions return
72//! `(&'static Path, &'static str)`, binary extensions return
73//! `(&'static Path, &'static [u8])` and files not registered as binary or strings
74//! just return a `&'static Path`.
75//! Empty directories are pruned from the
76//! output.
77
78use std::collections::BTreeMap;
79use std::fmt;
80use std::fmt::Write as _;
81use std::fs;
82use std::path::{Path, PathBuf};
83
84/// Errors that can occur when building or generating a [`FixtureTree`].
85#[derive(Debug)]
86pub enum Error {
87    /// An extension was registered as both string and binary.
88    ExtConflict(String),
89    /// A path could not be made relative to the source root.
90    StripPrefix(std::path::StripPrefixError),
91    /// An I/O error occurred while reading the source directory or writing the
92    /// output file.
93    Io(std::io::Error),
94}
95
96impl fmt::Display for Error {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        match self {
99            Error::ExtConflict(ext) => write!(
100                f,
101                "'{}' cannot be registered as both string and binary",
102                ext
103            ),
104            Error::StripPrefix(e) => write!(f, "failed to compute relative path: {}", e),
105            Error::Io(e) => write!(f, "I/O error: {}", e),
106        }
107    }
108}
109
110impl std::error::Error for Error {
111    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
112        match self {
113            Error::ExtConflict(_) => None,
114            Error::StripPrefix(e) => Some(e),
115            Error::Io(e) => Some(e),
116        }
117    }
118}
119
120impl From<std::path::StripPrefixError> for Error {
121    fn from(e: std::path::StripPrefixError) -> Self {
122        Error::StripPrefix(e)
123    }
124}
125
126impl From<std::io::Error> for Error {
127    fn from(e: std::io::Error) -> Self {
128        Error::Io(e)
129    }
130}
131
132/// A specialised [`Result`] type for fixture-tree operations.
133pub type Result<T> = std::result::Result<T, Error>;
134
135/// A discovered fixture file.
136///
137/// Holds the generated function name, the resolved filesystem path, and the
138/// lowercased file extension.
139#[derive(Debug)]
140pub struct Fixture {
141    fn_name: String,
142    path: PathBuf,
143    ext: String,
144}
145
146/// Internal representation of the source directory.
147///
148/// If the directory is under `CARGO_MANIFEST_DIR` it stores a relative path
149/// and sets `rel_to_manifest = true` so that generated code uses
150/// `concat!(env!("CARGO_MANIFEST_DIR"), "...")` rather than absolute paths.
151#[derive(Debug)]
152struct SourceDirectory {
153    p: PathBuf,
154    rel_to_manifest: bool,
155}
156
157impl SourceDirectory {
158    fn new(p: impl Into<PathBuf>) -> Self {
159        let p = p.into();
160        let mut p = p
161            .canonicalize()
162            .expect("could not make source path absolute");
163        let rel_to_manifest = match std::env::var("CARGO_MANIFEST_DIR") {
164            Ok(c) => match p.strip_prefix(&c) {
165                Ok(stripped) => {
166                    p = stripped.to_path_buf();
167                    true
168                }
169                _ => false,
170            },
171            _ => false,
172        };
173        Self { p, rel_to_manifest }
174    }
175
176    fn is_rel(&self) -> bool {
177        self.rel_to_manifest
178    }
179
180    fn path(&self) -> &Path {
181        &self.p
182    }
183}
184
185impl Default for SourceDirectory {
186    fn default() -> Self {
187        let cur = std::env::current_dir().expect("could not find the current directory");
188        Self::new(cur)
189    }
190}
191
192/// Builder for configuring a [`FixtureTree`].
193///
194/// Construct with [`Config::new`], chain builder methods to set options, and
195/// finalise with [`Config::build`].
196///
197/// # Examples
198///
199/// ```rust,no_run
200/// let tree = fixture_tree::Config::new()
201///     .from_path("test_data")
202///     .with_exts(["json", "toml"])
203///     .with_exts_as_string(["json", "toml"])
204///     .without_path("scratch")
205///     .build()
206///     .unwrap();
207/// tree.generate_fixtures().unwrap();
208/// ```
209#[derive(Debug)]
210pub struct Config {
211    #[cfg(feature = "regex")]
212    pub(crate) allow_regexs: Vec<regex::Regex>,
213    pub(crate) allow_exts: Vec<String>,
214    pub(crate) ignore_paths: Vec<PathBuf>,
215    pub(crate) source: SourceDirectory,
216    pub(crate) include_ext_as_str: Vec<String>,
217    pub(crate) include_ext_as_bin: Vec<String>,
218    pub(crate) out_path: PathBuf,
219}
220
221impl Default for Config {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl Config {
228    /// Create a new config with default settings.
229    ///
230    /// Defaults:
231    /// - **source directory**: current working directory
232    /// - **output path**: `$OUT_DIR/fixture_tree_autogen.rs` (falls back to cwd
233    ///   when `OUT_DIR` is not set)
234    /// - **filters**: none (all files included)
235    pub fn new() -> Self {
236        let out_path = std::env::var("OUT_DIR")
237            .map(PathBuf::from)
238            .unwrap_or(std::env::current_dir().unwrap());
239        let out_path = out_path.join("fixture_tree_autogen.rs");
240
241        Self {
242            #[cfg(feature = "regex")]
243            allow_regexs: vec![],
244            allow_exts: vec![],
245            ignore_paths: vec![],
246            include_ext_as_bin: vec![],
247            include_ext_as_str: vec![],
248            source: Default::default(),
249            out_path,
250        }
251    }
252
253    /// Only include files whose extension matches `ext` (case-insensitive).
254    ///
255    /// Can be called multiple times to allow several extensions. If no
256    /// extension filter is set, all files are included.
257    pub fn with_ext(mut self, ext: impl Into<String>) -> Self {
258        self.allow_exts.push(ext.into());
259        self
260    }
261
262    /// Batch version of [`Config::with_ext`].
263    ///
264    /// ```rust,no_run
265    /// # let config = fixture_tree::Config::new();
266    /// config.with_exts(["json", "toml", "yaml"]);
267    /// ```
268    pub fn with_exts(mut self, exts: impl IntoIterator<Item = impl Into<String>>) -> Self {
269        self.allow_exts.extend(exts.into_iter().map(|x| x.into()));
270        self
271    }
272
273    /// Exclude an entire directory subtree by its path relative to the source
274    /// root.
275    ///
276    /// ```rust,no_run
277    /// # let config = fixture_tree::Config::new();
278    /// config.without_path("snapshots");
279    /// ```
280    pub fn without_path(mut self, p: impl Into<PathBuf>) -> Self {
281        self.ignore_paths.push(p.into());
282        self
283    }
284
285    /// Batch version of [`Config::without_path`].
286    pub fn without_paths(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
287        self.ignore_paths
288            .extend(paths.into_iter().map(|x| x.into()));
289        self
290    }
291
292    /// Only include files whose relative path matches the given regex.
293    ///
294    /// Multiple patterns are combined with OR semantics — a file is included if
295    /// it matches *any* pattern. The pattern is matched against the file's path
296    /// relative to the source root (e.g. `"configs/pass/basic.json"`).
297    ///
298    /// Requires the `regex` feature.
299    ///
300    /// # Panics
301    ///
302    /// Panics if the pattern string is not a valid regex.
303    #[cfg(feature = "regex")]
304    pub fn with_allow_pattern(mut self, pat: impl Into<String>) -> Self {
305        self.allow_regexs
306            .push(regex::Regex::new(&pat.into()).expect("could not create regex"));
307        self
308    }
309
310    /// Batch version of [`Config::with_allow_pattern`].
311    ///
312    /// Requires the `regex` feature.
313    ///
314    /// # Panics
315    ///
316    /// Panics if any pattern string is not a valid regex.
317    #[cfg(feature = "regex")]
318    pub fn with_allow_patterns(
319        mut self,
320        pats: impl IntoIterator<Item = impl Into<String>>,
321    ) -> Self {
322        let pats = pats
323            .into_iter()
324            .map(|x| regex::Regex::new(&x.into()).expect("could not create regex"));
325        self.allow_regexs.extend(pats);
326        self
327    }
328
329    /// Set the root directory to scan for fixture files.
330    ///
331    /// The path is canonicalised and, if it falls under `CARGO_MANIFEST_DIR`,
332    /// generated code will reference it relative to the manifest via
333    /// `env!("CARGO_MANIFEST_DIR")`.
334    ///
335    /// # Panics
336    ///
337    /// Panics if the path does not exist or cannot be canonicalised.
338    pub fn from_path(mut self, p: impl Into<PathBuf>) -> Self {
339        let source = SourceDirectory::new(p);
340        self.source = source;
341        self
342    }
343
344    /// Register a file extension whose contents should be embedded as a
345    /// `&'static str` via `include_str!`.
346    ///
347    /// The generated accessor for matching files returns
348    /// `(&'static Path, &'static str)`.
349    pub fn with_ext_as_string(mut self, ext: impl Into<String>) -> Self {
350        self.include_ext_as_str.push(ext.into());
351        self
352    }
353
354    /// Batch version of [`Config::with_ext_as_string`].
355    pub fn with_exts_as_string(
356        mut self,
357        exts: impl IntoIterator<Item = impl Into<String>>,
358    ) -> Self {
359        self.include_ext_as_str
360            .extend(exts.into_iter().map(|x| x.into()));
361        self
362    }
363
364    /// Register a file extension whose contents should be embedded as a
365    /// `&'static [u8]` via `include_bytes!`.
366    ///
367    /// The generated accessor for matching files returns
368    /// `(&'static Path, &'static [u8])`.
369    pub fn with_ext_as_bin(mut self, ext: impl Into<String>) -> Self {
370        self.include_ext_as_bin.push(ext.into());
371        self
372    }
373
374    /// Batch version of [`Config::with_ext_as_bin`].
375    pub fn with_exts_as_bin(mut self, exts: impl IntoIterator<Item = impl Into<String>>) -> Self {
376        self.include_ext_as_bin
377            .extend(exts.into_iter().map(|x| x.into()));
378        self
379    }
380
381    /// Override the path where the generated Rust source file is written.
382    ///
383    /// Defaults to `$OUT_DIR/fixture_tree_autogen.rs`.
384    pub fn with_output_path(mut self, p: impl Into<PathBuf>) -> Self {
385        self.out_path = p.into();
386        self
387    }
388
389    /// Validate the configuration and build a [`FixtureTree`].
390    ///
391    /// # Errors
392    ///
393    /// Returns an error if:
394    /// - An extension is registered as both string and binary.
395    /// - The source directory cannot be read.
396    pub fn build(self) -> Result<FixtureTree> {
397        for ext in &self.include_ext_as_str {
398            if self.include_ext_as_bin.contains(ext) {
399                return Err(Error::ExtConflict(ext.clone()));
400            }
401        }
402        FixtureTree::new(self)
403    }
404}
405
406/// In-memory representation of a scanned directory.
407///
408/// Each `Directory` holds its child directories (as nested `Directory` values)
409/// and the [`Fixture`] files found at this level. Used internally to build
410/// the module tree before code generation.
411#[derive(Debug)]
412pub struct Directory {
413    directories: BTreeMap<String, Directory>,
414    fixtures: Vec<Fixture>,
415    path: PathBuf,
416}
417
418impl Directory {
419    /// Scan a directory and build the tree according to `config`.
420    pub fn from_path(p: &Path, config: &Config) -> Result<Self> {
421        let mut root = Self {
422            directories: BTreeMap::new(),
423            fixtures: Vec::new(),
424            path: p.to_path_buf(),
425        };
426        root.from_path_inner(p, config)?;
427        Ok(root)
428    }
429
430    #[allow(clippy::wrong_self_convention)]
431    fn from_path_inner(&mut self, p: &Path, config: &Config) -> Result<()> {
432        if let Ok(entries) = fs::read_dir(p) {
433            for entry in entries.flatten() {
434                let path = entry.path();
435                let relpath = path.strip_prefix(config.source.path())?.to_path_buf();
436                let ext = path
437                    .extension()
438                    .map(|s| s.to_string_lossy().to_string().to_lowercase());
439
440                if path.is_dir() && !config.ignore_paths.contains(&relpath) {
441                    // Get directory name for module
442                    let dirname = path.file_name().unwrap().to_str().unwrap().to_string();
443                    // Create or get the subtree for this directory
444                    let subtree = self.directories.entry(dirname.clone()).or_insert(Self {
445                        directories: BTreeMap::new(),
446                        fixtures: Vec::new(),
447                        path: p.join(&dirname),
448                    });
449                    subtree.from_path_inner(&path, config)?;
450                } else if ext.as_ref().is_some_and(|e| {
451                    if config.allow_exts.is_empty() {
452                        true
453                    } else {
454                        config.allow_exts.contains(e)
455                    }
456                }) {
457                    // If regex filters are configured, the relative path must
458                    // match at least one of them to be included.
459                    #[cfg(feature = "regex")]
460                    if !config.allow_regexs.is_empty() {
461                        let relpath_str = relpath.to_string_lossy();
462                        if !config.allow_regexs.iter().any(|r| r.is_match(&relpath_str)) {
463                            continue;
464                        }
465                    }
466
467                    // Generate function name from filename
468                    let fn_name = path
469                        .file_stem()
470                        .unwrap()
471                        .to_str()
472                        .unwrap()
473                        .replace('-', "_")
474                        .to_lowercase();
475
476                    self.fixtures.push(Fixture {
477                        fn_name,
478                        path,
479                        ext: ext.unwrap(),
480                    });
481                }
482            }
483        }
484
485        Ok(())
486    }
487
488    /// Render the full generated Rust source for this directory tree.
489    pub fn generate_code(&self, config: &Config) -> String {
490        let mut buffer = String::from("// fixture-tree auto-generated fixture accessors\n\n");
491        self.generate_code_inner(config, &mut buffer, 0);
492        buffer
493    }
494
495    fn generate_code_inner(&self, config: &Config, buffer: &mut String, indent_level: usize) {
496        let indent = "    ".repeat(indent_level);
497
498        let path = self.path.to_string_lossy().replace('\\', "/");
499
500        if config.source.is_rel() {
501            rel_mod_path(buffer, &path, &indent);
502        } else {
503            non_rel_mod_path(buffer, &path, &indent);
504        }
505
506        // Generate functions for fixtures at this level
507        for f in &self.fixtures {
508            let path = f.path.to_string_lossy().replace('\\', "/");
509
510            if config.include_ext_as_str.contains(&f.ext) {
511                if config.source.is_rel() {
512                    rel_as_string_file(buffer, &f.fn_name, &path, &indent);
513                } else {
514                    non_rel_as_string_file(buffer, &f.fn_name, &path, &indent);
515                }
516            } else if config.include_ext_as_bin.contains(&f.ext) {
517                if config.source.is_rel() {
518                    rel_as_bin_file(buffer, &f.fn_name, &path, &indent);
519                } else {
520                    non_rel_as_bin_file(buffer, &f.fn_name, &path, &indent);
521                }
522            } else if config.source.is_rel() {
523                rel_file_path(buffer, &f.fn_name, &path, &indent);
524            } else {
525                non_rel_file_path(buffer, &f.fn_name, &path, &indent);
526            }
527        }
528
529        // Generate nested modules
530        for (module_name, subtree) in &self.directories {
531            if subtree.is_empty() {
532                continue;
533            }
534            buffer.push_str(&format!("{}pub mod {} {{\n\n", indent, module_name));
535            subtree.generate_code_inner(config, buffer, indent_level + 1);
536            buffer.push_str(&format!("{}}}\n\n", indent));
537        }
538    }
539
540    /// Returns `true` when this directory (and all descendants) contain no
541    /// fixtures. Used to prune empty modules from the generated output.
542    pub fn is_empty(&self) -> bool {
543        self.fixtures.is_empty() && self.directories.values().all(|d| d.is_empty())
544    }
545}
546
547/// The top-level handle returned by [`Config::build`].
548///
549/// Call [`FixtureTree::generate_fixtures`] to write the generated Rust source
550/// file to disk.
551#[derive(Debug)]
552pub struct FixtureTree {
553    root: Directory,
554    config: Config,
555}
556
557impl FixtureTree {
558    /// Create a new `FixtureTree` by scanning the configured source directory.
559    pub fn new(config: Config) -> Result<Self> {
560        let root = Directory::from_path(config.source.path(), &config)?;
561        Ok(Self { root, config })
562    }
563
564    /// Generate the Rust source file and write it to the configured output
565    /// path.
566    ///
567    /// # Errors
568    ///
569    /// Returns an error if the output file cannot be written.
570    pub fn generate_fixtures(&self) -> Result<()> {
571        let fixtures = self.root.generate_code(&self.config);
572        fs::write(&self.config.out_path, fixtures)?;
573        Ok(())
574    }
575}
576
577fn non_rel_mod_path(buffer: &mut String, path: &str, indent: &str) {
578    write!(
579        buffer,
580        r#"{indent}pub fn path() -> &'static std::path::Path {{
581{indent}    std::path::Path::new("{path}")
582{indent}}}
583
584"#
585    )
586    .unwrap();
587}
588
589fn rel_mod_path(buffer: &mut String, path: &str, indent: &str) {
590    write!(
591        buffer,
592        "{indent}pub fn path() -> &'static std::path::Path {{\n\
593{indent}    std::path::Path::new(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/{path}\"))\n\
594{indent}}}\n\n"
595    )
596    .unwrap();
597}
598
599fn non_rel_file_path(buffer: &mut String, fn_name: &str, path: &str, indent: &str) {
600    write!(
601        buffer,
602        r#"{indent}pub fn {fn_name}() -> &'static std::path::Path {{
603{indent}    std::path::Path::new("{path}")
604{indent}}}
605
606"#
607    )
608    .unwrap();
609}
610
611fn rel_file_path(buffer: &mut String, fn_name: &str, path: &str, indent: &str) {
612    write!(
613        buffer,
614        r#"{indent}pub fn {fn_name}() -> &'static std::path::Path {{
615{indent}    std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/{path}"))
616{indent}}}
617
618"#
619    )
620    .unwrap();
621}
622
623fn rel_as_string_file(buffer: &mut String, fn_name: &str, path: &str, indent: &str) {
624    write!(buffer,
625r#"{indent}pub fn {fn_name}() -> (&'static std::path::Path, &'static str) {{
626{indent}    (std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/{path}")), include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/{path}")))
627{indent}}}
628
629"#
630    ).unwrap();
631}
632
633fn non_rel_as_string_file(buffer: &mut String, fn_name: &str, path: &str, indent: &str) {
634    write!(
635        buffer,
636        r#"{indent}pub fn {fn_name}() -> (&'static std::path::Path, &'static str) {{
637{indent}    (std::path::Path::new("{path}"), include_str!("{path}"))
638{indent}}}
639
640"#
641    )
642    .unwrap();
643}
644
645fn rel_as_bin_file(buffer: &mut String, fn_name: &str, path: &str, indent: &str) {
646    write!(buffer,
647r#"{indent}pub fn {fn_name}() -> (&'static std::path::Path, &'static [u8]) {{
648{indent}    (std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/{path}")), include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/{path}")))
649{indent}}}
650
651"#
652    ).unwrap();
653}
654
655fn non_rel_as_bin_file(buffer: &mut String, fn_name: &str, path: &str, indent: &str) {
656    write!(
657        buffer,
658        r#"{indent}pub fn {fn_name}() -> (&'static std::path::Path, &'static [u8]) {{
659{indent}    (std::path::Path::new("{path}"), include_bytes!("{path}"))
660{indent}}}
661
662"#
663    )
664    .unwrap();
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use std::path::PathBuf;
671    use tempfile::TempDir;
672
673    /// Lay out a sufficiently complex fixture tree under `root`:
674    fn create_fixture_tree(root: &Path) {
675        let dirs = [
676            "",
677            "models",
678            "configs",
679            "configs/pass",
680            "configs/fail",
681            "ignored",
682        ];
683        for d in &dirs {
684            fs::create_dir_all(root.join(d)).unwrap();
685        }
686
687        let text_files = [
688            ("alpha.json", r#"{"a": 1}"#),
689            ("beta.json", r#"{"b": 2}"#),
690            ("delta.txt", "plain text"),
691            ("models/linear.json", r#"{"type": "linear"}"#),
692            ("models/conv.json", r#"{"type": "conv"}"#),
693            ("configs/pass/basic.json", r#"{"ok": true}"#),
694            (
695                "configs/pass/advanced.json",
696                r#"{"ok": true, "level": "advanced"}"#,
697            ),
698            ("configs/fail/bad_config.json", r#"{"ok": false}"#),
699            ("ignored/should_skip.json", r#"{"skip": true}"#),
700        ];
701        for (name, content) in &text_files {
702            fs::write(root.join(name), content).unwrap();
703        }
704
705        // Binary files
706        fs::write(root.join("gamma.bin"), &[0xDE, 0xAD, 0xBE, 0xEF]).unwrap();
707        fs::write(root.join("models/weights.bin"), &[0x01, 0x02, 0x03]).unwrap();
708    }
709
710    /// Build a fixture tree and return the generated code as a String.
711    fn generate_to_string(config: Config) -> String {
712        let out = config.out_path.clone();
713        let ft = config.build().unwrap();
714        ft.generate_fixtures().unwrap();
715        fs::read_to_string(&out).unwrap()
716    }
717
718    use std::sync::atomic::{AtomicUsize, Ordering};
719
720    static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0);
721
722    /// Create a temp dir outside `CARGO_MANIFEST_DIR` and populate it.
723    fn setup_tempdir() -> TempDir {
724        let tmp = TempDir::new().unwrap();
725        create_fixture_tree(tmp.path());
726        tmp
727    }
728
729    /// Create a uniquely-named dir inside the project (under CARGO_MANIFEST_DIR)
730    /// and populate it. Each call gets its own directory so tests can run in
731    /// parallel without interfering.
732    struct InManifestDir(PathBuf);
733
734    impl InManifestDir {
735        fn new() -> Self {
736            let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
737            let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
738            let test_root = manifest.join("test_fixtures");
739            let dir = test_root.join(format!("auto_{id}"));
740            if dir.exists() {
741                fs::remove_dir_all(&dir).unwrap();
742            }
743            create_fixture_tree(&dir);
744            Self(dir)
745        }
746
747        fn path(&self) -> &Path {
748            &self.0
749        }
750    }
751
752    impl Drop for InManifestDir {
753        fn drop(&mut self) {
754            let _ = fs::remove_dir_all(&self.0);
755        }
756    }
757
758    // in-manifest tests (rel_to_manifest = true)
759
760    #[test]
761    fn manual_review_all() {
762        let dir = InManifestDir::new();
763        let out = dir.path().parent().unwrap().join("manual_review_all.rs");
764
765        Config::new()
766            .from_path(dir.path())
767            .with_output_path(out.clone())
768            .with_exts_as_string(["json", "txt"])
769            .with_exts_as_bin(["bin"])
770            .build()
771            .unwrap()
772            .generate_fixtures()
773            .unwrap();
774    }
775
776    #[test]
777    fn manual_review_only_stringy() {
778        let dir = InManifestDir::new();
779        let out = dir
780            .path()
781            .parent()
782            .unwrap()
783            .join("manual_review_stringy.rs");
784
785        Config::new()
786            .from_path(dir.path())
787            .with_output_path(out.clone())
788            .with_exts_as_string(["json", "txt"])
789            .build()
790            .unwrap()
791            .generate_fixtures()
792            .unwrap();
793    }
794
795    #[test]
796    fn in_manifest_generates_all_json_files() {
797        let dir = InManifestDir::new();
798        let out = dir.path().join("_test_out.rs");
799        let code = generate_to_string(
800            Config::new()
801                .from_path(dir.path())
802                .with_output_path(&out)
803                .with_ext("json")
804                .with_ext_as_string("json"),
805        );
806
807        // root-level json files present
808        assert!(code.contains("pub fn alpha()"), "alpha.json missing");
809        assert!(code.contains("pub fn beta()"), "beta.json missing");
810
811        // nested files present
812        assert!(
813            code.contains("pub fn linear()"),
814            "models/linear.json missing"
815        );
816        assert!(code.contains("pub fn conv()"), "models/conv.json missing");
817        assert!(
818            code.contains("pub fn basic()"),
819            "configs/pass/basic.json missing"
820        );
821        assert!(
822            code.contains("pub fn advanced()"),
823            "configs/pass/advanced.json missing"
824        );
825        assert!(
826            code.contains("pub fn bad_config()"),
827            "configs/fail/bad_config.json missing"
828        );
829
830        // non-json files excluded
831        assert!(
832            !code.contains("pub fn gamma()"),
833            "gamma.bin should be excluded by ext filter"
834        );
835        assert!(
836            !code.contains("pub fn delta()"),
837            "delta.txt should be excluded by ext filter"
838        );
839        assert!(
840            !code.contains("pub fn weights()"),
841            "weights.bin should be excluded by ext filter"
842        );
843
844        // uses CARGO_MANIFEST_DIR (relative)
845        assert!(
846            code.contains("env!(\"CARGO_MANIFEST_DIR\")"),
847            "should use CARGO_MANIFEST_DIR"
848        );
849        assert!(
850            !code.contains("\"/home"),
851            "should not contain absolute paths"
852        );
853    }
854
855    #[test]
856    fn in_manifest_nested_modules_structure() {
857        let dir = InManifestDir::new();
858        let out = dir.path().join("_test_structure.rs");
859        let code = generate_to_string(
860            Config::new()
861                .from_path(dir.path())
862                .with_output_path(&out)
863                .with_ext("json")
864                .with_ext_as_string("json"),
865        );
866
867        assert!(code.contains("pub mod models {"), "models module missing");
868        assert!(code.contains("pub mod configs {"), "configs module missing");
869        assert!(code.contains("pub mod pass {"), "pass module missing");
870        assert!(code.contains("pub mod fail {"), "fail module missing");
871        assert!(code.contains("pub mod ignored {"), "ignored module missing");
872    }
873
874    #[test]
875    fn in_manifest_path_fn_per_module() {
876        let dir = InManifestDir::new();
877        let out = dir.path().join("_test_paths.rs");
878        let code = generate_to_string(
879            Config::new()
880                .from_path(dir.path())
881                .with_output_path(&out)
882                .with_ext("json")
883                .with_ext_as_string("json"),
884        );
885
886        // Every module gets a path() function
887        let path_count = code.matches("pub fn path()").count();
888        // root + models + configs + pass + fail + ignored = 6
889        assert!(
890            path_count >= 6,
891            "expected at least 6 path() fns, got {path_count}"
892        );
893    }
894
895    #[test]
896    fn in_manifest_ext_as_string_returns_tuple() {
897        let dir = InManifestDir::new();
898        let out = dir.path().join("_test_str.rs");
899        let code = generate_to_string(
900            Config::new()
901                .from_path(dir.path())
902                .with_output_path(&out)
903                .with_ext("json")
904                .with_ext_as_string("json"),
905        );
906
907        assert!(
908            code.contains("(&'static std::path::Path, &'static str)"),
909            "string fixtures should return (Path, str) tuple"
910        );
911        assert!(
912            code.contains("include_str!"),
913            "should use include_str! for string fixtures"
914        );
915    }
916
917    #[test]
918    fn in_manifest_ext_as_bin_returns_bytes() {
919        let dir = InManifestDir::new();
920        let out = dir.path().join("_test_bin.rs");
921        let code = generate_to_string(
922            Config::new()
923                .from_path(dir.path())
924                .with_output_path(&out)
925                .with_ext("bin")
926                .with_ext_as_bin("bin"),
927        );
928
929        assert!(code.contains("pub fn gamma()"), "gamma.bin missing");
930        assert!(
931            code.contains("pub fn weights()"),
932            "models/weights.bin missing"
933        );
934        assert!(
935            code.contains("(&'static std::path::Path, &'static [u8])"),
936            "binary fixtures should return (Path, [u8]) tuple"
937        );
938        assert!(
939            code.contains("include_bytes!"),
940            "should use include_bytes! for binary fixtures"
941        );
942    }
943
944    #[test]
945    fn in_manifest_multiple_exts() {
946        let dir = InManifestDir::new();
947        let out = dir.path().join("_test_multi_ext.rs");
948        let code = generate_to_string(
949            Config::new()
950                .from_path(dir.path())
951                .with_output_path(&out)
952                .with_ext("json")
953                .with_ext("bin")
954                .with_ext_as_string("json")
955                .with_ext_as_bin("bin"),
956        );
957
958        // Both types present
959        assert!(code.contains("pub fn alpha()"), "json fixture missing");
960        assert!(code.contains("pub fn gamma()"), "bin fixture missing");
961        assert!(code.contains("include_str!"), "missing include_str");
962        assert!(code.contains("include_bytes!"), "missing include_bytes");
963    }
964
965    #[test]
966    fn in_manifest_no_ext_filter_includes_everything() {
967        let dir = InManifestDir::new();
968        let out = dir.path().join("_test_no_ext.rs");
969        let code = generate_to_string(
970            Config::new()
971                .from_path(dir.path())
972                .with_output_path(&out)
973                .with_ext_as_string("json")
974                .with_ext_as_string("txt")
975                .with_ext_as_bin("bin"),
976        );
977
978        // All extensions should be walked when allow_exts is empty
979        assert!(code.contains("pub fn alpha()"), "json missing");
980        assert!(code.contains("pub fn gamma()"), "bin missing");
981        assert!(code.contains("pub fn delta()"), "txt missing");
982    }
983
984    #[test]
985    fn in_manifest_ignore_paths() {
986        let dir = InManifestDir::new();
987        let out = dir.path().join("_test_ignore.rs");
988        let code = generate_to_string(
989            Config::new()
990                .from_path(dir.path())
991                .with_output_path(&out)
992                .with_ext("json")
993                .with_ext_as_string("json")
994                .without_path(PathBuf::from("ignored")),
995        );
996
997        assert!(
998            !code.contains("pub fn should_skip()"),
999            "ignored dir should be excluded"
1000        );
1001        assert!(
1002            code.contains("pub fn alpha()"),
1003            "non-ignored files should remain"
1004        );
1005    }
1006
1007    #[test]
1008    fn in_manifest_ignore_multiple_paths() {
1009        let dir = InManifestDir::new();
1010        let out = dir.path().join("_test_ignore_multi.rs");
1011        let code = generate_to_string(
1012            Config::new()
1013                .from_path(dir.path())
1014                .with_output_path(&out)
1015                .with_ext("json")
1016                .with_ext_as_string("json")
1017                .without_paths(vec![PathBuf::from("ignored"), PathBuf::from("configs")]),
1018        );
1019
1020        assert!(!code.contains("pub fn should_skip()"), "ignored/ excluded");
1021        assert!(!code.contains("pub fn basic()"), "configs/ excluded");
1022        assert!(!code.contains("pub fn bad_config()"), "configs/ excluded");
1023        assert!(code.contains("pub fn alpha()"), "root files remain");
1024        assert!(code.contains("pub fn linear()"), "models/ remain");
1025    }
1026
1027    #[test]
1028    #[cfg(feature = "regex")]
1029    fn in_manifest_regex_filter_filename() {
1030        let dir = InManifestDir::new();
1031        let out = dir.path().join("_test_regex_name.rs");
1032        let code = generate_to_string(
1033            Config::new()
1034                .from_path(dir.path())
1035                .with_output_path(&out)
1036                .with_ext("json")
1037                .with_ext_as_string("json")
1038                .with_allow_pattern(r"alpha|beta"),
1039        );
1040
1041        assert!(code.contains("pub fn alpha()"), "alpha should match");
1042        assert!(code.contains("pub fn beta()"), "beta should match");
1043        assert!(!code.contains("pub fn linear()"), "linear should not match");
1044        assert!(!code.contains("pub fn basic()"), "basic should not match");
1045    }
1046
1047    #[test]
1048    #[cfg(feature = "regex")]
1049    fn in_manifest_regex_filter_path_component() {
1050        let dir = InManifestDir::new();
1051        let out = dir.path().join("_test_regex_path.rs");
1052        let code = generate_to_string(
1053            Config::new()
1054                .from_path(dir.path())
1055                .with_output_path(&out)
1056                .with_ext("json")
1057                .with_ext_as_string("json")
1058                .with_allow_pattern(r"^configs/pass/"),
1059        );
1060
1061        assert!(
1062            code.contains("pub fn basic()"),
1063            "configs/pass/basic.json should match"
1064        );
1065        assert!(
1066            code.contains("pub fn advanced()"),
1067            "configs/pass/advanced.json should match"
1068        );
1069        assert!(
1070            !code.contains("pub fn bad_config()"),
1071            "configs/fail/ should not match"
1072        );
1073        assert!(
1074            !code.contains("pub fn alpha()"),
1075            "root files should not match"
1076        );
1077    }
1078
1079    #[test]
1080    #[cfg(feature = "regex")]
1081    fn in_manifest_multiple_regex_patterns_or() {
1082        let dir = InManifestDir::new();
1083        let out = dir.path().join("_test_regex_multi.rs");
1084        let code = generate_to_string(
1085            Config::new()
1086                .from_path(dir.path())
1087                .with_output_path(&out)
1088                .with_ext("json")
1089                .with_ext_as_string("json")
1090                .with_allow_patterns(vec![r"^alpha", r"^models/"]),
1091        );
1092
1093        assert!(
1094            code.contains("pub fn alpha()"),
1095            "alpha should match first pattern"
1096        );
1097        assert!(
1098            code.contains("pub fn linear()"),
1099            "models/ should match second pattern"
1100        );
1101        assert!(
1102            code.contains("pub fn conv()"),
1103            "models/ should match second pattern"
1104        );
1105        assert!(
1106            !code.contains("pub fn beta()"),
1107            "beta should not match either pattern"
1108        );
1109    }
1110
1111    #[test]
1112    fn in_manifest_empty_dirs_omitted() {
1113        let dir = InManifestDir::new();
1114        let out = dir.path().join("_test_empty.rs");
1115        // Only allow .txt, the only .txt file is at the root, so all subdirs
1116        // with only .json/.bin become empty and should be pruned.
1117        let code = generate_to_string(
1118            Config::new()
1119                .from_path(dir.path())
1120                .with_output_path(&out)
1121                .with_ext("txt")
1122                .with_ext_as_string("txt"),
1123        );
1124
1125        assert!(
1126            code.contains("pub fn delta()"),
1127            "delta.txt should be present"
1128        );
1129        assert!(
1130            !code.contains("pub mod models {"),
1131            "empty models module should be omitted"
1132        );
1133        assert!(
1134            !code.contains("pub mod configs {"),
1135            "empty configs module should be omitted"
1136        );
1137    }
1138
1139    #[test]
1140    fn in_manifest_fn_name_sanitisation() {
1141        let dir = InManifestDir::new();
1142        // Create a file with dashes in the name
1143        fs::write(dir.path().join("my-dashed-name.json"), "{}").unwrap();
1144        let out = dir.path().join("_test_sanitise.rs");
1145        let code = generate_to_string(
1146            Config::new()
1147                .from_path(dir.path())
1148                .with_output_path(&out)
1149                .with_ext("json")
1150                .with_ext_as_string("json"),
1151        );
1152
1153        assert!(
1154            code.contains("pub fn my_dashed_name()"),
1155            "dashes should be replaced with underscores"
1156        );
1157        assert!(
1158            !code.contains("pub fn my-dashed-name()"),
1159            "raw dashes should not appear in fn names"
1160        );
1161    }
1162
1163    #[test]
1164    fn in_manifest_ext_overlap_rejected() {
1165        let dir = InManifestDir::new();
1166        let out = dir.path().join("_test_overlap.rs");
1167        let result = Config::new()
1168            .from_path(dir.path())
1169            .with_output_path(&out)
1170            .with_ext("json")
1171            .with_ext_as_string("json")
1172            .with_ext_as_bin("json")
1173            .build();
1174
1175        assert!(result.is_err(), "overlapping string/bin ext should fail");
1176        let msg = result.unwrap_err().to_string();
1177        assert!(
1178            msg.contains("json"),
1179            "error should mention the offending ext"
1180        );
1181    }
1182
1183    // temp-dir tests (rel_to_manifest = false)
1184
1185    #[test]
1186    fn tempdir_generates_absolute_paths() {
1187        let tmp = setup_tempdir();
1188        let out = tmp.path().join("_test_out.rs");
1189        let code = generate_to_string(
1190            Config::new()
1191                .from_path(tmp.path())
1192                .with_output_path(&out)
1193                .with_ext("json")
1194                .with_ext_as_string("json"),
1195        );
1196
1197        // Should NOT reference CARGO_MANIFEST_DIR
1198        assert!(
1199            !code.contains("env!(\"CARGO_MANIFEST_DIR\")"),
1200            "temp-dir output should not use CARGO_MANIFEST_DIR"
1201        );
1202
1203        // Should contain absolute paths from the tempdir
1204        let tmp_str = tmp.path().to_string_lossy();
1205        assert!(
1206            code.contains(tmp_str.as_ref()),
1207            "output should contain the temp dir absolute path"
1208        );
1209    }
1210
1211    #[test]
1212    fn tempdir_generates_all_json_files() {
1213        let tmp = setup_tempdir();
1214        let out = tmp.path().join("_test_all.rs");
1215        let code = generate_to_string(
1216            Config::new()
1217                .from_path(tmp.path())
1218                .with_output_path(&out)
1219                .with_ext("json")
1220                .with_ext_as_string("json"),
1221        );
1222
1223        assert!(code.contains("pub fn alpha()"), "alpha missing");
1224        assert!(code.contains("pub fn beta()"), "beta missing");
1225        assert!(code.contains("pub fn linear()"), "linear missing");
1226        assert!(code.contains("pub fn basic()"), "basic missing");
1227        assert!(code.contains("pub fn bad_config()"), "bad_config missing");
1228        assert!(
1229            !code.contains("pub fn gamma()"),
1230            "gamma.bin should be excluded"
1231        );
1232    }
1233
1234    #[test]
1235    fn tempdir_ext_as_bin() {
1236        let tmp = setup_tempdir();
1237        let out = tmp.path().join("_test_bin.rs");
1238        let code = generate_to_string(
1239            Config::new()
1240                .from_path(tmp.path())
1241                .with_output_path(&out)
1242                .with_ext("bin")
1243                .with_ext_as_bin("bin"),
1244        );
1245
1246        assert!(code.contains("pub fn gamma()"), "gamma.bin missing");
1247        assert!(code.contains("pub fn weights()"), "weights.bin missing");
1248        assert!(code.contains("include_bytes!"), "should use include_bytes!");
1249        assert!(
1250            !code.contains("include_str!"),
1251            "should not use include_str!"
1252        );
1253    }
1254
1255    #[test]
1256    fn tempdir_ignore_paths() {
1257        let tmp = setup_tempdir();
1258        let out = tmp.path().join("_test_ign.rs");
1259        let code = generate_to_string(
1260            Config::new()
1261                .from_path(tmp.path())
1262                .with_output_path(&out)
1263                .with_ext("json")
1264                .with_ext_as_string("json")
1265                .without_path(PathBuf::from("ignored"))
1266                .without_path(PathBuf::from("models")),
1267        );
1268
1269        assert!(!code.contains("pub fn should_skip()"), "ignored/ excluded");
1270        assert!(!code.contains("pub fn linear()"), "models/ excluded");
1271        assert!(code.contains("pub fn alpha()"), "root files remain");
1272    }
1273
1274    #[test]
1275    #[cfg(feature = "regex")]
1276    fn tempdir_regex_filter() {
1277        let tmp = setup_tempdir();
1278        let out = tmp.path().join("_test_regex.rs");
1279        let code = generate_to_string(
1280            Config::new()
1281                .from_path(tmp.path())
1282                .with_output_path(&out)
1283                .with_ext("json")
1284                .with_ext_as_string("json")
1285                .with_allow_pattern(r"configs/"),
1286        );
1287
1288        assert!(
1289            code.contains("pub fn basic()"),
1290            "configs/ files should match"
1291        );
1292        assert!(
1293            code.contains("pub fn bad_config()"),
1294            "configs/ files should match"
1295        );
1296        assert!(
1297            !code.contains("pub fn alpha()"),
1298            "root files should not match"
1299        );
1300        assert!(
1301            !code.contains("pub fn linear()"),
1302            "models/ should not match"
1303        );
1304    }
1305
1306    #[test]
1307    fn tempdir_nested_modules() {
1308        let tmp = setup_tempdir();
1309        let out = tmp.path().join("_test_mods.rs");
1310        let code = generate_to_string(
1311            Config::new()
1312                .from_path(tmp.path())
1313                .with_output_path(&out)
1314                .with_ext("json")
1315                .with_ext_as_string("json"),
1316        );
1317
1318        assert!(code.contains("pub mod models {"), "models module");
1319        assert!(code.contains("pub mod configs {"), "configs module");
1320        assert!(code.contains("pub mod pass {"), "pass submodule");
1321        assert!(code.contains("pub mod fail {"), "fail submodule");
1322    }
1323
1324    #[test]
1325    fn tempdir_empty_modules_pruned() {
1326        let tmp = setup_tempdir();
1327        let out = tmp.path().join("_test_prune.rs");
1328        let code = generate_to_string(
1329            Config::new()
1330                .from_path(tmp.path())
1331                .with_output_path(&out)
1332                .with_ext("txt")
1333                .with_ext_as_string("txt"),
1334        );
1335
1336        assert!(code.contains("pub fn delta()"), "delta.txt present");
1337        assert!(!code.contains("pub mod models {"), "empty dir pruned");
1338    }
1339
1340    #[test]
1341    fn tempdir_mixed_string_and_bin() {
1342        let tmp = setup_tempdir();
1343        let out = tmp.path().join("_test_mixed.rs");
1344        let code = generate_to_string(
1345            Config::new()
1346                .from_path(tmp.path())
1347                .with_output_path(&out)
1348                .with_ext("json")
1349                .with_ext("bin")
1350                .with_ext_as_string("json")
1351                .with_ext_as_bin("bin"),
1352        );
1353
1354        // String
1355        assert!(code.contains("include_str!"), "json uses include_str!");
1356        // Bin
1357        assert!(code.contains("include_bytes!"), "bin uses include_bytes!");
1358        // Both types present
1359        assert!(code.contains("pub fn alpha()"), "json fixture");
1360        assert!(code.contains("pub fn gamma()"), "bin fixture");
1361    }
1362
1363    #[test]
1364    fn tempdir_ext_overlap_rejected() {
1365        let tmp = setup_tempdir();
1366        let out = tmp.path().join("_test_overlap.rs");
1367        let result = Config::new()
1368            .from_path(tmp.path())
1369            .with_output_path(&out)
1370            .with_ext("json")
1371            .with_ext_as_string("json")
1372            .with_ext_as_bin("json")
1373            .build();
1374
1375        assert!(result.is_err(), "overlapping ext should be rejected");
1376    }
1377}