Skip to main content

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
37/// Opportunistically copy package 1033 (mini-python) from the global ISPM install to a local
38/// simics home directory. Package 1033 is required for Simics 7.28.0+ and is separate from the
39/// base package 1000. Returns `None` silently when 1033 is not installed (e.g. Simics 6).
40fn 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
63/// An environment variable which, if set, causes package installation to default to local installation
64/// only
65pub const SIMICS_TEST_LOCAL_PACKAGES_ONLY_ENV: &str = "SIMICS_TEST_LOCAL_PACKAGES_ONLY";
66
67/// Copy the contents of one directory to another, recursively, overwriting files if they exist but
68/// without replacing directories or their contents if they already exist
69pub 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
119/// Abstract install procedure for public and internal ISPM
120pub 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        // Clear the remote packages to install, we can install local paths no problem
187        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)]
198/// A specification for a test environment
199pub 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    /// Convert the specification for a test environment to a built test environment
228    pub fn to_env(&self) -> Result<TestEnv> {
229        TestEnv::build(self)
230    }
231}
232
233/// A test environment, which is a directory that consists of a simics directory with a set of
234/// installed packages and a project directory, where test scripts and resources can be placed.
235pub struct TestEnv {
236    #[allow(unused)]
237    /// The base of the test environment, e.g. the `CARGO_TARGET_TMPDIR` directory
238    test_base: PathBuf,
239    /// The subdirectory in the test environment for this test
240    test_dir: PathBuf,
241    /// The project subdirectory in the test environment for this test
242    project_dir: PathBuf,
243    #[allow(unused)]
244    /// The simics home subdirectory in the test environment for this test
245    simics_home_dir: PathBuf,
246}
247
248impl TestEnv {
249    /// Return a reference to the test base directory
250    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    /// Return a reference to the base directory specified by a version
273    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    /// Install a set of files into a project directory, with the files specified as relative
297    /// paths inside the project directory and their raw contents
298    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    /// Install a set of existing directories into a project, where each directory will be
317    /// copied recursively into the project
318    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        // Install nonrepo packages which do not use a possibly-provided package repo
357        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        // Opportunistically include package 1033 (mini-python) if available (Simics 7.28.0+).
411        // This is a no-op for Simics 6, which bundles Python inside package 1000.
412        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            // change directory to c
467            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            // Uninstall first, then install. Uninstall is allowed to fail if the output
535            // contains 'could not be found to uninstall'
536            ispm::packages::uninstall(
537                &UninstallOptions::builder()
538                    .packages([
539                        // Package file names are always 'simics-pkg-<package_number>-<version>-<host>.ispm'
540                        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        // Create the project using the installed packages
588        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    /// Clean up the test environment
614    pub fn cleanup(&mut self) -> Result<(), CommandExtError> {
615        remove_dir_all(&self.test_dir).map_err(CommandExtError::from)
616    }
617
618    /// Clean up the test environment if the SIMICS_TEST_CLEANUP_EACH environment variable is set
619    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    /// Run a test in the environment in the form of a Simics script. To fail the test, either
628    /// exit Simics with an error or check the output result.
629    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    /// Run a test in the environment in the form of a Simics script. To fail the test, either
646    /// exit Simics with an error or check the output result.
647    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}