1#![deny(missing_docs)]
5
6use anyhow::{anyhow, bail, ensure, Error, Result};
9use cargo_simics_build::{App, Cmd, SimicsBuildCmd};
10use cargo_subcommand::Args;
11use command_ext::{CommandExtCheck, CommandExtError};
12use ispm_wrapper::{
13 data::ProjectPackage,
14 ispm::{
15 self,
16 packages::{InstallOptions, UninstallOptions},
17 projects::CreateOptions,
18 GlobalOptions,
19 },
20 Internal,
21};
22use std::{
23 collections::HashSet,
24 env::{current_dir, set_current_dir, var},
25 fs::{copy, create_dir_all, read_dir, remove_dir_all, write},
26 path::{Path, PathBuf},
27 process::{Command, Output},
28};
29use typed_builder::TypedBuilder;
30use versions::{Requirement, Versioning};
31use walkdir::WalkDir;
32
33pub const SIMICS_TEST_CLEANUP_EACH_ENV: &str = "SIMICS_TEST_CLEANUP_EACH";
36pub const SIMICS_TEST_LOCAL_PACKAGES_ONLY_ENV: &str = "SIMICS_TEST_LOCAL_PACKAGES_ONLY";
39
40pub fn copy_dir_contents<P>(src_dir: P, dst_dir: P) -> Result<()>
43where
44 P: AsRef<Path>,
45{
46 let src_dir = src_dir.as_ref().to_path_buf();
47 ensure!(src_dir.is_dir(), "Source must be a directory");
48 let dst_dir = dst_dir.as_ref().to_path_buf();
49 if !dst_dir.is_dir() {
50 create_dir_all(&dst_dir).map_err(|e| {
51 anyhow!(
52 "Failed to create destination directory for directory copy {:?}: {}",
53 dst_dir,
54 e
55 )
56 })?;
57 }
58
59 for (src, dst) in WalkDir::new(&src_dir)
60 .into_iter()
61 .filter_map(|p| p.ok())
62 .filter_map(|p| {
63 let src = p.path().to_path_buf();
64 match src.strip_prefix(&src_dir) {
65 Ok(suffix) => Some((src.clone(), dst_dir.join(suffix))),
66 Err(_) => None,
67 }
68 })
69 {
70 if src.is_dir() {
71 create_dir_all(&dst).map_err(|e| {
72 anyhow!(
73 "Failed to create nested destination directory for copy {:?}: {}",
74 dst,
75 e
76 )
77 })?;
78 } else if src.is_file() {
79 if let Err(e) = copy(&src, &dst) {
80 eprintln!(
81 "Warning: failed to copy file from {} to {}: {}",
82 src.display(),
83 dst.display(),
84 e
85 );
86 }
87 }
88 }
89 Ok(())
90}
91
92pub fn local_or_remote_pkg_install(mut options: InstallOptions) -> Result<()> {
94 if Internal::is_internal()? && var(SIMICS_TEST_LOCAL_PACKAGES_ONLY_ENV).is_err() {
95 ispm::packages::install(&options)?;
96 } else {
97 let installed = ispm::packages::list(&GlobalOptions::default())?;
98
99 for package in options.packages.iter() {
100 let Some(installed) = installed.installed_packages.as_ref() else {
101 bail!("Did not get any installed packages");
102 };
103
104 let Some(available) = installed.iter().find(|p| {
105 p.package_number == package.package_number
106 && (Requirement::new(&format!("={}", package.version))
107 .or_else(|| {
108 eprintln!("Failed to parse requirement {}", package.version);
109 None
110 })
111 .is_some_and(|r| {
112 Versioning::new(&p.version)
113 .or_else(|| {
114 eprintln!("Failed to parse version{}", p.version);
115 None
116 })
117 .is_some_and(|pv| r.matches(&pv))
118 })
119 || package.version == "latest")
120 }) else {
121 bail!("Did not find package {package:?} in {installed:?}");
122 };
123
124 let Some(path) = available.paths.first() else {
125 bail!("No paths for available package {available:?}");
126 };
127
128 let Some(install_dir) = options.global.install_dir.as_ref() else {
129 bail!("No install dir for global options {options:?}");
130 };
131
132 let package_install_dir = path
133 .components()
134 .last()
135 .ok_or_else(|| anyhow!("No final component in install dir {}", path.display()))?
136 .as_os_str()
137 .to_str()
138 .ok_or_else(|| anyhow!("Could not convert component to string"))?
139 .to_string();
140
141 create_dir_all(install_dir.join(&package_install_dir)).map_err(|e| {
142 anyhow!(
143 "Could not create install dir {:?}: {}",
144 install_dir.join(&package_install_dir),
145 e
146 )
147 })?;
148
149 copy_dir_contents(&path, &&install_dir.join(&package_install_dir)).map_err(|e| {
150 anyhow!(
151 "Error copying installed directory from {:?} to {:?}: {}",
152 path,
153 install_dir.join(&package_install_dir),
154 e
155 )
156 })?;
157 }
158
159 options.packages.clear();
161
162 if !options.package_paths.is_empty() {
163 ispm::packages::install(&options)?;
164 }
165 }
166
167 Ok(())
168}
169
170#[derive(TypedBuilder, Debug)]
171pub struct TestEnvSpec {
173 #[builder(setter(into))]
174 cargo_target_tmpdir: String,
175 #[builder(setter(into))]
176 name: String,
177 #[builder(default, setter(into))]
178 packages: HashSet<ProjectPackage>,
179 #[builder(default, setter(into))]
180 nonrepo_packages: HashSet<ProjectPackage>,
181 #[builder(default, setter(into))]
182 files: Vec<(String, Vec<u8>)>,
183 #[builder(default, setter(into))]
184 directories: Vec<PathBuf>,
185 #[builder(default, setter(into, strip_option))]
186 simics_home: Option<PathBuf>,
187 #[builder(default, setter(into, strip_option))]
188 package_repo: Option<String>,
189 #[builder(default = false)]
190 install_all: bool,
191 #[builder(default, setter(into))]
192 package_crates: Vec<PathBuf>,
193 #[builder(default, setter(into, strip_option))]
194 build_simics_version: Option<String>,
195 #[builder(default, setter(into))]
196 run_simics_version: Option<String>,
197}
198
199impl TestEnvSpec {
200 pub fn to_env(&self) -> Result<TestEnv> {
202 TestEnv::build(self)
203 }
204}
205
206pub struct TestEnv {
209 #[allow(unused)]
210 test_base: PathBuf,
212 test_dir: PathBuf,
214 project_dir: PathBuf,
216 #[allow(unused)]
217 simics_home_dir: PathBuf,
219}
220
221impl TestEnv {
222 pub fn default_simics_base_dir<P>(simics_home_dir: P) -> Result<PathBuf>
224 where
225 P: AsRef<Path>,
226 {
227 read_dir(simics_home_dir.as_ref())?
228 .filter_map(|d| d.ok())
229 .filter(|d| d.path().is_dir())
230 .map(|d| d.path())
231 .find(|d| {
232 d.file_name().is_some_and(|n| {
233 n.to_string_lossy().starts_with("simics-6.")
234 || n.to_string_lossy().starts_with("simics-7.")
235 })
236 })
237 .ok_or_else(|| {
238 anyhow!(
239 "No simics base in home directory {:?}",
240 simics_home_dir.as_ref()
241 )
242 })
243 }
244
245 pub fn simics_base_dir<S, P>(version: S, simics_home_dir: P) -> Result<PathBuf>
247 where
248 P: AsRef<Path>,
249 S: AsRef<str>,
250 {
251 read_dir(simics_home_dir.as_ref())?
252 .filter_map(|d| d.ok())
253 .filter(|d| d.path().is_dir())
254 .map(|d| d.path())
255 .find(|d| {
256 d.file_name()
257 .is_some_and(|n| n.to_string_lossy() == format!("simics-{}", version.as_ref()))
258 })
259 .ok_or_else(|| {
260 anyhow!(
261 "No simics base in home directory {:?}",
262 simics_home_dir.as_ref()
263 )
264 })
265 }
266}
267
268impl TestEnv {
269 pub fn install_files<P>(project_dir: P, files: &Vec<(String, Vec<u8>)>) -> Result<()>
272 where
273 P: AsRef<Path>,
274 {
275 for (name, content) in files {
276 let target = project_dir.as_ref().join(name);
277
278 if let Some(target_parent) = target.parent() {
279 if target_parent != project_dir.as_ref() {
280 create_dir_all(target_parent)?;
281 }
282 }
283 write(target, content)?;
284 }
285
286 Ok(())
287 }
288
289 pub fn install_directories<P>(project_dir: P, directories: &Vec<PathBuf>) -> Result<()>
292 where
293 P: AsRef<Path>,
294 {
295 for directory in directories {
296 copy_dir_contents(directory, &project_dir.as_ref().to_path_buf()).map_err(|e| {
297 anyhow!(
298 "Failed to copy directory contents from {:?} to {:?}: {}",
299 directory,
300 project_dir.as_ref(),
301 e
302 )
303 })?;
304 }
305
306 Ok(())
307 }
308
309 fn build(spec: &TestEnvSpec) -> Result<Self> {
310 let test_base = PathBuf::from(&spec.cargo_target_tmpdir);
311 let test_dir = test_base.join(&spec.name);
312
313 let project_dir = test_dir.join("project");
314
315 let simics_home_dir = if let Some(simics_home) = spec.simics_home.as_ref() {
316 simics_home.clone()
317 } else {
318 create_dir_all(test_dir.join("simics")).map_err(|e| {
319 anyhow!(
320 "Could not create simics home directory: {:?}: {}",
321 test_dir.join("simics"),
322 e
323 )
324 })?;
325
326 test_dir.join("simics")
327 };
328
329 if !spec.nonrepo_packages.is_empty() {
331 local_or_remote_pkg_install(
332 InstallOptions::builder()
333 .global(
334 GlobalOptions::builder()
335 .install_dir(&simics_home_dir)
336 .trust_insecure_packages(true)
337 .build(),
338 )
339 .packages(spec.nonrepo_packages.clone())
340 .build(),
341 )?;
342 }
343
344 let mut installed_packages = spec
345 .nonrepo_packages
346 .iter()
347 .cloned()
348 .collect::<HashSet<_>>();
349
350 let packages = spec.packages.clone();
351
352 if let Some(package_repo) = &spec.package_repo {
353 if !packages.is_empty() {
354 local_or_remote_pkg_install(
355 InstallOptions::builder()
356 .packages(packages.clone())
357 .global(
358 GlobalOptions::builder()
359 .install_dir(&simics_home_dir)
360 .trust_insecure_packages(true)
361 .package_repo([package_repo.to_string()])
362 .build(),
363 )
364 .build(),
365 )?;
366 }
367 } else if !packages.is_empty() {
368 local_or_remote_pkg_install(
369 InstallOptions::builder()
370 .packages(packages.clone())
371 .global(
372 GlobalOptions::builder()
373 .install_dir(&simics_home_dir)
374 .trust_insecure_packages(true)
375 .build(),
376 )
377 .build(),
378 )?;
379 }
380
381 installed_packages.extend(packages);
382
383 if spec.install_all {
384 if let Some(package_repo) = &spec.package_repo {
385 local_or_remote_pkg_install(
386 InstallOptions::builder()
387 .install_all(spec.install_all)
388 .global(
389 GlobalOptions::builder()
390 .install_dir(&simics_home_dir)
391 .trust_insecure_packages(true)
392 .package_repo([package_repo.to_string()])
393 .build(),
394 )
395 .build(),
396 )?;
397
398 let installed = ispm::packages::list(
399 &GlobalOptions::builder()
400 .install_dir(&simics_home_dir)
401 .build(),
402 )?;
403
404 if let Some(installed) = installed.installed_packages {
405 installed_packages.extend(
406 installed
407 .iter()
408 .filter(|ip| {
409 if ip.package_number == 1000 {
410 if let Some(run_version) = spec.run_simics_version.as_ref() {
411 *run_version == ip.version
412 } else {
413 true
414 }
415 } else {
416 true
417 }
418 })
419 .map(|ip| {
420 ProjectPackage::builder()
421 .package_number(ip.package_number)
422 .version(ip.version.clone())
423 .build()
424 }),
425 );
426 }
427 }
428 }
429
430 let initial_dir = current_dir()?;
431
432 spec.package_crates.iter().try_for_each(|c| {
433 set_current_dir(c)
435 .map_err(|e| anyhow!("Failed to set current directory to {c:?}: {e}"))?;
436
437 #[cfg(debug_assertions)]
438 let release = true;
439 #[cfg(not(debug_assertions))]
440 let release = false;
441
442 let install_args = Args {
443 quiet: false,
444 manifest_path: Some(c.join("Cargo.toml")),
445 package: vec![],
446 workspace: false,
447 exclude: vec![],
448 lib: false,
449 bin: vec![],
450 bins: false,
451 example: vec![],
452 examples: false,
453 release,
454 profile: None,
455 features: vec![],
456 all_features: false,
457 no_default_features: false,
458 target: None,
459 target_dir: None,
460 };
461
462 let cmd = Cmd {
463 simics_build: SimicsBuildCmd::SimicsBuild {
464 args: install_args,
465 simics_base: Some(
466 spec.build_simics_version
467 .as_ref()
468 .map(|v| Self::simics_base_dir(v, &simics_home_dir))
469 .unwrap_or_else(|| Self::default_simics_base_dir(&simics_home_dir))?,
470 ),
471 },
472 };
473
474 let package = App::run(cmd).map_err(|e| anyhow!("Error running app: {e}"))?;
475
476 let project_package = ProjectPackage::builder()
477 .package_number(
478 package
479 .file_name()
480 .ok_or_else(|| anyhow!("No file name"))?
481 .to_str()
482 .ok_or_else(|| anyhow!("Could not convert filename to string"))?
483 .split('-')
484 .nth(2)
485 .ok_or_else(|| anyhow!("No package number"))?
486 .parse::<isize>()?,
487 )
488 .version(
489 package
490 .file_name()
491 .ok_or_else(|| anyhow!("No file name"))?
492 .to_str()
493 .ok_or_else(|| anyhow!("Could not convert filename to string"))?
494 .split('-')
495 .nth(3)
496 .ok_or_else(|| anyhow!("No version"))?
497 .to_string(),
498 )
499 .build();
500
501 ispm::packages::uninstall(
504 &UninstallOptions::builder()
505 .packages([
506 project_package.clone(),
508 ])
509 .global(
510 GlobalOptions::builder()
511 .install_dir(&simics_home_dir)
512 .build(),
513 )
514 .build(),
515 )
516 .or_else(|e| {
517 if e.to_string().contains("could not be found to uninstall") {
518 Ok(())
519 } else {
520 Err(e)
521 }
522 })?;
523
524 ispm::packages::install(
525 &InstallOptions::builder()
526 .package_paths([package])
527 .global(
528 GlobalOptions::builder()
529 .install_dir(&simics_home_dir)
530 .trust_insecure_packages(true)
531 .build(),
532 )
533 .build(),
534 )?;
535
536 installed_packages.insert(project_package);
537
538 Ok::<(), Error>(())
539 })?;
540
541 set_current_dir(&initial_dir)
542 .map_err(|e| anyhow!("Failed to set current directory to {initial_dir:?}: {e}"))?;
543
544 remove_dir_all(&project_dir).or_else(|e| {
545 if e.to_string().contains("No such file or directory") {
546 Ok(())
547 } else {
548 Err(e)
549 }
550 })?;
551
552 ispm::projects::create(
554 &CreateOptions::builder()
555 .packages(installed_packages)
556 .global(
557 GlobalOptions::builder()
558 .install_dir(&simics_home_dir)
559 .trust_insecure_packages(true)
560 .build(),
561 )
562 .ignore_existing_files(true)
563 .build(),
564 &project_dir,
565 )?;
566
567 Self::install_files(&project_dir, &spec.files)?;
568 Self::install_directories(&project_dir, &spec.directories)?;
569
570 Ok(Self {
571 test_base,
572 test_dir,
573 project_dir,
574 simics_home_dir,
575 })
576 }
577
578 pub fn cleanup(&mut self) -> Result<(), CommandExtError> {
580 remove_dir_all(&self.test_dir).map_err(CommandExtError::from)
581 }
582
583 pub fn cleanup_if_env(&mut self) -> Result<(), CommandExtError> {
585 if let Ok(_cleanup) = var(SIMICS_TEST_CLEANUP_EACH_ENV) {
586 self.cleanup()?;
587 }
588
589 Ok(())
590 }
591
592 pub fn test<S>(&mut self, script: S) -> Result<Output, CommandExtError>
595 where
596 S: AsRef<str>,
597 {
598 let test_script_path = self.project_dir.join("test.simics");
599 write(test_script_path, script.as_ref())?;
600 let output = Command::new("./simics")
601 .current_dir(&self.project_dir)
602 .arg("--batch-mode")
603 .arg("--no-win")
604 .arg("./test.simics")
605 .check()?;
606 self.cleanup_if_env()?;
607 Ok(output)
608 }
609
610 pub fn test_python<S>(&mut self, script: S) -> Result<Output, CommandExtError>
613 where
614 S: AsRef<str>,
615 {
616 let test_script_path = self.project_dir.join("test.py");
617 write(test_script_path, script.as_ref())?;
618 let output = Command::new("./simics")
619 .current_dir(&self.project_dir)
620 .arg("--batch-mode")
621 .arg("--no-win")
622 .arg("./test.py")
623 .check()?;
624 self.cleanup_if_env()?;
625 Ok(output)
626 }
627}