treadmill/
lib.rs

1use std::{
2    collections::{hash_map::Entry, HashMap},
3    ffi::OsString,
4    io::{BufReader, ErrorKind},
5    path::{Path, PathBuf},
6    process::Stdio,
7};
8
9use cargo_metadata::{
10    diagnostic::{Diagnostic, DiagnosticLevel},
11    BuildFinished, Edition, Message, MetadataCommand,
12};
13
14#[derive(thiserror::Error, Debug)]
15pub enum Error {
16    #[error("Missing environment variable: {0}. Expected to be run by cargo.")]
17    MissingEnvVar(&'static str),
18
19    #[error("Failed to get cargo metadata: {0}")]
20    CargoMetadata(#[from] cargo_metadata::Error),
21
22    #[error("Failed to find package: {0}")]
23    NoPackage(std::io::Error),
24
25    #[error("Failed to get path to test's executable: {0}")]
26    NoCurrentExe(std::io::Error),
27
28    #[error("Failed create cargo package for test binary: {0}")]
29    CreatingCargoPackage(std::io::Error),
30
31    #[error("Failed to run cargo build for test binary: {0}")]
32    CargoBuildCommand(std::io::Error),
33
34    #[error("Failed to build test binary.")]
35    BuildError(HashMap<DiagnosticLevel, Vec<Diagnostic>>),
36}
37
38type Result<T, E = Error> = std::result::Result<T, E>;
39
40#[derive(Debug)]
41pub struct DetectedEnvironment {
42    target: String,
43    package_name: String,
44    package_manifest: PathBuf,
45    test_name: String,
46    edition: Edition,
47    target_directory: PathBuf,
48}
49
50#[derive(Debug)]
51pub struct Program {
52    /// Path to program's directory in the target folder.
53    path: PathBuf,
54
55    name: String,
56
57    source: String,
58
59    dependencies: Vec<String>,
60
61    detected: DetectedEnvironment,
62}
63
64impl Program {
65    pub fn new(name: String, source: String) -> Result<Self> {
66        let package_name = package_name()?;
67
68        let (base_dir, edition) = base_dir_and_edition(&package_name)?;
69
70        let target = target();
71
72        let package_manifest = package_manifest()?;
73
74        let test_name = test_name()?;
75
76        let path = base_dir.join(&package_name).join(&test_name).join(&name);
77
78        let path_dependency = format!(
79            "{} = {{ path = \"{}\"}}",
80            package_name,
81            package_manifest.to_string_lossy()
82        );
83
84        Ok(Program {
85            path,
86            name,
87            source,
88            dependencies: vec![path_dependency],
89            detected: DetectedEnvironment {
90                target: target.to_owned(),
91                package_name,
92                package_manifest,
93                test_name,
94                edition,
95                target_directory: base_dir
96                    .parent()
97                    .expect("base dir should have parent")
98                    .to_path_buf(),
99            },
100        })
101    }
102
103    pub fn add_dependency(&mut self, value: String) {
104        self.dependencies.push(value);
105    }
106
107    pub fn compile(self) -> Result<Binary> {
108        let cargo_path = cargo_path()?;
109
110        // Crate directory for cargo package.
111        std::fs::create_dir_all(&self.path)
112            .map_err(Error::CreatingCargoPackage)?;
113
114        // Create cargo manifest.
115        // cargo won't rebuild if it didn't change.
116        std::fs::write(self.path.join("Cargo.toml"), format!(r#"
117[package]
118name = "{name}"
119version = "0.1.0"
120edition = "{edition}"
121publish = false
122
123[[bin]]
124name = "{name}"
125path = "main.rs"
126
127[dependencies]
128{dependencies}
129
130[package.metadata.treadmill]
131notice = "This package was automatically generated by the `treadmill` crate for use with tests."
132"#,
133            name = &self.name,
134            dependencies = self.dependencies.join("\n"),
135            edition = self.detected.edition.as_str(),
136        )).map_err(Error::CreatingCargoPackage)?;
137
138        // cargo won't rebuild if it didn't change.
139        std::fs::write(self.path.join("main.rs"), self.source)
140            .map_err(Error::CreatingCargoPackage)?;
141
142        // Set cargo command.
143        let mut command = std::process::Command::new(cargo_path);
144        command
145            .args(["build", "--message-format=json", "-q", "--manifest-path"])
146            .arg(self.path.join("Cargo.toml"))
147            .arg("--bin")
148            .arg(&self.name)
149            .arg("--target")
150            .arg(&self.detected.target)
151            .arg("--target-dir")
152            .arg(&self.detected.target_directory)
153            .stdout(Stdio::piped());
154
155        let mut spawned = command.spawn().map_err(Error::CargoBuildCommand)?;
156
157        let reader = BufReader::new(
158            spawned
159                .stdout
160                .take()
161                .expect("spawned should have stdout buffer"),
162        );
163
164        let messages = Message::parse_stream(reader);
165
166        let mut executable_path = None;
167        let mut diagnostics: HashMap<DiagnosticLevel, Vec<Diagnostic>> =
168            HashMap::new();
169        let mut build_finished = false;
170
171        // Record the path to the artifact and any diagnostics.
172        for message in messages {
173            match message {
174                Ok(Message::CompilerArtifact(artifact))
175                    if artifact.target.name == self.name =>
176                {
177                    if let Some(path) = artifact.executable {
178                        executable_path = Some(path);
179                    }
180                }
181                Ok(Message::CompilerMessage(msg))
182                    if msg.target.name == self.name =>
183                {
184                    match diagnostics.entry(msg.message.level) {
185                        Entry::Occupied(occupied) => {
186                            occupied.into_mut().push(msg.message)
187                        }
188                        Entry::Vacant(vacant) => {
189                            vacant.insert(vec![msg.message]);
190                        }
191                    }
192                }
193                Ok(Message::BuildFinished(BuildFinished {
194                    success: true,
195                    ..
196                })) => build_finished = true,
197                _ => {}
198            }
199        }
200
201        if build_finished {
202            if let Some(path) = executable_path {
203                Ok(Binary {
204                    path: path.into_std_path_buf(),
205                })
206            } else {
207                Err(Error::CargoBuildCommand(std::io::Error::new(
208                    ErrorKind::Other,
209                    "No executable was generated.",
210                )))
211            }
212        } else {
213            Err(Error::BuildError(diagnostics))
214        }
215    }
216}
217
218fn environment_var(name: &'static str) -> Result<OsString> {
219    std::env::var_os(name).ok_or(Error::MissingEnvVar(name))
220}
221
222fn base_dir_and_edition(package_name: &str) -> Result<(PathBuf, Edition)> {
223    let manifest_path = package_manifest()?.join("Cargo.toml");
224
225    // cargo_metadata will find the target folder for us.
226    let metadata =
227        MetadataCommand::new().manifest_path(manifest_path).exec()?;
228
229    let package = metadata
230        .packages
231        .iter()
232        .find(|package| package.name == package_name)
233        .ok_or_else(|| {
234            Error::NoPackage(std::io::Error::new(ErrorKind::Other, ""))
235        })?;
236
237    let edition = package.edition.clone();
238
239    // ttp stands for "treadmill test programs" but is short to minimize the length
240    // of the paths for systems like Windows.
241    Ok((
242        metadata.target_directory.join("ttp").into_std_path_buf(),
243        edition,
244    ))
245}
246
247fn cargo_path() -> Result<PathBuf> {
248    // cargo will set this environment variable when using `cargo test`.
249    // https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
250    environment_var("CARGO").map(PathBuf::from)
251}
252
253fn target() -> &'static str {
254    // This is set by the build script.
255    env!("TREADMILL_TARGET")
256}
257
258fn package_manifest() -> Result<PathBuf> {
259    // cargo will set this environment variable when using `cargo test`.
260    // https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
261    environment_var("CARGO_MANIFEST_DIR").map(PathBuf::from)
262}
263
264fn package_name() -> Result<String> {
265    // cargo will set this environment variable when using `cargo test`.
266    // https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
267    environment_var("CARGO_PKG_NAME")
268        .map(|name| name.to_string_lossy().into_owned())
269}
270
271fn test_name() -> Result<String> {
272    // We will get the name of the test from the executable.
273    // We can do this because cargo names test executables like `test_name-hash`.
274    let current_exe = std::env::current_exe().map_err(Error::NoCurrentExe)?;
275
276    let name = current_exe
277        .file_name()
278        .ok_or_else(|| {
279            Error::NoCurrentExe(std::io::Error::new(
280                ErrorKind::Other,
281                "File doesn't have a file name.",
282            ))
283        })?
284        .to_string_lossy()
285        .into_owned();
286
287    // Remove the hash. We just want the name of the test.
288    if let Some((test_name, _)) = name.rsplit_once('-') {
289        Ok(test_name.to_owned())
290    } else {
291        Ok(name)
292    }
293}
294
295pub struct Binary {
296    path: PathBuf,
297}
298
299impl Binary {
300    pub fn command(&self) -> std::process::Command {
301        std::process::Command::new(&self.path)
302    }
303}