simics_test/
lib.rs

1// Copyright (C) 2024 Intel Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4#![deny(missing_docs)]
5
6//! SIMICS test utilities for test environment setup and configuration
7
8use 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
33/// An environment variable which, if set, causes the entire test workspace to be cleaned up
34/// after the test
35pub const SIMICS_TEST_CLEANUP_EACH_ENV: &str = "SIMICS_TEST_CLEANUP_EACH";
36/// An environment variable which, if set, causes package installation to default to local installation
37/// only
38pub const SIMICS_TEST_LOCAL_PACKAGES_ONLY_ENV: &str = "SIMICS_TEST_LOCAL_PACKAGES_ONLY";
39
40/// Copy the contents of one directory to another, recursively, overwriting files if they exist but
41/// without replacing directories or their contents if they already exist
42pub 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
92/// Abstract install procedure for public and internal ISPM
93pub 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        // Clear the remote packages to install, we can install local paths no problem
160        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)]
171/// A specification for a test environment
172pub 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    /// Convert the specification for a test environment to a built test environment
201    pub fn to_env(&self) -> Result<TestEnv> {
202        TestEnv::build(self)
203    }
204}
205
206/// A test environment, which is a directory that consists of a simics directory with a set of
207/// installed packages and a project directory, where test scripts and resources can be placed.
208pub struct TestEnv {
209    #[allow(unused)]
210    /// The base of the test environment, e.g. the `CARGO_TARGET_TMPDIR` directory
211    test_base: PathBuf,
212    /// The subdirectory in the test environment for this test
213    test_dir: PathBuf,
214    /// The project subdirectory in the test environment for this test
215    project_dir: PathBuf,
216    #[allow(unused)]
217    /// The simics home subdirectory in the test environment for this test
218    simics_home_dir: PathBuf,
219}
220
221impl TestEnv {
222    /// Return a reference to the test base directory
223    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    /// Return a reference to the base directory specified by a version
246    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    /// Install a set of files into a project directory, with the files specified as relative
270    /// paths inside the project directory and their raw contents
271    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    /// Install a set of existing directories into a project, where each directory will be
290    /// copied recursively into the project
291    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        // Install nonrepo packages which do not use a possibly-provided package repo
330        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            // change directory to c
434            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            // Uninstall first, then install. Uninstall is allowed to fail if the output
502            // contains 'could not be found to uninstall'
503            ispm::packages::uninstall(
504                &UninstallOptions::builder()
505                    .packages([
506                        // Package file names are always 'simics-pkg-<package_number>-<version>-<host>.ispm'
507                        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        // Create the project using the installed packages
553        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    /// Clean up the test environment
579    pub fn cleanup(&mut self) -> Result<(), CommandExtError> {
580        remove_dir_all(&self.test_dir).map_err(CommandExtError::from)
581    }
582
583    /// Clean up the test environment if the SIMICS_TEST_CLEANUP_EACH environment variable is set
584    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    /// Run a test in the environment in the form of a Simics script. To fail the test, either
593    /// exit Simics with an error or check the output result.
594    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    /// Run a test in the environment in the form of a Simics script. To fail the test, either
611    /// exit Simics with an error or check the output result.
612    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}