use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use anyhow::{anyhow, Context, Result};
use semver::Version;
use shell_words;
use tempfile::TempDir;
use clyde::app::App;
use clyde::arch_os::ArchOs;
use clyde::file_utils::{get_file_name, prepend_dir_to_path};
use clyde::package::Package;
use clyde::store::INDEX_NAME;
use clyde::ui::Ui;
use clyde::vars::{expand_vars, VarsMap};
struct FailedPackage {
package_path: PathBuf,
error_message: String,
}
impl FailedPackage {
fn new(package_path: &Path, error_message: &str) -> FailedPackage {
FailedPackage {
package_path: package_path.to_path_buf(),
error_message: error_message.to_string(),
}
}
}
fn check_has_release_assets(package: &Package) -> Result<()> {
if package.releases.is_empty() {
return Err(anyhow!("No releases"));
}
for (version, release) in package.releases.iter() {
if release.is_empty() {
return Err(anyhow!("No release assets for version {}", version));
}
}
Ok(())
}
fn check_has_installs(package: &Package) -> Result<()> {
if package.installs.is_empty() {
return Err(anyhow!("No installs"));
}
for (version, installs_for_arch_os) in package.installs.iter() {
if installs_for_arch_os.is_empty() {
return Err(anyhow!("No install for version {}", version));
}
}
Ok(())
}
fn get_latest_version(package: &Package) -> Option<Version> {
let version = package.get_latest_version().unwrap();
package
.get_asset(version, &ArchOs::current())
.map(|_| version.clone())
}
fn run_test_command(report: &mut Vec<String>, home_dir: &Path, test_command: &str) -> Result<()> {
let clyde_bin_dir = home_dir.join("inst").join("bin");
let new_path = prepend_dir_to_path(&clyde_bin_dir)?;
let words = shell_words::split(test_command)?;
let mut iter = words.iter();
let binary = iter
.next()
.ok_or_else(|| anyhow!("Test command is empty"))?;
let args: Vec<String> = iter.map(|x| x.into()).collect();
run_command(
report,
Command::new(binary)
.env("PATH", new_path)
.env("CLYDE_HOME", home_dir)
.args(args),
)
}
fn create_vars_map() -> VarsMap {
let mut map = VarsMap::new();
map.insert(
"exe_ext".into(),
if cfg!(windows) {
".exe".into()
} else {
"".into()
},
);
map
}
fn string_for_command_output(output: &Output) -> String {
let mut string = String::new();
string.push_str("--- Stdout ---\n");
string.push_str(&String::from_utf8_lossy(&output.stdout));
string.push_str("--- Stderr ---\n");
string.push_str(&String::from_utf8_lossy(&output.stderr));
string
}
fn run_command(report: &mut Vec<String>, command: &mut Command) -> Result<()> {
let command_str = format!("{:?} {:?}", command.get_program(), command.get_args());
report.push(format!("Running {command_str}"));
let output = command.output().context("Failed to execute command")?;
match output.status.code() {
Some(0) => Ok(()),
Some(x) => {
report.push(format!("Command failed with exit code {x}"));
report.push(string_for_command_output(&output));
Err(anyhow!("Command failed with exit code {x}"))
}
None => {
report.push("Command terminated by signal".to_string());
Err(anyhow!("Command terminated by signal"))
}
}
}
fn check_can_install(package: &Package, package_path: &Path, version: &Version) -> Result<()> {
let mut report = Vec::<String>::new();
report.push("### Setup Clyde home".to_string());
let home_temp_dir = TempDir::new()?;
let home_dir = home_temp_dir.path();
let store_dir = home_dir.join("store");
fs::create_dir(&store_dir)
.with_context(|| format!("Could not create store directory {store_dir:?}"))?;
let app = App::new(home_dir).context("Could not create test Clyde home")?;
app.database.create()?;
report.push("\n### Install package\n".to_string());
let package_str = package_path.as_os_str().to_str().unwrap();
run_command(
&mut report,
Command::new("clyde")
.arg("install")
.arg(format!("{package_str}@={version}"))
.env("CLYDE_HOME", home_dir.as_os_str()),
)
.map_err(|_| anyhow!(report.join("\n")))?;
report.push("\n### Running test commmands\n".to_string());
let install = package.get_install(version, &ArchOs::current()).unwrap();
let vars = create_vars_map();
for test_command in &install.tests {
let test_command = expand_vars(test_command, &vars)?;
run_test_command(&mut report, home_dir, &test_command)
.map_err(|_| anyhow!(report.join("\n")))?;
}
Ok(())
}
fn check_package_name(package: &Package, path: &Path) -> Result<()> {
let file_name = get_file_name(path)?;
let package_file_name = if file_name == INDEX_NAME {
get_file_name(path.parent().unwrap())?
} else {
match file_name.rsplit_once('.') {
Some((stem, _ext)) => stem,
None => {
return Err(anyhow!("Invalid package name ({})", path.display()));
}
}
};
if package.name != package_file_name {
return Err(anyhow!(
"Package name ({}) must match the package file name ({})",
package.name,
package_file_name
));
}
Ok(())
}
fn load_package(path: &Path) -> Result<Package> {
let path = path.canonicalize()?;
let package = Package::from_file(&path)?;
check_package_name(&package, &path)?;
Ok(package)
}
fn check_package(package: &Package, path: &Path) -> Result<bool> {
check_has_release_assets(package)?;
check_has_installs(package)?;
let version = match get_latest_version(package) {
Some(x) => x,
None => {
return Ok(false);
}
};
check_can_install(package, path, &version)?;
Ok(true)
}
fn print_summary_line(header: &str, packages: &[String]) {
let joined = packages.join(", ");
println!("{header}: {joined}");
}
pub fn check_packages(ui: &Ui, paths: &Vec<PathBuf>) -> Result<()> {
let mut ok_packages = Vec::<String>::new();
let mut not_on_arch_os_packages = Vec::<String>::new();
let mut failed_packages = Vec::<FailedPackage>::new();
let count = paths.len();
let mut idx = 1;
for path in paths {
print!(
"[{idx}/{count} {:3}%] {}: ",
100 * idx / count,
path.display()
);
io::stdout().lock().flush().unwrap_or_default();
idx += 1;
let package = match load_package(path) {
Ok(x) => x,
Err(message) => {
failed_packages.push(FailedPackage::new(path, &message.to_string()));
println!("FAIL");
continue;
}
};
let name = package.name.clone();
match check_package(&package, path) {
Ok(true) => {
ok_packages.push(name);
println!("OK");
}
Ok(false) => {
not_on_arch_os_packages.push(name);
println!("OK (not on arch-os)");
}
Err(message) => {
failed_packages.push(FailedPackage::new(path, &message.to_string()));
println!("FAIL");
}
};
}
ui.info("Finished");
print_summary_line("OK", &ok_packages);
print_summary_line("N/A", ¬_on_arch_os_packages);
if !failed_packages.is_empty() {
println!("# Failed packages\n");
for failed_package in &failed_packages {
println!("## {}", failed_package.package_path.display());
println!("\n{}\n", failed_package.error_message);
}
return Err(anyhow!("{} package(s) failed", failed_packages.len()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clyde::package::Package;
#[test]
fn check_has_release_assets_fails_if_a_release_has_no_assets() {
let package = Package::from_yaml_str(
"
name: test
description: desc
homepage:
releases:
1.2.0:
",
)
.unwrap();
let result = check_has_release_assets(&package);
assert!(result.is_err());
}
#[test]
fn check_has_release_assets_fails_if_it_has_no_releases() {
let package = Package::from_yaml_str(
"
name: test
description: desc
homepage:
releases:
",
)
.unwrap();
let result = check_has_release_assets(&package);
assert!(result.is_err());
}
}