diff options
26 files changed, 2191 insertions, 251 deletions
diff --git a/crates/shirabe-php-shim/src/datetime.rs b/crates/shirabe-php-shim/src/datetime.rs index f44bb7b..8d1062c 100644 --- a/crates/shirabe-php-shim/src/datetime.rs +++ b/crates/shirabe-php-shim/src/datetime.rs @@ -63,6 +63,7 @@ pub const DATE_ATOM: &str = DATE_RFC3339; pub fn date_format_to_strftime(format: &str) -> &'static str { match format { "Y-m-d H:i:s" => "%Y-%m-%d %H:%M:%S", + "Y/m/d H:i:s" => "%Y/%m/%d %H:%M:%S", "Y-m-d Hi" => "%Y-%m-%d %H%M", "Y-m-d" => "%Y-%m-%d", "Ymd" => "%Y%m%d", diff --git a/crates/shirabe-php-shim/src/fs.rs b/crates/shirabe-php-shim/src/fs.rs index 2fb0dc5..a557d26 100644 --- a/crates/shirabe-php-shim/src/fs.rs +++ b/crates/shirabe-php-shim/src/fs.rs @@ -672,15 +672,43 @@ pub fn fwrite_resource(resource: &PhpResource, data: &str) { fwrite(resource, data, None); } -pub fn touch2(_path: &str, _mtime: i64) -> bool { - // TODO(phase-d): setting an explicit mtime needs utimensat(2), not exposed by std (no - // libc/filetime crate available). - todo!() +// libc is already linked into every binary, so `utime` can be declared directly without an extra +// crate (mirrors the `fcntl`/`statvfs` declarations elsewhere). PHP's `touch($path, $mtime, $atime)` +// passes whole seconds, which matches `struct utimbuf`'s `time_t` fields. +#[repr(C)] +struct Utimbuf { + actime: std::os::raw::c_long, + modtime: std::os::raw::c_long, } -pub fn touch3(_path: &str, _mtime: i64, _atime: i64) -> bool { - // TODO(phase-d): setting explicit mtime/atime needs utimensat(2); see touch2. - todo!() +unsafe extern "C" { + fn utime(path: *const std::os::raw::c_char, times: *const Utimbuf) -> std::os::raw::c_int; +} + +fn touch_impl(path: &str, mtime: i64, atime: i64) -> bool { + let Ok(c_path) = std::ffi::CString::new(path) else { + return false; + }; + // PHP's `touch` creates the file first if it does not exist. + if !std::path::Path::new(path).exists() && !touch(path) { + return false; + } + let times = Utimbuf { + actime: atime as std::os::raw::c_long, + modtime: mtime as std::os::raw::c_long, + }; + unsafe { utime(c_path.as_ptr(), ×) == 0 } +} + +/// PHP `touch($path, $mtime)`: sets the modification time (and access time, per PHP, to the same +/// value). Returns `false` (PHP failure) on error. +pub fn touch2(path: &str, mtime: i64) -> bool { + touch_impl(path, mtime, mtime) +} + +/// PHP `touch($path, $mtime, $atime)`. +pub fn touch3(path: &str, mtime: i64, atime: i64) -> bool { + touch_impl(path, mtime, atime) } pub fn chmod(_path: &str, _mode: u32) -> bool { @@ -1077,10 +1105,48 @@ pub fn clearstatcache2(_clear_realpath_cache: bool, _filename: &str) { // cache to invalidate. } +// libc is already linked into every binary, so `statvfs` can be declared directly without an extra +// crate (mirrors the `fcntl`/`select` declarations in stream.rs). The layout below matches Linux's +// `struct statvfs`. +#[cfg(target_os = "linux")] +#[repr(C)] +struct Statvfs { + f_bsize: std::os::raw::c_ulong, + f_frsize: std::os::raw::c_ulong, + f_blocks: u64, + f_bfree: u64, + f_bavail: u64, + f_files: u64, + f_ffree: u64, + f_favail: u64, + f_fsid: std::os::raw::c_ulong, + f_flag: std::os::raw::c_ulong, + f_namemax: std::os::raw::c_ulong, + f_spare: [std::os::raw::c_int; 6], +} + +#[cfg(target_os = "linux")] +unsafe extern "C" { + fn statvfs(path: *const std::os::raw::c_char, buf: *mut Statvfs) -> std::os::raw::c_int; +} + +/// PHP `disk_free_space()`: the number of available bytes on the filesystem containing `directory`, +/// computed via `statvfs(3)` (`f_bavail * f_frsize`). Returns `None` (PHP `false`) on failure. +#[cfg(target_os = "linux")] +pub fn disk_free_space(directory: &str) -> Option<f64> { + let c_path = std::ffi::CString::new(directory).ok()?; + let mut buf = std::mem::MaybeUninit::<Statvfs>::uninit(); + let rc = unsafe { statvfs(c_path.as_ptr(), buf.as_mut_ptr()) }; + if rc != 0 { + return None; + } + let buf = unsafe { buf.assume_init() }; + Some(buf.f_bavail as f64 * buf.f_frsize as f64) +} + +#[cfg(not(target_os = "linux"))] pub fn disk_free_space(_directory: &str) -> Option<f64> { - // TODO(phase-d): reading free space for an arbitrary path requires statvfs(3); std exposes no - // equivalent and no /proc file gives per-path free space (no libc/syscall crate available). - todo!() + None } pub const GLOB_MARK: i64 = 8; diff --git a/crates/shirabe/src/command/licenses_command.rs b/crates/shirabe/src/command/licenses_command.rs index 3c1745a..3be4df3 100644 --- a/crates/shirabe/src/command/licenses_command.rs +++ b/crates/shirabe/src/command/licenses_command.rs @@ -107,8 +107,7 @@ impl Command for LicensesCommand { input: Rc<RefCell<dyn InputInterface>>, output: Rc<RefCell<dyn OutputInterface>>, ) -> anyhow::Result<i64> { - let composer = self.require_composer(None, None)?; - let mut composer = crate::command::composer_full_mut(&composer); + let composer_handle = self.require_composer(None, None)?; // TODO(plugin): dispatch COMMAND event for plugin hooks let command_event = CommandEvent::new( @@ -117,11 +116,14 @@ impl Command for LicensesCommand { input.clone(), output.clone(), ); - composer - .get_event_dispatcher() + // The event dispatcher reads back through the shared Composer handle (script listeners), so the + // dispatch must run while no other borrow of that handle is held. + let event_dispatcher = composer_handle.borrow_partial().get_event_dispatcher(); + event_dispatcher .borrow_mut() .dispatch(Some(command_event.get_name()), None); + let mut composer = crate::command::composer_full_mut(&composer_handle); let root = composer.get_package(); let packages = if input diff --git a/crates/shirabe/src/package/loader/array_loader.rs b/crates/shirabe/src/package/loader/array_loader.rs index 844b3ef..20280e0 100644 --- a/crates/shirabe/src/package/loader/array_loader.rs +++ b/crates/shirabe/src/package/loader/array_loader.rs @@ -370,7 +370,7 @@ impl ArrayLoader { package.package_mut().set_is_default_branch(true); } - if let Some(source) = config.get("source").cloned() { + if let Some(source) = config.get("source").filter(|v| !v.is_null()).cloned() { let source_map = match &source { PhpMixed::Array(m) => Some(m.clone()), _ => None, @@ -404,9 +404,12 @@ impl ArrayLoader { package .package_mut() .set_source_url(source_map.get("url").map(|v| strval(v))); - package - .package_mut() - .set_source_reference(source_map.get("reference").map(|v| strval(v))); + package.package_mut().set_source_reference( + source_map + .get("reference") + .filter(|v| !v.is_null()) + .map(|v| strval(v)), + ); if let Some(mirrors) = source_map.get("mirrors") { package .package_mut() @@ -414,7 +417,7 @@ impl ArrayLoader { } } - if let Some(dist) = config.get("dist").cloned() { + if let Some(dist) = config.get("dist").filter(|v| !v.is_null()).cloned() { let dist_map = match &dist { PhpMixed::Array(m) => Some(m.clone()), _ => None, @@ -446,9 +449,12 @@ impl ArrayLoader { package .package_mut() .set_dist_url(dist_map.get("url").map(|v| strval(v))); - package - .package_mut() - .set_dist_reference(dist_map.get("reference").map(|v| strval(v))); + package.package_mut().set_dist_reference( + dist_map + .get("reference") + .filter(|v| !v.is_null()) + .map(|v| strval(v)), + ); package .package_mut() .set_dist_sha1_checksum(dist_map.get("shasum").map(|v| strval(v))); diff --git a/crates/shirabe/src/package/loader/root_package_loader.rs b/crates/shirabe/src/package/loader/root_package_loader.rs index 9cd1eac..308706f 100644 --- a/crates/shirabe/src/package/loader/root_package_loader.rs +++ b/crates/shirabe/src/package/loader/root_package_loader.rs @@ -266,7 +266,7 @@ impl RootPackageLoader { for (req_name, req_version) in requires { let mut m: IndexMap<CaptureKey, String> = IndexMap::new(); if Preg::is_match3( - r"(?:^|\| *|, *)([^,\s#|]+)(?:#[^ ]+)? +as +([^,\s|]+)(?:$| *\|| *)", + r"{(?:^|\| *|, *)([^,\s#|]+)(?:#[^ ]+)? +as +([^,\s|]+)(?:$| *\|| *,)}", req_version, Some(&mut m), ) { @@ -320,7 +320,7 @@ impl RootPackageLoader { for (req_name, req_version) in requires { let mut constraints: Vec<String> = vec![]; - let or_split = Preg::split(r"\s*\|\|?\s*", req_version.trim()); + let or_split = Preg::split(r"{\s*\|\|?\s*}", req_version.trim()); for or_constraint in &or_split { let and_split = shirabe_semver::split_and_constraints(or_constraint); for and_constraint in and_split { @@ -329,7 +329,7 @@ impl RootPackageLoader { } let stability_names: Vec<&str> = stabilities.keys().copied().collect(); - let pattern = format!("^[^@]*?@({})$", stability_names.join("|")); + let pattern = format!("{{^[^@]*?@({})$}}i", stability_names.join("|")); let mut matched = false; for constraint in &constraints { @@ -353,8 +353,8 @@ impl RootPackageLoader { } for constraint in &constraints { - let req_version_stripped = Preg::replace(r"^([^,\s@]+) as .+$", "$1", constraint); - if Preg::is_match(r"^[^,\s@]+$", &req_version_stripped) { + let req_version_stripped = Preg::replace(r"{^([^,\s@]+) as .+$}", "$1", constraint); + if Preg::is_match(r"{^[^,\s@]+$}", &req_version_stripped) { let stability_name = VersionParser::parse_stability(&req_version_stripped); if stability_name != "stable" { let name = strtolower(req_name); @@ -378,9 +378,9 @@ impl RootPackageLoader { mut references: IndexMap<String, String>, ) -> IndexMap<String, String> { for (req_name, req_version) in requires { - let req_version = Preg::replace(r"^([^,\s@]+) as .+$", "$1", req_version); + let req_version = Preg::replace(r"{^([^,\s@]+) as .+$}", "$1", req_version); let mut m: IndexMap<CaptureKey, String> = IndexMap::new(); - if Preg::is_match3(r"^[^,\s@]+?#([a-f0-9]+)$", &req_version, Some(&mut m)) + if Preg::is_match3(r"{^[^,\s@]+?#([a-f0-9]+)$}", &req_version, Some(&mut m)) && VersionParser::parse_stability(&req_version) == "dev" { let name = strtolower(req_name); diff --git a/crates/shirabe/src/package/version/version_bumper.rs b/crates/shirabe/src/package/version/version_bumper.rs index c029e3b..4deb450 100644 --- a/crates/shirabe/src/package/version/version_bumper.rs +++ b/crates/shirabe/src/package/version/version_bumper.rs @@ -54,9 +54,17 @@ impl VersionBumper { return Ok(pretty_constraint); } + // Regex pattern compatibility: + // PHP uses zero-width look-behind `(?<=,|\ |\||^)` and look-ahead `(?=,|$|\ |\||@)` to require + // a separator around the constraint. The `regex` crate supports neither, so the separators are + // matched as *consuming* groups instead. Only the `constraint` named group's own offset/length + // is used for the replacement below (never group 0), so consuming the surrounding separators + // does not shift the rewrite. The single-character `|`/space/comma separators are not consumed + // by both an adjacent trailing and leading match, but Composer never packs two same-major + // constraints around a single such separator (alternatives use `||`, ranges use spaces). let pattern = format!( r#"{{ - (?<=,|\ |\||^) # leading separator + (?:^|[,\ |]) # leading separator (?P<constraint> \^v?{major}(?:\.\d+)* # e.g. ^2.anything | ~v?{major}(?:\.\d+){{1,3}} # e.g. ~2.2 or ~2.2.2 or ~2.2.2.2 @@ -64,7 +72,7 @@ impl VersionBumper { | >=v?\d(?:\.\d+)* # e.g. >=2 or >=1.2 etc | \* # full wildcard ) - (?=,|$|\ |\||@) # trailing separator + (?:$|[,\ |@]) # trailing separator }}x"#, major = major ); diff --git a/crates/shirabe/src/repository/handle.rs b/crates/shirabe/src/repository/handle.rs index 2e83ef8..90a9090 100644 --- a/crates/shirabe/src/repository/handle.rs +++ b/crates/shirabe/src/repository/handle.rs @@ -148,6 +148,14 @@ impl RepositoryInterfaceHandle { // --- InstalledRepositoryInterface helpers (valid only when the wrapped repository is one) --- + /// PHP `$repository instanceof InstalledRepositoryInterface`. + pub fn is_installed_repository_interface(&self) -> bool { + self.0 + .borrow() + .as_installed_repository_interface() + .is_some() + } + pub fn is_fresh(&self) -> bool { self.0 .borrow() diff --git a/crates/shirabe/src/repository/installed_repository.rs b/crates/shirabe/src/repository/installed_repository.rs index 55ec50f..cfa5a7a 100644 --- a/crates/shirabe/src/repository/installed_repository.rs +++ b/crates/shirabe/src/repository/installed_repository.rs @@ -364,12 +364,14 @@ impl InstalledRepository { } pub fn add_repository(&mut self, repository: RepositoryInterfaceHandle) { - // TODO: type guard? assert!( repository.is::<LockArrayRepository>() + || repository.is_installed_repository_interface() || repository.is::<RootPackageRepository>() || repository.is::<PlatformRepository>(), - "An InstalledRepository can contain a repository of type: LockArrayRepository, RootPackageRepository or PlatformRepository" + "An InstalledRepository can not contain a repository of type {} ({})", + repository.get_repo_name(), + repository.get_repo_name() ); self.inner.add_repository(repository); diff --git a/crates/shirabe/src/repository/repository_manager.rs b/crates/shirabe/src/repository/repository_manager.rs index 403b504..247236c 100644 --- a/crates/shirabe/src/repository/repository_manager.rs +++ b/crates/shirabe/src/repository/repository_manager.rs @@ -147,10 +147,30 @@ impl RepositoryManager { fn create_repository_by_class( &self, - _class: &str, - _config: IndexMap<String, PhpMixed>, + class: &str, + config: IndexMap<String, PhpMixed>, ) -> anyhow::Result<RepositoryInterfaceHandle> { - todo!("Phase B: dynamic class instantiation by class name") + // PHP: `new $class($config, $this->io, $this->config, $this->httpDownloader, + // $this->eventDispatcher, $this->process)`. Rust cannot instantiate by string class name, so + // dispatch over the classes registered in `createDefaultRepositoryManager`. + match class { + "Composer\\Repository\\ComposerRepository" => Ok(RepositoryInterfaceHandle::new( + crate::repository::ComposerRepository::new( + config, + self.io.clone(), + &self.config.borrow(), + self.http_downloader.clone(), + self.event_dispatcher.clone(), + )?, + )), + "Composer\\Repository\\PackageRepository" => Ok(RepositoryInterfaceHandle::new( + crate::repository::PackageRepository::new(config), + )), + other => todo!( + "Phase B: dynamic class instantiation by class name: {}", + other + ), + } } pub fn set_repository_class(&mut self, r#type: &str, class: &str) { diff --git a/crates/shirabe/src/repository/vcs_repository.rs b/crates/shirabe/src/repository/vcs_repository.rs index 526aa01..7a143eb 100644 --- a/crates/shirabe/src/repository/vcs_repository.rs +++ b/crates/shirabe/src/repository/vcs_repository.rs @@ -264,6 +264,16 @@ impl VcsRepository { &self.version_transport_exceptions } + /// For testing only: drives `initialize` (which shells out to the VCS driver to discover + /// tags/branches and load each package) and returns the packages collected by the inner + /// `ArrayRepository`, mirroring the polymorphic `RepositoryInterface::getPackages` dispatch + /// in PHP where `VcsRepository` inherits `getPackages` from `ArrayRepository`. + pub fn __get_packages(&mut self) -> anyhow::Result<Vec<crate::package::BasePackageHandle>> { + self.initialize()?; + use crate::repository::RepositoryInterface; + self.inner.get_packages() + } + pub fn initialize(&mut self) -> Result<()> { self.inner.initialize(); @@ -282,7 +292,7 @@ impl VcsRepository { self.version_parser = Some(VersionParser::new()); if self.loader.is_none() { self.loader = Some(Box::new(ArrayLoader::new( - Some(todo!("phase-b: clone VersionParser")), + self.version_parser.clone(), false, ))); } diff --git a/crates/shirabe/src/util/forgejo_url.rs b/crates/shirabe/src/util/forgejo_url.rs index 85f2238..d336b87 100644 --- a/crates/shirabe/src/util/forgejo_url.rs +++ b/crates/shirabe/src/util/forgejo_url.rs @@ -14,7 +14,7 @@ pub struct ForgejoUrl { impl ForgejoUrl { pub const URL_REGEX: &'static str = - r"^(?:(?:https?|git)://([^/]+)/|git@([^:]+):/?)([^/]+)/([^/]+?)(?:\.git|/)?$"; + r"{^(?:(?:https?|git)://([^/]+)/|git@([^:]+):/?)([^/]+)/([^/]+?)(?:\.git|/)?$}"; fn new(owner: String, repository: String, origin_url: String, api_url: String) -> Self { Self { diff --git a/crates/shirabe/tests/command/bump_command_test.rs b/crates/shirabe/tests/command/bump_command_test.rs index 7accb75..509b42a 100644 --- a/crates/shirabe/tests/command/bump_command_test.rs +++ b/crates/shirabe/tests/command/bump_command_test.rs @@ -1,16 +1,206 @@ //! ref: composer/tests/Composer/Test/Command/BumpCommandTest.php -use crate::test_case::{RunOptions, get_application_tester, init_temp_composer}; +use crate::test_case::{ + RunOptions, create_composer_lock, create_installed_json, get_application_tester, + get_complete_package, init_temp_composer, +}; use serial_test::serial; +use shirabe::json::JsonFile; +use shirabe::package::handle::PackageInterfaceHandle; use shirabe_php_shim::PhpMixed; -#[ignore = "needs create_installed_json / create_composer_lock helpers \ - (InstalledFilesystemRepository / Locker::set_lock_data), and BumpCommand::execute calls \ - require_composer -> Factory, reaching ProcessExecutor (git) -> shirabe-php-shim \ - stream_set_blocking (stream.rs todo!(), requires fcntl(2))"] +/// ref: BumpCommandTest::testBump (data provider rolled into one body). +fn run_bump_case( + composer_json: serde_json::Value, + command: &[(&str, PhpMixed)], + expected: serde_json::Value, + lock: bool, + exit_code: i32, +) { + let tear_down = init_temp_composer(Some(&composer_json), None, None, true); + + let packages: Vec<PackageInterfaceHandle> = vec![ + get_complete_package("first/pkg", "2.3.4").into(), + get_complete_package("second/pkg", "3.4.0").into(), + ]; + let dev_packages: Vec<PackageInterfaceHandle> = + vec![get_complete_package("dev/pkg", "2.3.4.5").into()]; + + create_installed_json(&packages, &dev_packages, true); + if lock { + create_composer_lock(&packages, &dev_packages); + } + + let mut input: Vec<(PhpMixed, PhpMixed)> = + vec![(PhpMixed::from("command"), PhpMixed::from("bump"))]; + for (k, v) in command { + input.push((PhpMixed::from(*k), v.clone())); + } + + let mut app_tester = get_application_tester(); + let status_code = app_tester.run(input, RunOptions::default()).unwrap(); + assert_eq!(exit_code, status_code); + + let mut json = JsonFile::new("./composer.json".to_string(), None, None).unwrap(); + let read = json.read().unwrap(); + let actual: serde_json::Value = serde_json::from_str(&JsonFile::encode(&read)).unwrap(); + assert_eq!(expected, actual); + + drop(tear_down); +} + #[test] +#[serial] fn test_bump() { - todo!() + // 'bump all by default' + run_bump_case( + serde_json::json!({ + "require": { "first/pkg": "^v2.0", "second/pkg": "3.*" }, + "require-dev": { "dev/pkg": "~2.0" }, + }), + &[], + serde_json::json!({ + "require": { "first/pkg": "^2.3.4", "second/pkg": "^3.4" }, + "require-dev": { "dev/pkg": "^2.3.4.5" }, + }), + true, + 0, + ); + + // 'bump only dev with --dev-only' + run_bump_case( + serde_json::json!({ + "require": { "first/pkg": "^2.0", "second/pkg": "3.*" }, + "require-dev": { "dev/pkg": "~2.0" }, + }), + &[("--dev-only", PhpMixed::from(true))], + serde_json::json!({ + "require": { "first/pkg": "^2.0", "second/pkg": "3.*" }, + "require-dev": { "dev/pkg": "^2.3.4.5" }, + }), + true, + 0, + ); + + // 'bump only non-dev with --no-dev-only' + run_bump_case( + serde_json::json!({ + "require": { "first/pkg": "^2.0", "second/pkg": "3.*" }, + "require-dev": { "dev/pkg": "~2.0" }, + }), + &[("--no-dev-only", PhpMixed::from(true))], + serde_json::json!({ + "require": { "first/pkg": "^2.3.4", "second/pkg": "^3.4" }, + "require-dev": { "dev/pkg": "~2.0" }, + }), + true, + 0, + ); + + // 'bump only listed with packages arg' + run_bump_case( + serde_json::json!({ + "require": { "first/pkg": "^2.0", "second/pkg": "3.*" }, + "require-dev": { "dev/pkg": "~2.0" }, + }), + &[( + "packages", + PhpMixed::List(vec![ + PhpMixed::from("first/pkg:3.0.1"), + PhpMixed::from("dev/*"), + ]), + )], + serde_json::json!({ + "require": { "first/pkg": "^2.3.4", "second/pkg": "3.*" }, + "require-dev": { "dev/pkg": "^2.3.4.5" }, + }), + true, + 0, + ); + + // 'bump works from installed repo without lock file' + run_bump_case( + serde_json::json!({ + "require": { "first/pkg": "^2.0", "second/pkg": "3.*" }, + }), + &[], + serde_json::json!({ + "require": { "first/pkg": "^2.3.4", "second/pkg": "^3.4" }, + }), + false, + 0, + ); + + // 'bump with --dry-run with packages to bump' + run_bump_case( + serde_json::json!({ + "require": { "first/pkg": "^2.0", "second/pkg": "3.*" }, + "require-dev": { "dev/pkg": "~2.0" }, + }), + &[("--dry-run", PhpMixed::from(true))], + serde_json::json!({ + "require": { "first/pkg": "^2.0", "second/pkg": "3.*" }, + "require-dev": { "dev/pkg": "~2.0" }, + }), + true, + 1, + ); + + // 'bump with --dry-run without packages to bump' + run_bump_case( + serde_json::json!({ + "require": { "first/pkg": "^2.3.4", "second/pkg": "^3.4" }, + "require-dev": { "dev/pkg": "^2.3.4.5" }, + }), + &[("--dry-run", PhpMixed::from(true))], + serde_json::json!({ + "require": { "first/pkg": "^2.3.4", "second/pkg": "^3.4" }, + "require-dev": { "dev/pkg": "^2.3.4.5" }, + }), + true, + 0, + ); + + // 'bump works with non-standard package' + run_bump_case( + serde_json::json!({ + "require": { "php": ">=5.3", "first/pkg": "^2.3.4", "second/pkg": "^3.4" }, + "require-dev": { "dev/pkg": "^2.3.4.5" }, + }), + &[], + serde_json::json!({ + "require": { "php": ">=5.3", "first/pkg": "^2.3.4", "second/pkg": "^3.4" }, + "require-dev": { "dev/pkg": "^2.3.4.5" }, + }), + true, + 0, + ); + + // 'bump works with unknown package' + run_bump_case( + serde_json::json!({ + "require": { "first/pkg": "^2.3.4", "second/pkg": "^3.4", "third/pkg": "^1.2" }, + }), + &[], + serde_json::json!({ + "require": { "first/pkg": "^2.3.4", "second/pkg": "^3.4", "third/pkg": "^1.2" }, + }), + true, + 0, + ); + + // 'bump works with aliased package' + run_bump_case( + serde_json::json!({ + "require": { "first/pkg": "^2.3.4", "second/pkg": "dev-bugfix as 3.4.x-dev" }, + }), + &[], + serde_json::json!({ + "require": { "first/pkg": "^2.3.4", "second/pkg": "dev-bugfix as 3.4.x-dev" }, + }), + true, + 0, + ); } #[test] @@ -44,10 +234,6 @@ fn test_bump_fails_on_non_existing_composer_file() { #[test] #[serial] -#[ignore = "CurlDownloader::new no longer panics (HTTP layer is now reqwest-based), but this path \ - still reaches an unrelated todo!() in shirabe-php-shim stream_set_blocking (stream.rs), \ - which requires fcntl(2). The companion test_bump_fails_on_non_existing_composer_file \ - covers the same error-output capture path without reaching that shim."] fn test_bump_fails_on_write_error_to_composer_file() { if shirabe_php_shim::function_exists("posix_getuid") && shirabe_php_shim::posix_getuid() == 0 { // ref: $this->markTestSkipped('Cannot run as root'); diff --git a/crates/shirabe/tests/command/fund_command_test.rs b/crates/shirabe/tests/command/fund_command_test.rs index f75090c..bbcc589 100644 --- a/crates/shirabe/tests/command/fund_command_test.rs +++ b/crates/shirabe/tests/command/fund_command_test.rs @@ -1,7 +1,201 @@ //! ref: composer/tests/Composer/Test/Command/FundCommandTest.php -#[ignore = "missing TestCase::init_temp_composer, create_installed_json, and get_application_tester (ApplicationTester) infrastructure"] +use crate::test_case::{ + RunOptions, create_installed_json, get_application_tester, get_complete_package, + init_temp_composer, +}; +use indexmap::IndexMap; +use serial_test::serial; +use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle}; +use shirabe_php_shim::PhpMixed; + +fn funding_entry(r#type: &str, url: &str) -> IndexMap<String, PhpMixed> { + let mut m = IndexMap::new(); + m.insert("type".to_string(), PhpMixed::from(r#type)); + m.insert("url".to_string(), PhpMixed::from(url)); + m +} + +/// Runs one `useCaseProvider` case. +fn run_fund_case( + composer_json: serde_json::Value, + command: &[(&str, PhpMixed)], + funding: &[(&str, IndexMap<String, PhpMixed>)], + expected: &str, +) { + let tear_down = init_temp_composer(Some(&composer_json), None, None, true); + + let first = get_complete_package("first/pkg", "2.3.4"); + let stable = get_complete_package("stable/pkg", "1.0.0"); + let dev = get_complete_package("dev/pkg", "2.3.4.5"); + + let by_name: IndexMap<&str, &CompletePackageHandle> = [ + ("first/pkg", &first), + ("stable/pkg", &stable), + ("dev/pkg", &dev), + ] + .into_iter() + .collect(); + + for (pkg_name, info) in funding { + if let Some(pkg) = by_name.get(pkg_name) { + pkg.set_funding(vec![info.clone()]); + } + } + + let packages: Vec<PackageInterfaceHandle> = vec![first.into(), stable.into()]; + let dev_packages: Vec<PackageInterfaceHandle> = vec![dev.into()]; + + create_installed_json(&packages, &dev_packages, true); + + let mut input: Vec<(PhpMixed, PhpMixed)> = + vec![(PhpMixed::from("command"), PhpMixed::from("fund"))]; + for (k, v) in command { + input.push((PhpMixed::from(*k), v.clone())); + } + + let mut app_tester = get_application_tester(); + let status_code = app_tester + .run( + input, + RunOptions { + capture_stderr_separately: true, + ..RunOptions::default() + }, + ) + .unwrap(); + assert_eq!(0, status_code); + assert_eq!(expected.trim(), app_tester.get_display().trim()); + + drop(tear_down); +} + #[test] +#[serial] +#[ignore = "FundCommand queries every repository (incl. the default packagist ComposerRepository) over \ + HTTP for funding metadata before falling back to locally installed data, reaching \ + shirabe-php-shim curl.rs curl_version (todo!()); this path needs real network access"] fn test_fund_command() { - todo!() + // 'no funding links present, locally or remotely' + run_fund_case( + serde_json::json!({ + "repositories": [], + "require": { "first/pkg": "^2.0" }, + "require-dev": { "dev/pkg": "~4.0" }, + }), + &[], + &[], + "No funding links were found in your package dependencies. This doesn't mean they don't need your support!", + ); + + // 'funding links set locally are used as fallback if not found remotely' + run_fund_case( + serde_json::json!({ + "repositories": [], + "require": { "first/pkg": "^2.0" }, + "require-dev": { "dev/pkg": "~4.0" }, + }), + &[], + &[ + ( + "first/pkg", + funding_entry("github", "https://github.com/composer-test-data"), + ), + ( + "dev/pkg", + funding_entry("github", "https://github.com/composer-test-data-dev"), + ), + ], + "The following packages were found in your dependencies which publish funding information: + +dev + pkg + https://github.com/sponsors/composer-test-data-dev + +first + https://github.com/sponsors/composer-test-data + +Please consider following these links and sponsoring the work of package authors! +Thank you!", + ); + + // 'funding links set remotely are used as primary if found' + run_fund_case( + serde_json::json!({ + "repositories": [ + { + "type": "package", + "package": [ + { "name": "first/pkg", "version": "dev-foo", "funding": [{ "type": "github", "url": "https://github.com/test-should-not-be-used" }] }, + { "name": "first/pkg", "version": "dev-main", "default-branch": true, "funding": [{ "type": "custom", "url": "https://example.org" }] }, + { "name": "dev/pkg", "version": "dev-foo", "default-branch": true, "funding": [{ "type": "github", "url": "https://github.com/org" }] }, + { "name": "stable/pkg", "version": "1.0.0", "funding": [{ "type": "github", "url": "org2" }] }, + ], + }, + ], + "require": { "first/pkg": "^2.0", "stable/pkg": "^1.0" }, + "require-dev": { "dev/pkg": "~4.0" }, + }), + &[], + &[ + ( + "first/pkg", + funding_entry("github", "https://github.com/composer-test-data"), + ), + ( + "dev/pkg", + funding_entry("github", "https://github.com/composer-test-data-dev"), + ), + ( + "stable/pkg", + funding_entry("github", "https://github.com/composer-test-data-stable"), + ), + ], + "The following packages were found in your dependencies which publish funding information: + +dev + pkg + https://github.com/sponsors/org + +first + https://example.org + +stable + https://github.com/sponsors/composer-test-data-stable + +Please consider following these links and sponsoring the work of package authors! +Thank you!", + ); + + // 'format funding links as JSON' + run_fund_case( + serde_json::json!({ + "repositories": [], + "require": { "first/pkg": "^2.0" }, + "require-dev": { "dev/pkg": "~4.0" }, + }), + &[("--format", PhpMixed::from("json"))], + &[ + ( + "first/pkg", + funding_entry("github", "https://github.com/composer-test-data"), + ), + ( + "dev/pkg", + funding_entry("github", "https://github.com/composer-test-data-dev"), + ), + ], + r#"{ + "dev": { + "https://github.com/sponsors/composer-test-data-dev": [ + "pkg" + ] + }, + "first": { + "https://github.com/sponsors/composer-test-data": [ + "pkg" + ] + } +}"#, + ); } diff --git a/crates/shirabe/tests/command/licenses_command_test.rs b/crates/shirabe/tests/command/licenses_command_test.rs index 6bfe832..f680621 100644 --- a/crates/shirabe/tests/command/licenses_command_test.rs +++ b/crates/shirabe/tests/command/licenses_command_test.rs @@ -1,71 +1,336 @@ //! ref: composer/tests/Composer/Test/Command/LicensesCommandTest.php -fn set_up() { - // Builds the temp project and installed.json/composer.lock fixtures via - // initTempComposer/createInstalledJson/createComposerLock, none of which are ported yet. - todo!() +use crate::test_case::{ + RunOptions, TearDown, create_composer_lock, create_installed_json, get_application_tester, + get_complete_package, init_temp_composer, +}; +use serial_test::serial; +use shirabe::package::handle::PackageInterfaceHandle; +use shirabe_php_shim::PhpMixed; + +/// ref: LicensesCommandTest::setUp +fn set_up() -> TearDown { + let tear_down = init_temp_composer( + Some(&serde_json::json!({ + "name": "test/pkg", + "version": "1.2.3", + "license": "MIT", + "require": { + "first/pkg": "^2.0", + "second/pkg": "3.*", + "third/pkg": "^1.3", + }, + "require-dev": { + "dev/pkg": "~2.0", + }, + })), + None, + None, + true, + ); + + let first = get_complete_package("first/pkg", "2.3.4"); + first.set_license(vec!["MIT".to_string()]); + + let second = get_complete_package("second/pkg", "3.4.0"); + second.set_license(vec!["LGPL-2.0-only".to_string()]); + second.set_homepage("https://example.org".to_string()); + + let third = get_complete_package("third/pkg", "1.5.4"); + + let dev = get_complete_package("dev/pkg", "2.3.4.5"); + dev.set_license(vec!["MIT".to_string()]); + + let packages: Vec<PackageInterfaceHandle> = vec![first.into(), second.into(), third.into()]; + let dev_packages: Vec<PackageInterfaceHandle> = vec![dev.into()]; + + create_installed_json(&packages, &dev_packages, true); + create_composer_lock(&packages, &dev_packages); + + tear_down +} + +/// Asserts each non-blank line of `display` matches the regex built from `expected[i]` +/// (`implode("\s+", expected[i])` in PHP). +fn assert_lines(display: &str, expected: &[Vec<&str>]) { + let mut i = 0usize; + for line in display.split('\n') { + if line.trim().is_empty() { + i += 1; + continue; + } + assert!(i < expected.len(), "Got more output lines than expected"); + let pattern = expected[i] + .iter() + .map(|s| regex::escape(s)) + .collect::<Vec<_>>() + .join(r"\s+"); + let re = regex::Regex::new(&pattern).unwrap(); + assert!( + re.is_match(line), + "line {} {:?} did not match {:?}", + i, + line, + pattern + ); + i += 1; + } } #[test] -#[ignore = "missing TestCase::init_temp_composer / create_installed_json / create_composer_lock and get_application_tester (ApplicationTester) infrastructure"] +#[serial] +#[ignore = "renders the text-format Table, which reaches shirabe_php_shim::instance_of (var.rs todo!()); \ + PhpMixed::Object carries no runtime class tag, so the Symfony Table helper cannot \ + distinguish TableSeparator/TableCell rows yet"] fn test_basic_run() { - set_up(); + let _tear_down = set_up(); - todo!() + let mut app_tester = get_application_tester(); + let status_code = app_tester + .run( + vec![(PhpMixed::from("command"), PhpMixed::from("license"))], + RunOptions::default(), + ) + .unwrap(); + assert_eq!(0, status_code); + + let expected = vec![ + vec!["Name:", "test/pkg"], + vec!["Version:", "1.2.3"], + vec!["Licenses:", "MIT"], + vec!["Dependencies:"], + vec![], + vec!["Name", "Version", "Licenses"], + vec!["dev/pkg", "2.3.4.5", "MIT"], + vec!["first/pkg", "2.3.4", "MIT"], + vec!["second/pkg", "3.4.0", "LGPL-2.0-only"], + vec!["third/pkg", "1.5.4", "none"], + ]; + assert_lines(&app_tester.get_display(), &expected); } #[test] -#[ignore = "missing TestCase::init_temp_composer / create_installed_json / create_composer_lock and get_application_tester (ApplicationTester) infrastructure"] +#[serial] +#[ignore = "renders the text-format Table, which reaches shirabe_php_shim::instance_of (var.rs todo!()); \ + see test_basic_run"] fn test_no_dev() { - set_up(); + let _tear_down = set_up(); + + let mut app_tester = get_application_tester(); + let status_code = app_tester + .run( + vec![ + (PhpMixed::from("command"), PhpMixed::from("license")), + (PhpMixed::from("--no-dev"), PhpMixed::from(true)), + ], + RunOptions::default(), + ) + .unwrap(); + assert_eq!(0, status_code); - todo!() + let expected = vec![ + vec!["Name:", "test/pkg"], + vec!["Version:", "1.2.3"], + vec!["Licenses:", "MIT"], + vec!["Dependencies:"], + vec![], + vec!["Name", "Version", "Licenses"], + vec!["first/pkg", "2.3.4", "MIT"], + vec!["second/pkg", "3.4.0", "LGPL-2.0-only"], + vec!["third/pkg", "1.5.4", "none"], + ]; + assert_lines(&app_tester.get_display(), &expected); } #[test] -#[ignore = "missing TestCase::init_temp_composer / create_installed_json / create_composer_lock and get_application_tester (ApplicationTester) infrastructure"] +#[serial] fn test_format_json() { - set_up(); + let _tear_down = set_up(); + + let mut app_tester = get_application_tester(); + let status_code = app_tester + .run( + vec![ + (PhpMixed::from("command"), PhpMixed::from("license")), + (PhpMixed::from("--format"), PhpMixed::from("json")), + ], + RunOptions { + capture_stderr_separately: true, + ..RunOptions::default() + }, + ) + .unwrap(); + assert_eq!(0, status_code); - todo!() + let expected = serde_json::json!({ + "name": "test/pkg", + "version": "1.2.3", + "license": ["MIT"], + "dependencies": { + "dev/pkg": { "version": "2.3.4.5", "license": ["MIT"] }, + "first/pkg": { "version": "2.3.4", "license": ["MIT"] }, + "second/pkg": { "version": "3.4.0", "license": ["LGPL-2.0-only"] }, + "third/pkg": { "version": "1.5.4", "license": [] }, + }, + }); + + let actual: serde_json::Value = serde_json::from_str(&app_tester.get_display()).unwrap(); + assert_eq!(expected, actual); } #[test] -#[ignore = "missing TestCase::init_temp_composer / create_installed_json / create_composer_lock and get_application_tester (ApplicationTester) infrastructure"] +#[serial] +#[ignore = "renders a Table via SymfonyStyle::table, which reaches shirabe_php_shim::instance_of \ + (var.rs todo!()); see test_basic_run"] fn test_format_summary() { - set_up(); + let _tear_down = set_up(); + + let mut app_tester = get_application_tester(); + let status_code = app_tester + .run( + vec![ + (PhpMixed::from("command"), PhpMixed::from("license")), + (PhpMixed::from("--format"), PhpMixed::from("summary")), + ], + RunOptions::default(), + ) + .unwrap(); + assert_eq!(0, status_code); - todo!() + let expected = vec![ + ("-", "-"), + ("License", "Number of dependencies"), + ("-", "-"), + ("MIT", "2"), + ("LGPL-2.0-only", "1"), + ("none", "1"), + ("-", "-"), + ]; + + let display = app_tester.get_display(); + let lines: Vec<&str> = display.split('\n').collect(); + for (i, (key, value)) in expected.iter().enumerate() { + let pattern = format!(r"{}\s+{}", regex::escape(key), regex::escape(value)); + let re = regex::Regex::new(&pattern).unwrap(); + assert!( + re.is_match(lines[i]), + "line {} {:?} did not match {:?}", + i, + lines[i], + pattern + ); + } } #[test] -#[ignore = "missing TestCase::init_temp_composer / create_installed_json / create_composer_lock and get_application_tester (ApplicationTester) infrastructure"] +#[serial] fn test_format_unknown() { - set_up(); + let _tear_down = set_up(); - todo!() + let mut app_tester = get_application_tester(); + let result = app_tester.run( + vec![ + (PhpMixed::from("command"), PhpMixed::from("license")), + (PhpMixed::from("--format"), PhpMixed::from("unknown")), + ], + RunOptions::default(), + ); + assert!( + result.is_err(), + "expected a RuntimeException, got {:?}", + result + ); } #[test] -#[ignore = "missing TestCase::init_temp_composer / create_installed_json / create_composer_lock and get_application_tester (ApplicationTester) infrastructure"] +#[serial] +#[ignore = "renders the text-format Table, which reaches shirabe_php_shim::instance_of (var.rs todo!()); \ + see test_basic_run"] fn test_locked() { - set_up(); + let _tear_down = set_up(); + + let mut app_tester = get_application_tester(); + let status_code = app_tester + .run( + vec![ + (PhpMixed::from("command"), PhpMixed::from("license")), + (PhpMixed::from("--locked"), PhpMixed::from(true)), + ], + RunOptions::default(), + ) + .unwrap(); + assert_eq!(0, status_code); - todo!() + let expected = vec![ + vec!["Name:", "test/pkg"], + vec!["Version:", "1.2.3"], + vec!["Licenses:", "MIT"], + vec!["Dependencies:"], + vec![], + vec!["Name", "Version", "Licenses"], + vec!["dev/pkg", "2.3.4.5", "MIT"], + vec!["first/pkg", "2.3.4", "MIT"], + vec!["second/pkg", "3.4.0", "LGPL-2.0-only"], + vec!["third/pkg", "1.5.4", "none"], + ]; + assert_lines(&app_tester.get_display(), &expected); } #[test] -#[ignore = "missing TestCase::init_temp_composer / create_installed_json / create_composer_lock and get_application_tester (ApplicationTester) infrastructure"] +#[serial] +#[ignore = "renders the text-format Table, which reaches shirabe_php_shim::instance_of (var.rs todo!()); \ + see test_basic_run"] fn test_locked_no_dev() { - set_up(); + let _tear_down = set_up(); - todo!() + let mut app_tester = get_application_tester(); + let status_code = app_tester + .run( + vec![ + (PhpMixed::from("command"), PhpMixed::from("license")), + (PhpMixed::from("--locked"), PhpMixed::from(true)), + (PhpMixed::from("--no-dev"), PhpMixed::from(true)), + ], + RunOptions::default(), + ) + .unwrap(); + assert_eq!(0, status_code); + + let expected = vec![ + vec!["Name:", "test/pkg"], + vec!["Version:", "1.2.3"], + vec!["Licenses:", "MIT"], + vec!["Dependencies:"], + vec![], + vec!["Name", "Version", "Licenses"], + vec!["first/pkg", "2.3.4", "MIT"], + vec!["second/pkg", "3.4.0", "LGPL-2.0-only"], + vec!["third/pkg", "1.5.4", "none"], + ]; + assert_lines(&app_tester.get_display(), &expected); } #[test] -#[ignore = "missing TestCase::init_temp_composer / create_installed_json / create_composer_lock and get_application_tester (ApplicationTester) infrastructure"] +#[serial] fn test_locked_without_lock_file() { - set_up(); + let _tear_down = set_up(); + + // Remove the lock file + let _ = std::fs::remove_file("./composer.lock"); - todo!() + let mut app_tester = get_application_tester(); + let result = app_tester.run( + vec![ + (PhpMixed::from("command"), PhpMixed::from("license")), + (PhpMixed::from("--locked"), PhpMixed::from(true)), + ], + RunOptions::default(), + ); + let err = result.expect_err("expected an UnexpectedValueException"); + assert!( + err.to_string() + .contains("Valid composer.json and composer.lock files are required to run this command with --locked"), + "unexpected error: {}", + err + ); } diff --git a/crates/shirabe/tests/command/status_command_test.rs b/crates/shirabe/tests/command/status_command_test.rs index 6154ea0..aa0ecc6 100644 --- a/crates/shirabe/tests/command/status_command_test.rs +++ b/crates/shirabe/tests/command/status_command_test.rs @@ -1,12 +1,47 @@ //! ref: composer/tests/Composer/Test/Command/StatusCommandTest.php -#[ignore = "missing TestCase::init_temp_composer, create_composer_lock, create_installed_json, and get_application_tester (ApplicationTester) infrastructure"] +use crate::test_case::{ + RunOptions, create_composer_lock, create_installed_json, get_application_tester, + get_complete_package, init_temp_composer, +}; +use serial_test::serial; +use shirabe::package::handle::PackageInterfaceHandle; +use shirabe_php_shim::PhpMixed; + #[test] +#[serial] fn test_no_local_changes() { - todo!() + let tear_down = init_temp_composer( + Some(&serde_json::json!({ "require": { "root/req": "1.*" } })), + None, + None, + true, + ); + + let package = get_complete_package("root/req", "1.0.0"); + package.__set_type("metapackage".to_string()); + + let packages: Vec<PackageInterfaceHandle> = vec![package.into()]; + + create_composer_lock(&packages, &[]); + create_installed_json(&packages, &[], true); + + let mut app_tester = get_application_tester(); + app_tester + .run( + vec![(PhpMixed::from("command"), PhpMixed::from("status"))], + RunOptions::default(), + ) + .unwrap(); + + assert_eq!("No local changes", app_tester.get_display().trim()); + + drop(tear_down); } -#[ignore = "missing TestCase::init_temp_composer and get_application_tester (ApplicationTester) infrastructure"] +#[ignore = "exercises `install` over the network (downloads composer/class-map-generator from a git \ + source or smarty/smarty from a dist zip), then mutates the installed package and runs \ + `status`; the install path needs real network access"] #[test] fn test_locally_modified_packages() { todo!() diff --git a/crates/shirabe/tests/command/suggests_command_test.rs b/crates/shirabe/tests/command/suggests_command_test.rs index c23691b..8bbb950 100644 --- a/crates/shirabe/tests/command/suggests_command_test.rs +++ b/crates/shirabe/tests/command/suggests_command_test.rs @@ -1,13 +1,392 @@ //! ref: composer/tests/Composer/Test/Command/SuggestsCommandTest.php -#[ignore = "missing TestCase::init_temp_composer, create_installed_json, create_composer_lock, and get_application_tester (ApplicationTester) infrastructure"] +use crate::test_case::{ + RunOptions, create_composer_lock, create_installed_json, get_application_tester, + get_complete_package, get_version_constraint, init_temp_composer, +}; +use indexmap::IndexMap; +use serial_test::serial; +use shirabe::package::Link; +use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle}; +use shirabe_php_shim::PhpMixed; + +/// ref: SuggestsCommandTest::getPackageWithSuggestAndRequires +fn get_package_with_suggest_and_requires( + name: &str, + version: &str, + suggests: &[(&str, &str)], + requires: &[(&str, Link)], + require_devs: &[(&str, Link)], +) -> CompletePackageHandle { + let pkg = get_complete_package(name, version); + let suggests_map: IndexMap<String, String> = suggests + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + pkg.__set_suggests(suggests_map); + let requires_map: IndexMap<String, Link> = requires + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + pkg.__set_requires(requires_map); + let dev_requires_map: IndexMap<String, Link> = require_devs + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + pkg.__set_dev_requires(dev_requires_map); + pkg +} + +fn link(source: &str, target: &str, description: &str) -> Link { + Link::new( + source.to_string(), + target.to_string(), + get_version_constraint(">=", "1.0"), + Some(description.to_string()), + "^1.0".to_string(), + ) +} + #[test] +#[serial] fn test_installed_packages_with_no_suggestions() { - todo!() + let tear_down = init_temp_composer( + Some(&serde_json::json!({ + "repositories": { + "packages": { + "type": "package", + "package": [ + { "name": "vendor1/package1", "version": "1.0.0" }, + { "name": "vendor2/package2", "version": "1.0.0" }, + ], + }, + }, + "require": { + "vendor1/package1": "1.*", + "vendor2/package2": "1.*", + }, + })), + None, + None, + true, + ); + + let packages: Vec<PackageInterfaceHandle> = vec![ + get_complete_package("vendor1/package1", "1.0.0").into(), + get_complete_package("vendor2/package2", "1.0.0").into(), + ]; + + create_installed_json(&packages, &[], true); + create_composer_lock(&packages, &[]); + + let mut app_tester = get_application_tester(); + let status_code = app_tester + .run( + vec![(PhpMixed::from("command"), PhpMixed::from("suggest"))], + RunOptions::default(), + ) + .unwrap(); + assert_eq!(0, status_code); + assert!( + app_tester.get_display().trim().is_empty(), + "expected empty display, got: {:?}", + app_tester.get_display() + ); + + drop(tear_down); +} + +/// Builds the package set shared by every `test_suggest` case. +fn suggest_packages() -> (Vec<PackageInterfaceHandle>, Vec<PackageInterfaceHandle>) { + let packages: Vec<PackageInterfaceHandle> = vec![ + get_package_with_suggest_and_requires( + "vendor1/package1", + "1.0.0", + &[("vendor3/suggested", "helpful for vendor1/package1")], + &[( + "vendor6/package6", + link("vendor1/package1", "vendor6/package6", Link::TYPE_REQUIRE), + )], + &[ + ( + "vendor4/dev-suggested", + link( + "vendor1/package1", + "vendor4/dev-suggested", + Link::TYPE_DEV_REQUIRE, + ), + ), + ( + "vendor3/suggested", + link( + "vendor1/package1", + "vendor3/suggested", + Link::TYPE_DEV_REQUIRE, + ), + ), + ], + ) + .into(), + get_package_with_suggest_and_requires( + "vendor6/package6", + "1.0.0", + &[("vendor7/transitive", "helpful for vendor6/package6")], + &[], + &[], + ) + .into(), + ]; + let dev_packages: Vec<PackageInterfaceHandle> = vec![ + get_package_with_suggest_and_requires( + "vendor2/package2", + "1.0.0", + &[("vendor4/dev-suggested", "helpful for vendor2/package2")], + &[( + "vendor5/dev-package", + link( + "vendor2/package2", + "vendor5/dev-package", + Link::TYPE_REQUIRE, + ), + )], + &[], + ) + .into(), + get_package_with_suggest_and_requires( + "vendor5/dev-package", + "1.0.0", + &[("vendor8/dev-transitive", "helpful for vendor5/dev-package")], + &[], + &[( + "vendor8/dev-transitive", + link( + "vendor5/dev-package", + "vendor8/dev-transitive", + Link::TYPE_DEV_REQUIRE, + ), + )], + ) + .into(), + ]; + (packages, dev_packages) +} + +fn run_suggest_case(has_lock_file: bool, command: &[(&str, PhpMixed)], expected: &str) { + let tear_down = init_temp_composer( + Some(&serde_json::json!({ + "repositories": { + "packages": { + "type": "package", + "package": [ + { "name": "vendor1/package1", "version": "1.0.0", "suggests": { "vendor3/suggested": "helpful for vendor1/package1" }, "require": { "vendor6/package6": "^1.0" }, "require-dev": { "vendor3/suggested": "^1.0", "vendor4/dev-suggested": "^1.0" } }, + { "name": "vendor2/package2", "version": "1.0.0", "suggests": { "vendor4/dev-suggested": "helpful for vendor2/package2" }, "require": { "vendor5/dev-package": "^1.0" } }, + { "name": "vendor5/dev-package", "version": "1.0.0", "suggests": { "vendor8/dev-transitive": "helpful for vendor5/dev-package" }, "require-dev": { "vendor8/dev-transitive": "^1.0" } }, + { "name": "vendor6/package6", "version": "1.0.0", "suggests": { "vendor7/transitive": "helpful for vendor6/package6" } }, + ], + }, + }, + "require": { "vendor1/package1": "^1" }, + "require-dev": { "vendor2/package2": "^1" }, + })), + None, + None, + true, + ); + + let (packages, dev_packages) = suggest_packages(); + create_installed_json(&packages, &dev_packages, true); + if has_lock_file { + create_composer_lock(&packages, &dev_packages); + } + + let mut input: Vec<(PhpMixed, PhpMixed)> = + vec![(PhpMixed::from("command"), PhpMixed::from("suggest"))]; + for (k, v) in command { + input.push((PhpMixed::from(*k), v.clone())); + } + + let mut app_tester = get_application_tester(); + let status_code = app_tester.run(input, RunOptions::default()).unwrap(); + assert_eq!(0, status_code); + assert_eq!(expected.trim(), app_tester.get_display().trim()); + + drop(tear_down); } -#[ignore = "missing TestCase::init_temp_composer, create_installed_json, create_composer_lock, get_application_tester (ApplicationTester), and get_version_parser infrastructure"] #[test] +#[serial] fn test_suggest() { - todo!() + let t = PhpMixed::from(true); + let by_package = ("--by-package", t.clone()); + let by_suggestion = ("--by-suggestion", t.clone()); + let no_dev = ("--no-dev", t.clone()); + let all = ("--all", t.clone()); + let list = ("--list", t.clone()); + + // 'with lockfile, show suggested' / 'without lockfile, show suggested' + let basic = "vendor1/package1 suggests: + - vendor3/suggested: helpful for vendor1/package1 + +vendor2/package2 suggests: + - vendor4/dev-suggested: helpful for vendor2/package2 + +2 additional suggestions by transitive dependencies can be shown with --all"; + run_suggest_case(true, &[], basic); + run_suggest_case(false, &[], basic); + + // 'with lockfile, show suggested (excluding dev)' + run_suggest_case( + true, + &[no_dev.clone()], + "vendor1/package1 suggests: + - vendor3/suggested: helpful for vendor1/package1 + +1 additional suggestions by transitive dependencies can be shown with --all", + ); + // 'without lockfile, show suggested (excluding dev)' + run_suggest_case(false, &[no_dev.clone()], basic); + + let all_suggested = "vendor1/package1 suggests: + - vendor3/suggested: helpful for vendor1/package1 + +vendor2/package2 suggests: + - vendor4/dev-suggested: helpful for vendor2/package2 + +vendor5/dev-package suggests: + - vendor8/dev-transitive: helpful for vendor5/dev-package + +vendor6/package6 suggests: + - vendor7/transitive: helpful for vendor6/package6"; + run_suggest_case(true, &[all.clone()], all_suggested); + run_suggest_case(false, &[all.clone()], all_suggested); + + // 'with lockfile, show all suggested (excluding dev)' + run_suggest_case( + true, + &[all.clone(), no_dev.clone()], + "vendor1/package1 suggests: + - vendor3/suggested: helpful for vendor1/package1 + +vendor6/package6 suggests: + - vendor7/transitive: helpful for vendor6/package6", + ); + run_suggest_case(false, &[all.clone(), no_dev.clone()], all_suggested); + + // grouped by package + run_suggest_case(true, &[by_package.clone()], basic); + run_suggest_case(false, &[by_package.clone()], basic); + run_suggest_case( + true, + &[by_package.clone(), no_dev.clone()], + "vendor1/package1 suggests: + - vendor3/suggested: helpful for vendor1/package1 + +1 additional suggestions by transitive dependencies can be shown with --all", + ); + run_suggest_case(false, &[by_package.clone(), no_dev.clone()], basic); + + // grouped by suggestion + let by_suggestion_out = "vendor3/suggested is suggested by: + - vendor1/package1: helpful for vendor1/package1 + +vendor4/dev-suggested is suggested by: + - vendor2/package2: helpful for vendor2/package2 + +2 additional suggestions by transitive dependencies can be shown with --all"; + run_suggest_case(true, &[by_suggestion.clone()], by_suggestion_out); + run_suggest_case(false, &[by_suggestion.clone()], by_suggestion_out); + run_suggest_case( + true, + &[by_suggestion.clone(), no_dev.clone()], + "vendor3/suggested is suggested by: + - vendor1/package1: helpful for vendor1/package1 + +1 additional suggestions by transitive dependencies can be shown with --all", + ); + run_suggest_case( + false, + &[by_suggestion.clone(), no_dev.clone()], + by_suggestion_out, + ); + + // grouped by package and suggestion + let by_both = "vendor1/package1 suggests: + - vendor3/suggested: helpful for vendor1/package1 + +vendor2/package2 suggests: + - vendor4/dev-suggested: helpful for vendor2/package2 + +------------------------------------------------------------------------------ +vendor3/suggested is suggested by: + - vendor1/package1: helpful for vendor1/package1 + +vendor4/dev-suggested is suggested by: + - vendor2/package2: helpful for vendor2/package2 + +2 additional suggestions by transitive dependencies can be shown with --all"; + run_suggest_case(true, &[by_package.clone(), by_suggestion.clone()], by_both); + run_suggest_case(false, &[by_package.clone(), by_suggestion.clone()], by_both); + run_suggest_case( + true, + &[by_package.clone(), by_suggestion.clone(), no_dev.clone()], + "vendor1/package1 suggests: + - vendor3/suggested: helpful for vendor1/package1 + +------------------------------------------------------------------------------ +vendor3/suggested is suggested by: + - vendor1/package1: helpful for vendor1/package1 + +1 additional suggestions by transitive dependencies can be shown with --all", + ); + run_suggest_case( + false, + &[by_package.clone(), by_suggestion.clone(), no_dev.clone()], + by_both, + ); + + // show suggested for package + let for_pkg = "vendor2/package2 suggests: + - vendor4/dev-suggested: helpful for vendor2/package2"; + run_suggest_case( + true, + &[( + "packages", + PhpMixed::List(vec![PhpMixed::from("vendor2/package2")]), + )], + for_pkg, + ); + run_suggest_case( + false, + &[( + "packages", + PhpMixed::List(vec![PhpMixed::from("vendor2/package2")]), + )], + for_pkg, + ); + + // list suggested + let list_out = "vendor3/suggested +vendor4/dev-suggested"; + run_suggest_case(true, &[list.clone()], list_out); + run_suggest_case(false, &[list.clone()], list_out); + run_suggest_case(true, &[list.clone(), no_dev.clone()], "vendor3/suggested"); + run_suggest_case(false, &[list.clone(), no_dev.clone()], list_out); + + let list_all = "vendor3/suggested +vendor4/dev-suggested +vendor7/transitive +vendor8/dev-transitive"; + run_suggest_case(true, &[list.clone(), all.clone()], list_all); + run_suggest_case(false, &[list.clone(), all.clone()], list_all); + run_suggest_case( + true, + &[list.clone(), all.clone(), no_dev.clone()], + "vendor3/suggested +vendor7/transitive", + ); + run_suggest_case( + false, + &[list.clone(), all.clone(), no_dev.clone()], + list_all, + ); } diff --git a/crates/shirabe/tests/common/test_case.rs b/crates/shirabe/tests/common/test_case.rs index 9972f32..7365f73 100644 --- a/crates/shirabe/tests/common/test_case.rs +++ b/crates/shirabe/tests/common/test_case.rs @@ -4,11 +4,22 @@ //! `#[path = "../common/test_case.rs"] mod test_case;`. #![allow(dead_code)] +use indexmap::IndexMap; +use shirabe::config::Config; use shirabe::console::application::ApplicationHandle; +use shirabe::installer::InstallationManager; +use shirabe::io::IOInterface; +use shirabe::io::null_io::NullIO; +use shirabe::json::JsonFile; +use shirabe::package::Locker; use shirabe::package::handle::{ CompleteAliasPackageHandle, CompletePackageHandle, PackageInterfaceHandle, }; +use shirabe::repository::{InstalledFilesystemRepository, WritableRepositoryInterface}; +use shirabe::util::http_downloader::HttpDownloader; +use shirabe::util::r#loop::Loop; use shirabe::util::platform::Platform; +use shirabe::util::process_executor::ProcessExecutor; use shirabe_external_packages::symfony::console::input::array_input::ArrayInput; use shirabe_external_packages::symfony::console::input::input_interface::InputInterface; use shirabe_external_packages::symfony::console::input::streamable_input_interface::StreamableInputInterface; @@ -30,6 +41,13 @@ pub fn get_package(name: &str, version: &str) -> PackageInterfaceHandle { CompletePackageHandle::new(name.to_string(), norm_version, version.to_string()).into() } +/// ref: TestCase::getPackage but returning the concrete `CompletePackageHandle` so the +/// `CompletePackage`-only setters (`set_license`, `set_homepage`, ...) are reachable. +pub fn get_complete_package(name: &str, version: &str) -> CompletePackageHandle { + let norm_version = VersionParser.normalize(version, None).unwrap(); + CompletePackageHandle::new(name.to_string(), norm_version, version.to_string()) +} + /// ref: TestCase::getAliasPackage (default class CompleteAliasPackage) pub fn get_alias_package( package: &PackageInterfaceHandle, @@ -152,6 +170,88 @@ pub fn init_temp_composer( } } +fn null_io() -> Rc<RefCell<dyn IOInterface>> { + Rc::new(RefCell::new(NullIO::new())) +} + +/// ref: FactoryMock::createInstallationManager (the real installers are never created in tests, so a +/// bare InstallationManager over a mock HttpDownloader suffices). +fn installation_manager(io: &Rc<RefCell<dyn IOInterface>>) -> Rc<RefCell<InstallationManager>> { + let config = Rc::new(RefCell::new(Config::new(false, None))); + let http_downloader = Rc::new(RefCell::new(HttpDownloader::__new_mock(io.clone(), config))); + let r#loop = Rc::new(RefCell::new(Loop::new(http_downloader, None))); + Rc::new(RefCell::new(InstallationManager::new( + r#loop, + io.clone(), + None, + ))) +} + +/// ref: TestCase::createInstalledJson +/// +/// Creates a `vendor/composer/installed.json` in the CWD (which `init_temp_composer` has chdir()'d +/// into) listing the given packages. `dev_packages` are recorded as the dev-package-name set. +pub fn create_installed_json( + packages: &[PackageInterfaceHandle], + dev_packages: &[PackageInterfaceHandle], + dev_mode: bool, +) { + std::fs::create_dir_all("vendor/composer").unwrap(); + let json_file = + JsonFile::new("vendor/composer/installed.json".to_string(), None, None).unwrap(); + let mut repo = InstalledFilesystemRepository::new(json_file, false, None, None).unwrap(); + repo.set_dev_package_names( + dev_packages + .iter() + .map(|pkg| pkg.get_pretty_name()) + .collect(), + ); + for pkg in packages.iter().chain(dev_packages.iter()) { + repo.add_package(pkg.clone()).unwrap(); + std::fs::create_dir_all(format!("vendor/{}", pkg.get_name())).unwrap(); + } + + let io = null_io(); + let im = installation_manager(&io); + repo.write(dev_mode, &mut im.borrow_mut()).unwrap(); +} + +/// ref: TestCase::createComposerLock +/// +/// Creates a `composer.lock` in the CWD (chdir()'d into by `init_temp_composer`) listing the given +/// packages. +pub fn create_composer_lock( + packages: &[PackageInterfaceHandle], + dev_packages: &[PackageInterfaceHandle], +) { + let io = null_io(); + let json_file = JsonFile::new("./composer.lock".to_string(), None, None).unwrap(); + let composer_file_contents = std::fs::read_to_string("./composer.json").unwrap_or_default(); + let process = Rc::new(RefCell::new(ProcessExecutor::new(Some(io.clone())))); + let mut locker = Locker::new( + io.clone(), + json_file, + installation_manager(&io), + &composer_file_contents, + process, + ); + locker + .set_lock_data( + packages.to_vec(), + Some(dev_packages.to_vec()), + IndexMap::new(), + IndexMap::new(), + vec![], + "dev", + IndexMap::new(), + false, + false, + IndexMap::new(), + true, + ) + .unwrap(); +} + /// ref: TestCase::getApplicationTester pub fn get_application_tester() -> ApplicationTester { let application = ApplicationHandle::new("Composer".to_string(), "".to_string()).unwrap(); diff --git a/crates/shirabe/tests/dbg_test.rs b/crates/shirabe/tests/dbg_test.rs new file mode 100644 index 0000000..5553785 --- /dev/null +++ b/crates/shirabe/tests/dbg_test.rs @@ -0,0 +1 @@ +// OBSOLETE SCRATCH — safe to delete. diff --git a/crates/shirabe/tests/downloader/download_manager_test.rs b/crates/shirabe/tests/downloader/download_manager_test.rs index b7fa976..2405b4b 100644 --- a/crates/shirabe/tests/downloader/download_manager_test.rs +++ b/crates/shirabe/tests/downloader/download_manager_test.rs @@ -1,225 +1,242 @@ //! ref: composer/tests/Composer/Test/Downloader/DownloadManagerTest.php -fn set_up() { - // The Filesystem and IO mocks are not ported. - todo!() +use std::cell::RefCell; +use std::rc::Rc; + +use shirabe::downloader::DownloaderInterface; +use shirabe::downloader::download_manager::DownloadManager; +use shirabe::io::IOInterface; +use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle}; +use shirabe_semver::VersionParser; + +use crate::downloader_stub::DownloaderStub; +use crate::io_stub::IOStub; + +/// ref: DownloadManagerTest::createPackageMock +/// +/// PHPUnit returns a `PackageInterface` mock; a real CompletePackage with the +/// relevant fields left at their defaults is an equivalent stand-in for the +/// installation-source/type dispatch logic exercised by the ported cases. +fn create_package_mock() -> PackageInterfaceHandle { + let norm_version = VersionParser.normalize("1.0.0", None).unwrap(); + CompletePackageHandle::new("dummy/pkg".to_string(), norm_version, "1.0.0".to_string()).into() +} + +/// ref: DownloadManagerTest::createDownloaderMock +fn create_downloader_mock() -> Rc<RefCell<dyn DownloaderInterface>> { + Rc::new(RefCell::new(DownloaderStub::new())) as Rc<RefCell<dyn DownloaderInterface>> +} + +fn create_manager() -> DownloadManager { + let io = Rc::new(RefCell::new(IOStub::new())) as Rc<RefCell<dyn IOInterface>>; + DownloadManager::new(io, false, None) } -// These mock IO and individual downloaders to drive DownloadManager's selection/download/ -// update/remove logic; mocking is not available here. -#[ignore = "requires PHPUnit mock of DownloaderInterface (createDownloaderMock)"] #[test] fn test_set_get_downloader() { - set_up(); - todo!() + let downloader = create_downloader_mock(); + let mut manager = create_manager(); + + manager.set_downloader("test", downloader.clone()); + assert!(Rc::ptr_eq( + &downloader, + &manager.get_downloader("test").unwrap() + )); + + let result = manager.get_downloader("unregistered"); + assert!(result.is_err()); } -#[ignore = "requires PHPUnit mock of PackageInterface (createPackageMock)"] #[test] fn test_get_downloader_for_incorrectly_installed_package() { - set_up(); - todo!() + // getInstallationSource() => null (the default for a fresh package). + let package = create_package_mock(); + + let manager = create_manager(); + + let result = manager.get_downloader_for_package(package); + assert!(result.is_err()); +} + +#[test] +fn test_get_downloader_for_metapackage() { + let package = create_package_mock(); + package.__set_type("metapackage".to_string()); + + let manager = create_manager(); + + assert!( + manager + .get_downloader_for_package(package) + .unwrap() + .is_none() + ); } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloader"] #[test] fn test_get_downloader_for_correctly_installed_dist_package() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloader"] #[test] fn test_get_downloader_for_incorrectly_installed_dist_package() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloader"] #[test] fn test_get_downloader_for_correctly_installed_source_package() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloader"] #[test] fn test_get_downloader_for_incorrectly_installed_source_package() { - set_up(); - todo!() -} - -#[ignore = "requires PHPUnit mock of PackageInterface (createPackageMock)"] -#[test] -fn test_get_downloader_for_metapackage() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_full_package_download() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_full_package_download_failover() { - set_up(); todo!() } #[ignore = "requires PHPUnit mock of PackageInterface (createPackageMock)"] #[test] fn test_bad_package_download() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_dist_only_package_download() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_source_only_package_download() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_metapackage_package_download() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_full_package_download_with_source_preferred() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_dist_only_package_download_with_source_preferred() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_source_only_package_download_with_source_preferred() { - set_up(); todo!() } #[ignore = "requires PHPUnit mock of PackageInterface (createPackageMock)"] #[test] fn test_bad_package_download_with_source_preferred() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks of PackageInterface and DownloaderInterface"] #[test] fn test_update_dist_with_equal_types() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks of PackageInterface and DownloaderInterface"] #[test] fn test_update_dist_with_not_equal_types() { - set_up(); todo!() } #[ignore = "requires PHPUnit mock of PackageInterface and ReflectionMethod for private getAvailableSources"] #[test] fn test_get_available_sources_update_sticks_to_same_source() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_update_metapackage() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_remove() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_metapackage_remove() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_install_preference_without_preference_dev() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_install_preference_without_preference_no_dev() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_install_preference_without_match_dev() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_install_preference_without_match_no_dev() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_install_preference_with_match_auto_dev() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_install_preference_with_match_auto_no_dev() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_install_preference_with_match_source() { - set_up(); todo!() } #[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_install_preference_with_match_dist() { - set_up(); todo!() } diff --git a/crates/shirabe/tests/downloader/fossil_downloader_test.rs b/crates/shirabe/tests/downloader/fossil_downloader_test.rs index 0c45d2c..e403229 100644 --- a/crates/shirabe/tests/downloader/fossil_downloader_test.rs +++ b/crates/shirabe/tests/downloader/fossil_downloader_test.rs @@ -1,8 +1,30 @@ //! ref: composer/tests/Composer/Test/Downloader/FossilDownloaderTest.php +use std::cell::RefCell; +use std::rc::Rc; + +use shirabe::config::Config; +use shirabe::downloader::VcsDownloader; +use shirabe::downloader::fossil_downloader::FossilDownloader; +use shirabe::io::IOInterface; +use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle}; +use shirabe::util::ProcessExecutor; use shirabe::util::filesystem::Filesystem; +use shirabe_php_shim::PhpMixed; +use shirabe_semver::VersionParser; use tempfile::TempDir; +use crate::config_stub::ConfigStubBuilder; +use crate::io_stub::IOStub; +use crate::process_executor_mock::get_process_executor_mock; + +fn run<F: std::future::Future>(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(future) +} + fn set_up() -> TempDir { TempDir::new().unwrap() } @@ -30,48 +52,168 @@ impl Drop for TearDown { } } -// Every case constructs a FossilDownloader with a mocked IO/Config and a mocked -// ProcessExecutor to feed fossil command output; a real HttpDownloader reaches -// curl_multi_init (todo!()), and ProcessExecutor mocking is not available. +/// ref: TestCase::getMockBuilder('Composer\Package\PackageInterface')->getMock() +/// +/// A real CompletePackage seeded with the stubbed values is a faithful stand-in +/// for a PackageInterface mock as long as `getSourceUrls()` equals +/// `[getSourceUrl()]`, which holds for every non-ignored case here. +fn get_package(source_reference: Option<&str>, source_url: Option<&str>) -> PackageInterfaceHandle { + let norm_version = VersionParser.normalize("1.0.0", None).unwrap(); + let package = + CompletePackageHandle::new("dummy/pkg".to_string(), norm_version, "1.0.0".to_string()); + package.__set_source_type(Some("fossil".to_string())); + package.set_source_reference(source_reference.map(|s| s.to_string())); + package.set_source_url(source_url.map(|s| s.to_string())); + package.into() +} + +/// ref: FossilDownloaderTest::getDownloaderMock +fn get_downloader_mock( + io: Option<Rc<RefCell<dyn IOInterface>>>, + config: Option<Config>, + process: Rc<RefCell<ProcessExecutor>>, +) -> FossilDownloader { + let io = + io.unwrap_or_else(|| Rc::new(RefCell::new(IOStub::new())) as Rc<RefCell<dyn IOInterface>>); + // ref: getConfig(['secure-http' => false]) + let config = Rc::new(RefCell::new(config.unwrap_or_else(|| { + ConfigStubBuilder::new() + .with("secure-http", PhpMixed::Bool(false)) + .build() + }))); + let fs = Rc::new(RefCell::new(Filesystem::new(None))); + FossilDownloader::new(io, config, process, fs) +} #[test] -#[ignore = "needs ProcessExecutorMock and PHPUnit mocks of IOInterface/PackageInterface/Filesystem for getDownloaderMock; not available"] fn test_install_for_package_without_source_reference() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let package = get_package(None, None); + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + let mut downloader = get_downloader_mock(None, None, process); + + let path = format!("{}/path", working_dir.path().to_string_lossy()); + let result = run(downloader.install(package, &path)); + + let e = result.expect_err("missing source reference should throw"); + assert!(e.to_string().contains("missing reference information")); } #[test] -#[ignore = "needs ProcessExecutorMock with expects() command-sequence assertions and PHPUnit PackageInterface mock; not available"] fn test_install() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let working_dir_str = working_dir.path().to_string_lossy().into_owned(); + let package = get_package(Some("trunk"), Some("http://fossil.kd2.org/kd2fw/")); + + let repo_file = format!("{}.fossil", working_dir_str); + let (process, _guard) = get_process_executor_mock( + vec![ + crate::process_executor_mock::cmd(vec![ + "fossil", + "clone", + "--", + "http://fossil.kd2.org/kd2fw/", + &repo_file, + ]), + crate::process_executor_mock::cmd(vec!["fossil", "open", "--nested", "--", &repo_file]), + crate::process_executor_mock::cmd(vec!["fossil", "update", "--", "trunk"]), + ], + true, + Default::default(), + ); + + let mut downloader = get_downloader_mock(None, None, process); + run(downloader.install(package, &working_dir_str)).unwrap(); } #[test] -#[ignore = "needs ProcessExecutorMock and PHPUnit mocks of IOInterface/PackageInterface/Filesystem for getDownloaderMock; not available"] fn test_updatefor_package_without_source_reference() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let initial_package = get_package(Some("ref"), None); + let source_package = get_package(None, None); + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + let mut downloader = get_downloader_mock(None, None, process); + + let result = run(async { + downloader + .prepare( + "update", + source_package.clone(), + "/path", + Some(initial_package.clone()), + ) + .await?; + downloader + .update(initial_package.clone(), source_package.clone(), "/path") + .await?; + downloader + .cleanup("update", source_package, "/path", Some(initial_package)) + .await + }); + + let e = result.expect_err("missing source reference should throw"); + assert!(e.to_string().contains("missing reference information")); } #[test] -#[ignore = "needs ProcessExecutorMock with expects() command-sequence assertions and PHPUnit PackageInterface mock; not available"] fn test_update() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let working_dir_str = working_dir.path().to_string_lossy().into_owned(); + + // Ensure the .fslckout metadata file exists. + let fslckout = format!("{}/.fslckout", working_dir_str); + if !std::path::Path::new(&fslckout).exists() { + std::fs::File::create(&fslckout).unwrap(); + } + + let package = get_package(Some("trunk"), Some("http://fossil.kd2.org/kd2fw/")); + + let (process, _guard) = get_process_executor_mock( + vec![ + crate::process_executor_mock::cmd(vec!["fossil", "changes"]), + crate::process_executor_mock::cmd(vec!["fossil", "pull"]), + crate::process_executor_mock::cmd(vec!["fossil", "up", "--", "trunk"]), + ], + true, + Default::default(), + ); + + let mut downloader = get_downloader_mock(None, None, process); + run(async { + downloader + .prepare( + "update", + package.clone(), + &working_dir_str, + Some(package.clone()), + ) + .await + .unwrap(); + downloader + .update(package.clone(), package.clone(), &working_dir_str) + .await + .unwrap(); + downloader + .cleanup("update", package.clone(), &working_dir_str, Some(package)) + .await + .unwrap(); + }); } +#[ignore = "PHP mocks Filesystem::removeDirectoryAsync; with a real Filesystem the actual \ + removeDirectoryAsync drives its own ProcessExecutor for `rm -rf` through the \ + unported execute_async mock seam"] #[test] -#[ignore = "needs ProcessExecutorMock and a PHPUnit Filesystem mock asserting removeDirectoryAsync; not available"] fn test_remove() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); @@ -80,10 +222,13 @@ fn test_remove() { } #[test] -#[ignore = "needs ProcessExecutorMock and PHPUnit mocks of IOInterface/Filesystem for getDownloaderMock; not available"] fn test_get_installation_source() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); let _ = &working_dir; - todo!() + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + let downloader = get_downloader_mock(None, None, process); + + assert_eq!("source", downloader.get_installation_source()); } diff --git a/crates/shirabe/tests/downloader/hg_downloader_test.rs b/crates/shirabe/tests/downloader/hg_downloader_test.rs index 67a13cb..c331533 100644 --- a/crates/shirabe/tests/downloader/hg_downloader_test.rs +++ b/crates/shirabe/tests/downloader/hg_downloader_test.rs @@ -1,8 +1,28 @@ //! ref: composer/tests/Composer/Test/Downloader/HgDownloaderTest.php +use std::cell::RefCell; +use std::rc::Rc; + +use shirabe::config::Config; +use shirabe::downloader::VcsDownloader; +use shirabe::downloader::hg_downloader::HgDownloader; +use shirabe::io::IOInterface; +use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle}; +use shirabe::util::ProcessExecutor; use shirabe::util::filesystem::Filesystem; +use shirabe_semver::VersionParser; use tempfile::TempDir; +use crate::io_stub::IOStub; +use crate::process_executor_mock::{cmd, get_process_executor_mock}; + +fn run<F: std::future::Future>(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(future) +} + fn set_up() -> TempDir { TempDir::new().unwrap() } @@ -30,48 +50,165 @@ impl Drop for TearDown { } } -// Every case constructs an HgDownloader with a mocked IO/Config and a mocked -// ProcessExecutor to feed hg command output; a real HttpDownloader reaches -// curl_multi_init (todo!()), and ProcessExecutor mocking is not available. +/// ref: TestCase::getMockBuilder('Composer\Package\PackageInterface')->getMock() +/// +/// A real CompletePackage seeded with the stubbed values is a faithful stand-in +/// for a PackageInterface mock as long as `getSourceUrls()` equals +/// `[getSourceUrl()]`, which holds for every non-ignored case here. +fn get_package(source_reference: Option<&str>, source_url: Option<&str>) -> PackageInterfaceHandle { + let norm_version = VersionParser.normalize("1.0.0", None).unwrap(); + let package = + CompletePackageHandle::new("dummy/pkg".to_string(), norm_version, "1.0.0".to_string()); + package.__set_source_type(Some("hg".to_string())); + package.set_source_reference(source_reference.map(|s| s.to_string())); + package.set_source_url(source_url.map(|s| s.to_string())); + package.into() +} + +/// ref: HgDownloaderTest::getDownloaderMock +/// +/// PHP uses a `getMockBuilder('Composer\Config')->getMock()`, whose `get()` +/// returns null for everything; a default real `Config` resolves the only +/// relevant key (`secure-http`) compatibly for the https URLs used here. +fn get_downloader_mock( + io: Option<Rc<RefCell<dyn IOInterface>>>, + config: Option<Config>, + process: Rc<RefCell<ProcessExecutor>>, +) -> HgDownloader { + let io = + io.unwrap_or_else(|| Rc::new(RefCell::new(IOStub::new())) as Rc<RefCell<dyn IOInterface>>); + let config = Rc::new(RefCell::new( + config.unwrap_or_else(|| Config::new(false, None)), + )); + let fs = Rc::new(RefCell::new(Filesystem::new(None))); + HgDownloader::new(io, config, process, fs) +} #[test] -#[ignore = "needs PHPUnit mocks of IOInterface/Config/Filesystem/PackageInterface for getDownloaderMock; not available"] fn test_download_for_package_without_source_reference() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); let _ = &working_dir; - todo!() + + let package = get_package(None, None); + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + let mut downloader = get_downloader_mock(None, None, process); + + let result = run(downloader.install(package, "/path")); + let e = result.expect_err("missing source reference should throw"); + assert!(e.to_string().contains("missing reference information")); } #[test] -#[ignore = "needs ProcessExecutorMock with expects() command-sequence assertions and PHPUnit PackageInterface mock; not available"] fn test_download() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let working_dir_str = working_dir.path().to_string_lossy().into_owned(); + let package = get_package(Some("ref"), Some("https://mercurial.dev/l3l0/composer")); + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd(vec![ + "hg", + "clone", + "--", + "https://mercurial.dev/l3l0/composer", + &working_dir_str, + ]), + cmd(vec!["hg", "up", "--", "ref"]), + ], + true, + Default::default(), + ); + + let mut downloader = get_downloader_mock(None, None, process); + run(downloader.install(package, &working_dir_str)).unwrap(); } #[test] -#[ignore = "needs PHPUnit mocks of IOInterface/Config/Filesystem/PackageInterface for getDownloaderMock; not available"] fn test_updatefor_package_without_source_reference() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); let _ = &working_dir; - todo!() + + let initial_package = get_package(Some("ref"), None); + let source_package = get_package(None, None); + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + let mut downloader = get_downloader_mock(None, None, process); + + let result = run(async { + downloader + .prepare( + "update", + source_package.clone(), + "/path", + Some(initial_package.clone()), + ) + .await?; + downloader + .update(initial_package.clone(), source_package.clone(), "/path") + .await?; + downloader + .cleanup("update", source_package, "/path", Some(initial_package)) + .await + }); + + let e = result.expect_err("missing source reference should throw"); + assert!(e.to_string().contains("missing reference information")); } #[test] -#[ignore = "needs ProcessExecutorMock with expects() command-sequence assertions and PHPUnit PackageInterface mock; not available"] fn test_update() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let working_dir_str = working_dir.path().to_string_lossy().into_owned(); + + let mut fs = Filesystem::new(None); + fs.ensure_directory_exists(&format!("{}/.hg", working_dir_str)) + .unwrap(); + + let package = get_package(Some("ref"), Some("https://github.com/l3l0/composer")); + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd(vec!["hg", "st"]), + cmd(vec!["hg", "pull", "--", "https://github.com/l3l0/composer"]), + cmd(vec!["hg", "up", "--", "ref"]), + ], + true, + Default::default(), + ); + + let mut downloader = get_downloader_mock(None, None, process); + run(async { + downloader + .prepare( + "update", + package.clone(), + &working_dir_str, + Some(package.clone()), + ) + .await + .unwrap(); + downloader + .update(package.clone(), package.clone(), &working_dir_str) + .await + .unwrap(); + downloader + .cleanup("update", package.clone(), &working_dir_str, Some(package)) + .await + .unwrap(); + }); } +#[ignore = "PHP mocks Filesystem::removeDirectoryAsync; with a real Filesystem the actual \ + removeDirectoryAsync drives its own ProcessExecutor for `rm -rf` through the \ + unported execute_async mock seam"] #[test] -#[ignore = "needs ProcessExecutorMock and PHPUnit Filesystem mock with removeDirectoryAsync expectation; not available"] fn test_remove() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); @@ -80,10 +217,13 @@ fn test_remove() { } #[test] -#[ignore = "needs PHPUnit mocks of IOInterface/Config/Filesystem for getDownloaderMock; not available"] fn test_get_installation_source() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); let _ = &working_dir; - todo!() + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + let downloader = get_downloader_mock(None, None, process); + + assert_eq!("source", downloader.get_installation_source()); } diff --git a/crates/shirabe/tests/downloader/main.rs b/crates/shirabe/tests/downloader/main.rs index 8244010..d6e07e4 100644 --- a/crates/shirabe/tests/downloader/main.rs +++ b/crates/shirabe/tests/downloader/main.rs @@ -1,5 +1,7 @@ #[path = "../common/config_stub.rs"] mod config_stub; +#[path = "../common/downloader_stub.rs"] +mod downloader_stub; #[path = "../common/http_downloader_mock.rs"] mod http_downloader_mock; #[path = "../common/io_mock.rs"] diff --git a/crates/shirabe/tests/repository/path_repository_test.rs b/crates/shirabe/tests/repository/path_repository_test.rs index 5679c38..bbf4938 100644 --- a/crates/shirabe/tests/repository/path_repository_test.rs +++ b/crates/shirabe/tests/repository/path_repository_test.rs @@ -7,6 +7,8 @@ use indexmap::IndexMap; use shirabe::config::Config; use shirabe::io::{IOInterface, NullIO}; use shirabe::repository::PathRepository; +use shirabe::util::http_downloader::HttpDownloader; +use shirabe::util::r#loop::Loop; use shirabe::util::{Platform, ProcessExecutor}; use shirabe_php_shim::{ DIRECTORY_SEPARATOR, PhpMixed, file_get_contents, hash, realpath, serialize, @@ -28,6 +30,16 @@ fn create_path_repo(options: IndexMap<String, PhpMixed>) -> PathRepository { let config = Rc::new(RefCell::new(Config::new(true, None))); let proc = Rc::new(RefCell::new(ProcessExecutor::new(None))); + // ref: createPathRepo wires the ProcessExecutor through a Loop so the VersionGuesser's async + // git calls are permitted; constructing the Loop calls enable_async() on the shared executor. + let http_downloader = Rc::new(RefCell::new(HttpDownloader::new( + io.clone(), + config.clone(), + IndexMap::new(), + false, + ))); + let _loop = Loop::new(http_downloader, Some(proc.clone())); + PathRepository::new(options, io, config, None, None, Some(proc)).unwrap() } @@ -68,7 +80,6 @@ fn test_load_package_from_file_system_with_version() { ); } -#[ignore = "version guessing for an unversioned package calls VersionGuesser::guess_git_version, which reaches stream_set_blocking (fcntl(2) not implemented, todo!()) and aborts the process"] #[test] fn test_load_package_from_file_system_without_version() { let repository_url = [ @@ -90,7 +101,6 @@ fn test_load_package_from_file_system_without_version() { assert!(!package_version.is_empty()); } -#[ignore = "the without-version fixture matched by the wildcard triggers VersionGuesser::guess_git_version, which reaches stream_set_blocking (fcntl(2) not implemented, todo!()) and aborts the process"] #[test] fn test_load_package_from_file_system_with_wildcard() { let repository_url = @@ -197,7 +207,6 @@ fn test_url_remains_relative() { assert_eq!(Some(relative_url), package.get_dist_url()); } -#[ignore = "the wildcard url also matches the without-version fixture, whose version guessing reaches stream_set_blocking (fcntl(2) not implemented, todo!()) and aborts the process"] #[test] fn test_reference_none() { let options = coordinates(vec![("reference", PhpMixed::String("none".to_string()))]); @@ -216,7 +225,6 @@ fn test_reference_none() { } } -#[ignore = "the wildcard url also matches the without-version fixture, whose version guessing reaches stream_set_blocking (fcntl(2) not implemented, todo!()) and aborts the process"] #[test] fn test_reference_config() { let options = coordinates(vec![ diff --git a/crates/shirabe/tests/repository/vcs/forgejo_driver_test.rs b/crates/shirabe/tests/repository/vcs/forgejo_driver_test.rs index ea130ea..1f9ae53 100644 --- a/crates/shirabe/tests/repository/vcs/forgejo_driver_test.rs +++ b/crates/shirabe/tests/repository/vcs/forgejo_driver_test.rs @@ -9,15 +9,18 @@ use shirabe::io::IOInterface; use shirabe::io::null_io::NullIO; use shirabe::repository::vcs::ForgejoDriver; use shirabe::util::filesystem::Filesystem; +use shirabe::util::http_downloader::{HttpDownloader, HttpDownloaderMockHandler}; +use shirabe::util::process_executor::{MockHandler, ProcessExecutor}; use shirabe_php_shim::PhpMixed; use tempfile::TempDir; +use crate::http_downloader_mock::{HttpDownloaderMockGuard, expect_full, get_http_downloader_mock}; +use crate::process_executor_mock::{ProcessExecutorMockGuard, get_process_executor_mock}; + struct SetUp { home: TempDir, - config: Config, - // The IOInterface and HttpDownloader mocks are not ported. - io: (), - http_downloader: (), + config: Rc<RefCell<Config>>, + io: Rc<RefCell<dyn IOInterface>>, } fn set_up() -> SetUp { @@ -36,14 +39,14 @@ fn set_up() -> SetUp { top.insert("config".to_string(), PhpMixed::Array(config_section)); config.merge(&top, Config::SOURCE_UNKNOWN); - let io = (); - let http_downloader = (); + // PHP mocks IOInterface with isInteractive() => true; on the passing (200) paths + // exercised here the interactivity flag is never consulted, so a bare NullIO matches. + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new())); SetUp { home, - config, + config: Rc::new(RefCell::new(config)), io, - http_downloader, } } @@ -68,82 +71,233 @@ impl Drop for TearDown { } } -fn supports_provider() -> Vec<(bool, &'static str)> { - vec![ - (false, "https://example.org/acme/repo"), - (true, "https://codeberg.org/acme/repository"), - ] +// ref: ForgejoDriverTest::initializeDriver +fn initialize_driver( + set_up: &SetUp, + repo_url: &str, + http_downloader: Rc<RefCell<HttpDownloader>>, +) -> (ForgejoDriver, ProcessExecutorMockGuard) { + let (process, process_guard) = get_process_executor_mock(vec![], false, MockHandler::default()); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(repo_url.to_string())); + let mut driver = ForgejoDriver::new( + repo_config, + set_up.io.clone(), + set_up.config.clone(), + http_downloader, + process, + ); + driver.initialize().unwrap(); + + (driver, process_guard) } -#[test] -#[ignore] -fn test_supports() { - for (expected, repo_url) in supports_provider() { - let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new())); - let config = Rc::new(RefCell::new(Config::new(true, None))); +fn http_mock( + expectations: Vec<shirabe::util::http_downloader::HttpDownloaderMockExpectation>, +) -> (Rc<RefCell<HttpDownloader>>, HttpDownloaderMockGuard) { + get_http_downloader_mock(expectations, true, HttpDownloaderMockHandler::default()) +} - assert_eq!( - expected, - ForgejoDriver::supports(io, config, repo_url, false).unwrap() +fn repo_body() -> String { + shirabe::json::JsonFile::encode(&PhpMixed::Array({ + let mut m: IndexMap<String, PhpMixed> = IndexMap::new(); + m.insert( + "default_branch".to_string(), + PhpMixed::String("main".to_string()), ); - } + m.insert("has_issues".to_string(), PhpMixed::Bool(true)); + m.insert("archived".to_string(), PhpMixed::Bool(false)); + m.insert("private".to_string(), PhpMixed::Bool(false)); + m.insert( + "html_url".to_string(), + PhpMixed::String("https://codeberg.org/acme/repo".to_string()), + ); + m.insert( + "ssh_url".to_string(), + PhpMixed::String("git@codeberg.org:acme/repo.git".to_string()), + ); + m.insert( + "clone_url".to_string(), + PhpMixed::String("https://codeberg.org/acme/repo.git".to_string()), + ); + m + })) } -// The remaining cases construct a ForgejoDriver and mock the HttpDownloader to return -// Forgejo API responses; mocking is not available, and a real HttpDownloader reaches -// curl_multi_init (todo!()). #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_public_repository() { - let SetUp { - home, - config, - io, + let set_up = set_up(); + let _tear_down = TearDown::new(set_up.home.path().to_path_buf()); + + let (http_downloader, _http_guard) = http_mock(vec![expect_full( + "https://codeberg.org/api/v1/repos/acme/repo", + None, + 200, + repo_body(), + vec![String::new()], + )]); + + let (mut driver, _process_guard) = initialize_driver( + &set_up, + "https://codeberg.org/acme/repo.git", http_downloader, - } = set_up(); - let _tear_down = TearDown::new(home.path().to_path_buf()); - let _ = (&config, &io, &http_downloader); - todo!() + ); + assert_eq!("main", driver.get_root_identifier().unwrap()); + + let sha = "SOMESHA"; + let dist = driver.get_dist(sha).unwrap(); + assert_eq!("zip", dist.get("type").unwrap()); + assert_eq!( + "https://codeberg.org/api/v1/repos/acme/repo/archive/SOMESHA.zip", + dist.get("url").unwrap() + ); + assert_eq!(sha, dist.get("reference").unwrap()); + + let source = driver.get_source(sha); + assert_eq!("git", source.get("type").unwrap()); + assert_eq!( + "https://codeberg.org/acme/repo.git", + source.get("url").unwrap() + ); + assert_eq!(sha, source.get("reference").unwrap()); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_get_branches() { - let SetUp { - home, - config, - io, + let set_up = set_up(); + let _tear_down = TearDown::new(set_up.home.path().to_path_buf()); + + let branches_body = shirabe::json::JsonFile::encode(&PhpMixed::List(vec![PhpMixed::Array({ + let mut commit: IndexMap<String, PhpMixed> = IndexMap::new(); + commit.insert("id".to_string(), PhpMixed::String("SOMESHA".to_string())); + let mut entry: IndexMap<String, PhpMixed> = IndexMap::new(); + entry.insert("name".to_string(), PhpMixed::String("main".to_string())); + entry.insert("commit".to_string(), PhpMixed::Array(commit)); + entry + })])); + + let (http_downloader, _http_guard) = http_mock(vec![ + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo", + None, + 200, + repo_body(), + vec![String::new()], + ), + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo/branches?per_page=100", + None, + 200, + branches_body, + vec![String::new()], + ), + ]); + + let (mut driver, _process_guard) = initialize_driver( + &set_up, + "https://codeberg.org/acme/repo.git", http_downloader, - } = set_up(); - let _tear_down = TearDown::new(home.path().to_path_buf()); - let _ = (&config, &io, &http_downloader); - todo!() + ); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert("main".to_string(), "SOMESHA".to_string()); + assert_eq!(expected, driver.get_branches().unwrap()); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_get_tags() { - let SetUp { - home, - config, - io, + let set_up = set_up(); + let _tear_down = TearDown::new(set_up.home.path().to_path_buf()); + + let tags_body = shirabe::json::JsonFile::encode(&PhpMixed::List(vec![PhpMixed::Array({ + let mut commit: IndexMap<String, PhpMixed> = IndexMap::new(); + commit.insert("sha".to_string(), PhpMixed::String("SOMESHA".to_string())); + let mut entry: IndexMap<String, PhpMixed> = IndexMap::new(); + entry.insert("name".to_string(), PhpMixed::String("1.0".to_string())); + entry.insert("commit".to_string(), PhpMixed::Array(commit)); + entry + })])); + + let (http_downloader, _http_guard) = http_mock(vec![ + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo", + None, + 200, + repo_body(), + vec![String::new()], + ), + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo/tags?per_page=100", + None, + 200, + tags_body, + vec![String::new()], + ), + ]); + + let (mut driver, _process_guard) = initialize_driver( + &set_up, + "https://codeberg.org/acme/repo.git", http_downloader, - } = set_up(); - let _tear_down = TearDown::new(home.path().to_path_buf()); - let _ = (&config, &io, &http_downloader); - todo!() + ); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert("1.0".to_string(), "SOMESHA".to_string()); + assert_eq!(expected, driver.get_tags().unwrap()); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_get_empty_file_content() { - let SetUp { - home, - config, - io, + let set_up = set_up(); + let _tear_down = TearDown::new(set_up.home.path().to_path_buf()); + + let (http_downloader, _http_guard) = http_mock(vec![ + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo", + None, + 200, + repo_body(), + vec![String::new()], + ), + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo/contents/composer.json?ref=main", + None, + 200, + "{\"encoding\":\"base64\",\"content\":\"\"}", + vec![String::new()], + ), + ]); + + let (mut driver, _process_guard) = initialize_driver( + &set_up, + "https://codeberg.org/acme/repo.git", http_downloader, - } = set_up(); - let _tear_down = TearDown::new(home.path().to_path_buf()); - let _ = (&config, &io, &http_downloader); - todo!() + ); + + assert_eq!( + Some(String::new()), + driver.get_file_content("composer.json", "main").unwrap() + ); +} + +fn supports_provider() -> Vec<(bool, &'static str)> { + vec![ + (false, "https://example.org/acme/repo"), + (true, "https://codeberg.org/acme/repository"), + ] +} + +#[test] +fn test_supports() { + let set_up = set_up(); + let _tear_down = TearDown::new(set_up.home.path().to_path_buf()); + + for (expected, repo_url) in supports_provider() { + assert_eq!( + expected, + ForgejoDriver::supports(set_up.io.clone(), set_up.config.clone(), repo_url, false) + .unwrap() + ); + } } diff --git a/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs b/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs index d9178dd..5e9471f 100644 --- a/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs +++ b/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs @@ -108,7 +108,6 @@ fn test_supports_returns_false_no_deep_check() { // The remaining cases mock Perforce, the repository config and IO to drive initialization, // composer-file detection and cleanup; mocking is not available here. -#[ignore] #[test] fn test_initialize_captures_variables_from_repo_config() { let SetUp { @@ -132,9 +131,13 @@ fn test_initialize_captures_variables_from_repo_config() { IndexMap::new(), false, ))); - let process = Rc::new(RefCell::new(shirabe::util::ProcessExecutor::new(Some( - io.clone(), - )))); + // ref: setUp uses getProcessExecutorMock(); the empty, non-strict mock lets Perforce's p4 + // queries resolve with the default (exit 0) result so initialize() never shells out to p4. + let (process, _process_guard) = crate::process_executor_mock::get_process_executor_mock( + vec![], + false, + shirabe::util::process_executor::MockHandler::default(), + ); let mut driver = PerforceDriver::new(repo_config, io, config, http_downloader, process); driver.initialize().unwrap(); diff --git a/crates/shirabe/tests/repository/vcs_repository_test.rs b/crates/shirabe/tests/repository/vcs_repository_test.rs index d84ed51..279db04 100644 --- a/crates/shirabe/tests/repository/vcs_repository_test.rs +++ b/crates/shirabe/tests/repository/vcs_repository_test.rs @@ -1,29 +1,145 @@ //! ref: composer/tests/Composer/Test/Repository/VcsRepositoryTest.php use std::cell::RefCell; +use std::collections::HashSet; +use std::process::Command; use std::rc::Rc; use indexmap::IndexMap; +use serial_test::serial; use shirabe::config::Config; use shirabe::io::IOInterface; use shirabe::io::null_io::NullIO; -use shirabe::package::dumper::ArrayDumper; -use shirabe::repository::RepositoryInterface; use shirabe::repository::VcsRepository; +use shirabe::util::ProcessExecutor; use shirabe::util::filesystem::Filesystem; use shirabe::util::http_downloader::HttpDownloader; +use shirabe::util::r#loop::Loop; use shirabe_php_shim::PhpMixed; +use tempfile::TempDir; struct SetUp { - composer_home: std::path::PathBuf, - git_repo: std::path::PathBuf, + composer_home: TempDir, + git_repo: TempDir, } -fn set_up() -> SetUp { - // setUp lazily runs initialize(), which shells out to git to build a fixture repository - // on disk; the ExecutableFinder/ProcessExecutor-driven setup and the markTestSkipped - // skip path are not ported. - todo!() +// ref: VcsRepositoryTest::initialize. Builds a fixture git repository on disk by shelling out to +// git. Returns None when git is unavailable (mirroring markTestSkipped). +fn set_up() -> Option<SetUp> { + if which_git().is_none() { + // 'This test needs a git binary in the PATH to be able to run' + return None; + } + + let composer_home = TempDir::new().unwrap(); + let git_repo = TempDir::new().unwrap(); + let path = git_repo.path(); + + let exec = |args: &[&str]| { + let status = Command::new("git") + .args(args) + .current_dir(path) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .status() + .unwrap_or_else(|e| panic!("Failed to execute git {:?}: {}", args, e)); + if !status.success() { + panic!("Failed to execute git {:?}", args); + } + }; + let write_file = |name: &str, contents: &str| { + std::fs::write(path.join(name), contents).unwrap(); + }; + + // init + exec(&["init", "-q"]); + exec(&["checkout", "-b", "master"]); + exec(&["config", "user.email", "composertest@example.org"]); + exec(&["config", "user.name", "ComposerTest"]); + exec(&["config", "commit.gpgsign", "false"]); + write_file("foo", ""); + exec(&["add", "foo"]); + exec(&["commit", "-m", "init"]); + + // non-composed tag & branch + exec(&["tag", "0.5.0"]); + exec(&["branch", "oldbranch"]); + + // add composed tag & master branch + write_file( + "composer.json", + &shirabe::json::JsonFile::encode(&composer(None)), + ); + exec(&["add", "composer.json"]); + exec(&["commit", "-m", "addcomposer"]); + exec(&["tag", "0.6.0"]); + + // add feature-a branch + exec(&["checkout", "-b", "feature/a-1.0-B"]); + write_file("foo", "bar feature"); + exec(&["add", "foo"]); + exec(&["commit", "-m", "change-a"]); + + // add foo#bar branch which should result in dev-foo+bar + exec(&["branch", "foo#bar"]); + + // add version to composer.json + exec(&["checkout", "master"]); + write_file( + "composer.json", + &shirabe::json::JsonFile::encode(&composer(Some("1.0.0"))), + ); + exec(&["add", "composer.json"]); + exec(&["commit", "-m", "addversion"]); + + // create tag with wrong version in it + exec(&["tag", "0.9.0"]); + // create tag with correct version in it + exec(&["tag", "1.0.0"]); + + // add feature-b branch + exec(&["checkout", "-b", "feature-b"]); + write_file("foo", "baz feature"); + exec(&["add", "foo"]); + exec(&["commit", "-m", "change-b"]); + + // add 1.0 branch + exec(&["checkout", "master"]); + exec(&["branch", "1.0"]); + + // add 1.0.x branch + exec(&["branch", "1.1.x"]); + + // update master to 2.0 + write_file( + "composer.json", + &shirabe::json::JsonFile::encode(&composer(Some("2.0.0"))), + ); + exec(&["add", "composer.json"]); + exec(&["commit", "-m", "bump-version"]); + + Some(SetUp { + composer_home, + git_repo, + }) +} + +fn composer(version: Option<&str>) -> PhpMixed { + let mut m: IndexMap<String, PhpMixed> = IndexMap::new(); + m.insert("name".to_string(), PhpMixed::String("a/b".to_string())); + if let Some(version) = version { + m.insert("version".to_string(), PhpMixed::String(version.to_string())); + } + PhpMixed::Array(m) +} + +fn which_git() -> Option<()> { + Command::new("git") + .arg("--version") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|_| ()) } fn tear_down(composer_home: &std::path::Path, git_repo: &std::path::Path) { @@ -52,13 +168,85 @@ impl Drop for TearDown { } } -// testLoadVersions initialises a real git repository on disk and drives a VcsRepository over -// it, then asserts the loaded package versions; the git fixture setup and constraint parsing -// (look-around regex) are not ported. In addition, VcsRepository does not implement -// RepositoryInterface and keeps its inner ArrayRepository pub(crate), so getPackages() (inherited -// from ArrayRepository in PHP) is not reachable from the test crate. #[test] -#[ignore = "VcsRepository does not expose get_packages() to the test crate; git fixture setup not ported"] +#[serial] fn test_load_versions() { - todo!() + let Some(set_up) = set_up() else { + // git binary unavailable; skip like markTestSkipped. + return; + }; + let composer_home = set_up.composer_home.path().to_path_buf(); + let git_repo = set_up.git_repo.path().to_path_buf(); + + let mut expected: HashSet<String> = [ + "0.6.0", + "1.0.0", + "1.0.x-dev", + "1.1.x-dev", + "dev-feature-b", + "dev-feature/a-1.0-B", + "dev-foo+bar", + "dev-master", + "9999999-dev", // alias of dev-master + ] + .iter() + .map(|s| s.to_string()) + .collect(); + + let mut config = Config::new(true, None); + let mut top: IndexMap<String, PhpMixed> = IndexMap::new(); + let mut config_section: IndexMap<String, PhpMixed> = IndexMap::new(); + config_section.insert( + "home".to_string(), + PhpMixed::String(composer_home.to_string_lossy().into_owned()), + ); + top.insert("config".to_string(), PhpMixed::Array(config_section)); + config.merge(&top, Config::SOURCE_UNKNOWN); + let config = Rc::new(RefCell::new(config)); + + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new())); + let http_downloader = Rc::new(RefCell::new(HttpDownloader::new( + io.clone(), + config.clone(), + IndexMap::new(), + false, + ))); + let process = Rc::new(RefCell::new(ProcessExecutor::new(Some(io.clone())))); + // VcsRepository's git driver / VersionGuesser run async git processes; constructing a Loop + // enables async on the shared ProcessExecutor. + let _loop = Loop::new(http_downloader.clone(), Some(process.clone())); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert( + "url".to_string(), + PhpMixed::String(git_repo.to_string_lossy().into_owned()), + ); + repo_config.insert("type".to_string(), PhpMixed::String("vcs".to_string())); + + let mut repo = VcsRepository::new( + repo_config, + io, + config, + http_downloader, + None, + Some(process), + None, + None, + ) + .unwrap(); + + let _tear_down = TearDown::new(composer_home, git_repo); + + let packages = repo.__get_packages().unwrap(); + + for package in &packages { + let pretty = package.get_pretty_version(); + assert!(expected.remove(&pretty), "Unexpected version {}", pretty); + } + + assert!( + expected.is_empty(), + "Missing versions: {}", + expected.into_iter().collect::<Vec<_>>().join(", ") + ); } |
