9

I would like to copy an entire directory to a location in a user's $HOME. Individually copying files to that directory is straightforward:

let contents = include_str!("resources/profiles/default.json");
let fpath = dpath.join(&fname);
fs::write(fpath, contents).expect(&format!("failed to create profile: {}", n));

I haven't found a way to adapt this to multiple files:

for n in ["default"] {
    let fname = format!("{}{}", n, ".json");
    let x = format!("resources/profiles/{}", fname).as_str();
    let contents = include_str!(x);
    let fpath = dpath.join(&fname);
    fs::write(fpath, contents).expect(&format!("failed to create profile: {}", n));
}

...the compiler complains that x must be a string literal.

As far as I know, there are two options:

  1. Write a custom macro.
  2. Replicate the first code for each file I want to copy.

What is the best way of doing this?

Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
Alex
  • 18,484
  • 8
  • 60
  • 80

3 Answers3

11

I would create a build script that iterates through a directory, building up an array of tuples containing the name and another macro call to include the raw data:

use std::{
    env,
    error::Error,
    fs::{self, File},
    io::Write,
    path::Path,
};

const SOURCE_DIR: &str = "some/path/to/include";

fn main() -> Result<(), Box<dyn Error>> {
    let out_dir = env::var("OUT_DIR")?;
    let dest_path = Path::new(&out_dir).join("all_the_files.rs");
    let mut all_the_files = File::create(&dest_path)?;

    writeln!(&mut all_the_files, r##"["##,)?;

    for f in fs::read_dir(SOURCE_DIR)? {
        let f = f?;

        if !f.file_type()?.is_file() {
            continue;
        }

        writeln!(
            &mut all_the_files,
            r##"("{name}", include_bytes!(r#"{name}"#)),"##,
            name = f.path().display(),
        )?;
    }

    writeln!(&mut all_the_files, r##"]"##,)?;

    Ok(())
}

This has some weaknesses, namely that it requires the path to be expressible as a &str. Since you were already using include_string!, I don't think that's an extra requirement. This also means that the generated string has to be a valid Rust string. We use raw strings inside the generated file, but this can still fail if a filename were to contain the string "#. A better solution would probably use str::escape_default.

Since we are including files, I used include_bytes! instead of include_str!, but if you really needed to you can switch back. The raw bytes skips performing UTF-8 validation at compile time, so it's a small win.

Using it involves importing the generated value:

const ALL_THE_FILES: &[(&str, &[u8])] = &include!(concat!(env!("OUT_DIR"), "/all_the_files.rs"));

fn main() {
    for (name, data) in ALL_THE_FILES {
        println!("File {} is {} bytes", name, data.len());
    }
}

See also:

Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
  • Hi, I don't know if this is still the preferred way, but when I try this (first snippet in `{projectRoot}/build.rs`, second in `{projectRoot}/src/main.rs`) `cargo build` complains that it doesn't find the files. I guess if `const SOURCE_DIR: &str = "some/path/to/include";` is an absolute path it would work, but using absolute paths is not great for distribution. Am I missing something else? – DavSanchez Apr 06 '20 at 17:46
  • 1
    @DavSanchez does [How can I locate resources for testing with Cargo?](https://stackoverflow.com/q/30003921/155423) show you how to replace `SOURCE_DIR` to something relative to your project? – Shepmaster Apr 06 '20 at 17:53
  • Yeah, I tried it and it worked by also removing `;` from the raw string at your last `writeln!()` of `build.rs`. Thanks a lot! – DavSanchez Apr 06 '20 at 18:02
6

You can use include_dir macro.

use include_dir::{include_dir, Dir};
use std::path::Path;

const PROJECT_DIR: Dir = include_dir!(".");

// of course, you can retrieve a file by its full path
let lib_rs = PROJECT_DIR.get_file("src/lib.rs").unwrap();

// you can also inspect the file's contents
let body = lib_rs.contents_utf8().unwrap();
assert!(body.contains("SOME_INTERESTING_STRING"));
Owen Young
  • 157
  • 2
  • 8
2

Using a macro:

macro_rules! incl_profiles {
    ( $( $x:expr ),* ) => {
        {
            let mut profs = Vec::new();
            $(
                profs.push(($x, include_str!(concat!("resources/profiles/", $x, ".json"))));
            )*

            profs
        }
    };
}

...

let prof_tups: Vec<(&str, &str)> = incl_profiles!("default", "python");

for (prof_name, prof_str) in prof_tups {
    let fname = format!("{}{}", prof_name, ".json");
    let fpath = dpath.join(&fname);
    fs::write(fpath, prof_str).expect(&format!("failed to create profile: {}", prof_name));
}

Note: This is not dynamic. The files ("default" and "python") are specified in the call to the macro.

Updated: Use Vec instead of HashMap.

Alex
  • 18,484
  • 8
  • 60
  • 80
  • Using a `HashMap` is needless overhead if you are only going to iterate. – Shepmaster May 27 '18 at 17:04
  • @Shepmaster was using it so I didn't need another line with `"default"` and `"python"`... Guess I could use an array and split it afterwards but this seems more straightforward. What would you recommend? – Alex May 27 '18 at 17:06
  • 1
    The array of tuples as seen in my answer (`[(name, value)]`). – Shepmaster May 27 '18 at 17:07