diff --git a/Cargo.lock b/Cargo.lock index 09df3eb..301f62c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -747,7 +747,9 @@ name = "espanso-package" version = "0.1.0" dependencies = [ "anyhow", + "glob", "log", + "natord", "serde", "serde_json", "serde_yaml", @@ -1311,6 +1313,12 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "natord" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" + [[package]] name = "net2" version = "0.2.37" diff --git a/espanso-package/Cargo.toml b/espanso-package/Cargo.toml index 19e9b88..df84799 100644 --- a/espanso-package/Cargo.toml +++ b/espanso-package/Cargo.toml @@ -11,4 +11,6 @@ thiserror = "1.0.23" serde = { version = "1.0.123", features = ["derive"] } serde_json = "1.0.62" serde_yaml = "0.8.17" -tempdir = "0.3.7" \ No newline at end of file +tempdir = "0.3.7" +glob = "0.3.0" +natord = "1.0.9" \ No newline at end of file diff --git a/espanso-package/src/lib.rs b/espanso-package/src/lib.rs index 58d6711..718074a 100644 --- a/espanso-package/src/lib.rs +++ b/espanso-package/src/lib.rs @@ -22,6 +22,9 @@ use std::path::Path; use anyhow::Result; use thiserror::Error; +mod manifest; +mod package; +mod provider; mod resolver; #[derive(Debug, Default)] @@ -35,7 +38,7 @@ pub struct PackageSpecifier { } pub trait Package { - // Metadata + // Manifest fn name(&self) -> &str; fn title(&self) -> &str; fn description(&self) -> &str; @@ -46,13 +49,13 @@ pub trait Package { fn location(&self) -> &Path; } -pub trait PackageResolver { +pub trait PackageProvider { fn download(&self, package: &PackageSpecifier) -> Result>; // TODO: fn check update available? // TODO: fn update } -// TODO: the git resolver should delete the .git directory +// TODO: once the download is completed, avoid copying files beginning with "." #[derive(Error, Debug)] pub enum PackageResolutionError { diff --git a/espanso-package/src/manifest.rs b/espanso-package/src/manifest.rs new file mode 100644 index 0000000..172a3a1 --- /dev/null +++ b/espanso-package/src/manifest.rs @@ -0,0 +1,41 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use std::path::Path; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct Manifest { + pub name: String, + pub title: String, + pub description: String, + pub version: String, + pub author: String, +} + +impl Manifest { + pub fn parse(manifest_path: &Path) -> Result { + let manifest_str = std::fs::read_to_string(manifest_path)?; + Ok(serde_yaml::from_str(&manifest_str)?) + } +} + +// TODO: test diff --git a/espanso-package/src/package.rs b/espanso-package/src/package.rs new file mode 100644 index 0000000..0733fbb --- /dev/null +++ b/espanso-package/src/package.rs @@ -0,0 +1,74 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use std::path::PathBuf; + +use tempdir::TempDir; + +use crate::{Package, manifest::Manifest}; + +#[allow(dead_code)] +pub struct DefaultPackage { + manifest: Manifest, + + temp_dir: TempDir, + + // Sub-directory inside the temp_dir + location: PathBuf, +} + +impl DefaultPackage { + pub fn new( + manifest: Manifest, + temp_dir: TempDir, + location: PathBuf, + ) -> Self { + Self { + manifest, + temp_dir, + location, + } + } +} + +impl Package for DefaultPackage { + fn name(&self) -> &str { + self.manifest.name.as_str() + } + + fn title(&self) -> &str { + self.manifest.title.as_str() + } + + fn description(&self) -> &str { + self.manifest.description.as_str() + } + + fn version(&self) -> &str { + self.manifest.version.as_str() + } + + fn author(&self) -> &str { + self.manifest.author.as_str() + } + + fn location(&self) -> &std::path::Path { + self.location.as_path() + } +} diff --git a/espanso-package/src/resolver/git.rs b/espanso-package/src/provider/git.rs similarity index 64% rename from espanso-package/src/resolver/git.rs rename to espanso-package/src/provider/git.rs index 5441113..68f111f 100644 --- a/espanso-package/src/resolver/git.rs +++ b/espanso-package/src/provider/git.rs @@ -17,13 +17,17 @@ * along with espanso. If not, see . */ -use crate::{Package, PackageResolver, PackageSpecifier}; -use anyhow::{bail, Result, Context}; +use crate::{ + package::DefaultPackage, + resolver::{resolve_package}, + Package, PackageProvider, PackageSpecifier, +}; +use anyhow::{anyhow, bail, Context, Result}; use std::{path::Path, process::Command}; -pub struct GitPackageResolver {} +pub struct GitPackageProvider {} -impl GitPackageResolver { +impl GitPackageProvider { pub fn new() -> Self { Self {} } @@ -53,7 +57,10 @@ impl GitPackageResolver { let dest_dir_str = dest_dir.to_string_lossy().to_string(); args.push(&dest_dir_str); - let output = Command::new("git").args(&args).output().context("git command reported error")?; + let output = Command::new("git") + .args(&args) + .output() + .context("git command reported error")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -64,15 +71,31 @@ impl GitPackageResolver { } } -impl PackageResolver for GitPackageResolver { +impl PackageProvider for GitPackageProvider { fn download(&self, package: &PackageSpecifier) -> Result> { if !Self::is_git_installed() { bail!("unable to invoke 'git' command, please make sure it is installed and visible in PATH"); } - // TODO: download repository in temp directory - // TODO: read metadata + let repo_url = package + .git_repo_url + .as_deref() + .ok_or_else(|| anyhow!("missing git repository url"))?; + let repo_branch = package.git_branch.as_deref(); - todo!() + let temp_dir = tempdir::TempDir::new("espanso-package-download")?; + + Self::clone_repo(temp_dir.path(), repo_url, repo_branch)?; + + let resolved_package = + resolve_package(temp_dir.path(), &package.name, package.version.as_deref())?; + + let package = DefaultPackage::new( + resolved_package.manifest, + temp_dir, + resolved_package.base_dir, + ); + + Ok(Box::new(package)) } } diff --git a/espanso-package/src/resolver/mod.rs b/espanso-package/src/provider/mod.rs similarity index 100% rename from espanso-package/src/resolver/mod.rs rename to espanso-package/src/provider/mod.rs diff --git a/espanso-package/src/resolver.rs b/espanso-package/src/resolver.rs new file mode 100644 index 0000000..380b20d --- /dev/null +++ b/espanso-package/src/resolver.rs @@ -0,0 +1,360 @@ +/* +* This file is part of espanso. +* +* Copyright (C) 2019-2021 Federico Terzi +title: (), description: (), version: (), author: () * +* espanso is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* espanso is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with espanso. If not, see . +*/ + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; + +use crate::manifest::Manifest; + +#[derive(Debug, PartialEq)] +pub struct ResolvedPackage { + pub manifest: Manifest, + pub base_dir: PathBuf, +} + +pub fn resolve_package( + base_dir: &Path, + name: &str, + version: Option<&str>, +) -> Result { + let packages = resolve_all_packages(base_dir)?; + + let mut matching_packages: Vec = packages + .into_iter() + .filter(|package| package.manifest.name == name) + .collect(); + + if matching_packages.is_empty() { + bail!("no package found with name: {}", name); + } + + matching_packages.sort_by(|a, b| natord::compare(&a.manifest.version, &b.manifest.version)); + + let matching_package = if let Some(explicit_version) = version { + matching_packages + .into_iter() + .find(|package| package.manifest.version == explicit_version) + } else { + matching_packages.into_iter().last() + }; + + if let Some(matching_package) = matching_package { + Ok(matching_package) + } else { + bail!( + "unable to find version: {:?} for package: {}", + version, + name + ); + } +} + +pub fn resolve_all_packages(base_dir: &Path) -> Result> { + let manifest_files = find_all_manifests(base_dir)?; + + if manifest_files.is_empty() { + bail!("no manifests found in base_dir"); + } + + let mut manifests = Vec::new(); + + for manifest_file in manifest_files { + let base_dir = manifest_file + .parent() + .ok_or(anyhow!("unable to determine base_dir from manifest path"))? + .to_owned(); + let manifest = Manifest::parse(&manifest_file).context("manifest YAML parsing error")?; + manifests.push(ResolvedPackage { manifest, base_dir }); + } + + Ok(manifests) +} + +fn find_all_manifests(base_dir: &Path) -> Result> { + let pattern = format!("{}/{}", base_dir.to_string_lossy(), "**/_manifest.yml"); + + let mut manifests = Vec::new(); + + for entry in glob::glob(&pattern)? { + let path = entry?; + manifests.push(path); + } + + Ok(manifests) +} + +#[cfg(test)] +mod tests { + use std::fs::create_dir_all; + + use super::*; + use tempdir::TempDir; + + fn run_with_temp_dir(action: impl FnOnce(&Path)) { + let tmp_dir = TempDir::new("espanso-package").unwrap(); + let tmp_path = tmp_dir.path(); + + action(&tmp_path); + } + + #[test] + fn test_read_manifest_base_dir() { + run_with_temp_dir(|base_dir| { + std::fs::write( + base_dir.join("_manifest.yml"), + r#" + name: package1 + title: Package 1 + author: Federico + version: 0.1.0 + description: An awesome package + "#, + ) + .unwrap(); + + let packages = resolve_all_packages(base_dir).unwrap(); + + assert_eq!( + packages, + vec![ResolvedPackage { + manifest: Manifest { + name: "package1".to_owned(), + title: "Package 1".to_owned(), + version: "0.1.0".to_owned(), + author: "Federico".to_owned(), + description: "An awesome package".to_owned(), + }, + base_dir: base_dir.to_path_buf(), + },] + ) + }); + } + + #[test] + fn test_read_manifests_nested_dirs() { + run_with_temp_dir(|base_dir| { + let sub_dir1 = base_dir.join("package1"); + let version_dir1 = sub_dir1.join("0.1.0"); + create_dir_all(&version_dir1).unwrap(); + + std::fs::write( + version_dir1.join("_manifest.yml"), + r#" + name: package1 + title: Package 1 + author: Federico + version: 0.1.0 + description: An awesome package + "#, + ) + .unwrap(); + + let sub_dir2 = base_dir.join("package1"); + let version_dir2 = sub_dir2.join("0.1.1"); + create_dir_all(&version_dir2).unwrap(); + + std::fs::write( + version_dir2.join("_manifest.yml"), + r#" + name: package1 + title: Package 1 + author: Federico + version: 0.1.1 + description: An awesome package + "#, + ) + .unwrap(); + + let sub_dir3 = base_dir.join("package2"); + create_dir_all(&sub_dir3).unwrap(); + + std::fs::write( + sub_dir3.join("_manifest.yml"), + r#" + name: package2 + title: Package 2 + author: Federico + version: 2.0.0 + description: Another awesome package + "#, + ) + .unwrap(); + + let packages = resolve_all_packages(base_dir).unwrap(); + + assert_eq!( + packages, + vec![ + ResolvedPackage { + manifest: Manifest { + name: "package1".to_owned(), + title: "Package 1".to_owned(), + version: "0.1.0".to_owned(), + author: "Federico".to_owned(), + description: "An awesome package".to_owned(), + }, + base_dir: version_dir1, + }, + ResolvedPackage { + manifest: Manifest { + name: "package1".to_owned(), + title: "Package 1".to_owned(), + version: "0.1.1".to_owned(), + author: "Federico".to_owned(), + description: "An awesome package".to_owned(), + }, + base_dir: version_dir2, + }, + ResolvedPackage { + manifest: Manifest { + name: "package2".to_owned(), + title: "Package 2".to_owned(), + version: "2.0.0".to_owned(), + author: "Federico".to_owned(), + description: "Another awesome package".to_owned(), + }, + base_dir: sub_dir3, + }, + ] + ) + }); + } + + #[test] + fn test_resolve_package() { + run_with_temp_dir(|base_dir| { + let sub_dir1 = base_dir.join("package1"); + let version_dir1 = sub_dir1.join("0.1.0"); + create_dir_all(&version_dir1).unwrap(); + + std::fs::write( + version_dir1.join("_manifest.yml"), + r#" + name: package1 + title: Package 1 + author: Federico + version: 0.1.0 + description: An awesome package + "#, + ) + .unwrap(); + + let sub_dir2 = base_dir.join("package1"); + let version_dir2 = sub_dir2.join("0.1.1"); + create_dir_all(&version_dir2).unwrap(); + + std::fs::write( + version_dir2.join("_manifest.yml"), + r#" + name: package1 + title: Package 1 + author: Federico + version: 0.1.1 + description: An awesome package + "#, + ) + .unwrap(); + + let sub_dir3 = base_dir.join("package2"); + create_dir_all(&sub_dir3).unwrap(); + + std::fs::write( + sub_dir3.join("_manifest.yml"), + r#" + name: package2 + title: Package 2 + author: Federico + version: 2.0.0 + description: Another awesome package + "#, + ) + .unwrap(); + + assert_eq!( + resolve_package(base_dir, "package1", None).unwrap(), + ResolvedPackage { + manifest: Manifest { + name: "package1".to_owned(), + title: "Package 1".to_owned(), + version: "0.1.1".to_owned(), + author: "Federico".to_owned(), + description: "An awesome package".to_owned(), + }, + base_dir: version_dir2, + }, + ); + + assert_eq!( + resolve_package(base_dir, "package1", Some("0.1.0")).unwrap(), + ResolvedPackage { + manifest: Manifest { + name: "package1".to_owned(), + title: "Package 1".to_owned(), + version: "0.1.0".to_owned(), + author: "Federico".to_owned(), + description: "An awesome package".to_owned(), + }, + base_dir: version_dir1, + }, + ); + + assert_eq!( + resolve_package(base_dir, "package2", None).unwrap(), + ResolvedPackage { + manifest: Manifest { + name: "package2".to_owned(), + title: "Package 2".to_owned(), + version: "2.0.0".to_owned(), + author: "Federico".to_owned(), + description: "Another awesome package".to_owned(), + }, + base_dir: sub_dir3, + }, + ); + + assert!(resolve_package(base_dir, "invalid", None).is_err()); + assert!(resolve_package(base_dir, "package1", Some("9.9.9")).is_err()); + }); + } + + #[test] + fn test_no_manifest_error() { + run_with_temp_dir(|base_dir| { + assert_eq!(resolve_all_packages(base_dir).is_err(), true); + }); + } + + #[test] + fn test_malformed_manifest() { + run_with_temp_dir(|base_dir| { + std::fs::write( + base_dir.join("_manifest.yml"), + r#" + name: package1 + title: Package 1 + author: Federico + "#, + ) + .unwrap(); + + assert!(resolve_all_packages(base_dir).is_err()); + }); + } +}