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