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 name(&self) -> String {
let Ok(file_name) = get_file_name(&self.package_path) else {
return self.package_path.display().to_string();
};
if file_name != INDEX_NAME {
return file_name.replace(".yaml", "");
}
let Some(parent) = self.package_path.parent() else {
return self.package_path.display().to_string();
};
match get_file_name(parent) {
Ok(x) => x.to_string(),
Err(_) => self.package_path.display().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.assets.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 report_command_output(report: &mut Vec<String>, output: &Output) {
report.push("STDOUT".into());
report.push(String::from_utf8_lossy(&output.stdout).into());
report.push("STDERR".into());
report.push(String::from_utf8_lossy(&output.stderr).into());
}
fn run_command(report: &mut Vec<String>, command: &mut Command) -> Result<()> {
let mut command_str = format!("{:?}", command.get_program());
for arg in command.get_args() {
command_str.push_str(&format!(" {:?}", arg));
}
report.push(format!("Running {command_str}"));
let output = match command.output() {
Ok(x) => x,
Err(x) => {
report.push(format!("Failed to execute command: {x}"));
return Err(anyhow!("Failed to execute command: {x}"));
}
};
report_command_output(report, &output);
match output.status.code() {
Some(0) => Ok(()),
Some(x) => {
report.push(format!("Command failed with exit code {x}"));
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 commands\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(ui: &Ui, count: usize, header: &str, packages: &[String]) {
ui.info(&format!("{header} ({}/{count})", packages.len()));
if !packages.is_empty() {
let joined = packages.join(", ");
println!("\n{joined}\n");
}
}
pub fn check_packages(ui: &Ui, paths: &[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();
for (idx, path) in paths.iter().enumerate() {
print!(
"[{idx}/{count} {:3}%] {}: ",
100 * (idx + 1) / count,
path.display()
);
io::stdout().lock().flush().unwrap_or_default();
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");
let failed_package_names: Vec<_> = failed_packages.iter().map(|x| x.name()).collect();
print_summary_line(ui, count, "OK", &ok_packages);
print_summary_line(ui, count, "N/A", ¬_on_arch_os_packages);
print_summary_line(ui, count, "Failed", &failed_package_names);
if !failed_packages.is_empty() {
println!("\n# Failed packages details\n");
for failed_package in &failed_packages {
println!("## {}", failed_package.name());
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:
assets: {}
",
)
.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());
}
#[test]
fn run_command_reports_include_command_output() {
let mut report = Vec::<String>::new();
let result = run_command(&mut report, Command::new("cargo").args([&"foo"]));
assert!(result.is_err());
for line in &report {
println!("{}", line);
}
let running_line = report.get(0).unwrap();
assert!(running_line.contains("Running \"cargo"));
assert_eq!(report.get(1).unwrap(), "STDOUT");
assert_eq!(report.get(3).unwrap(), "STDERR");
assert!(report.get(4).unwrap().contains("no such command: `foo`"));
assert_eq!(report.last().unwrap(), "Command failed with exit code 101");
}
}