diff options
Diffstat (limited to 'crates')
21 files changed, 3454 insertions, 272 deletions
diff --git a/crates/shirabe-class-map-generator/src/class_map_generator.rs b/crates/shirabe-class-map-generator/src/class_map_generator.rs index 5359f12..f591811 100644 --- a/crates/shirabe-class-map-generator/src/class_map_generator.rs +++ b/crates/shirabe-class-map-generator/src/class_map_generator.rs @@ -181,7 +181,14 @@ impl ClassMapGenerator { file_path = format!("{}/{}", cwd, file_path); file_path = Self::normalize_path(&file_path); } else { - file_path = Preg::replace(r"{(?<!:)[\\/]{2,}}", "/", &file_path); + // Regex pattern compatibility: + // PHP collapses runs of 2+ slashes/backslashes into one, except when the run is + // immediately preceded by `:` (to preserve scheme separators like `phar://`). The + // `regex` crate has no look-behind, so the `(?<!:)` guard is turned into a consuming + // optional leading group `(^|[^:])` that is re-emitted in the replacement. Slash runs + // are always separated by path-segment characters, so consuming the single preceding + // char never prevents an adjacent run from matching. + file_path = Preg::replace(r"{(^|[^:])[\\/]{2,}}", "${1}/", &file_path); } if file_path.is_empty() { diff --git a/crates/shirabe-class-map-generator/src/php_file_cleaner.rs b/crates/shirabe-class-map-generator/src/php_file_cleaner.rs index 2b97d34..22e37a9 100644 --- a/crates/shirabe-class-map-generator/src/php_file_cleaner.rs +++ b/crates/shirabe-class-map-generator/src/php_file_cleaner.rs @@ -34,8 +34,18 @@ impl PhpFileCleaner { TypeConfigEntry { name: r#type.clone(), length: r#type.len(), + // Regex pattern compatibility: + // PHP uses `.\b(?<![$:>])<type>` anchored (`A`): it consumes the single char + // before the keyword (`.`), requires a word boundary there (`\b`) and forbids + // that char being `$`, `:` or `>`. The `regex` crate has no look-behind, so the + // consumed char plus both guards collapse into one negated class + // `[^a-zA-Z0-9_$:>]` (the `\w` set reproducing `\b`, plus the three operators). + // The possessive quantifiers (`++`, `*+`) are performance-only and become plain + // `+`/`*`. The leftmost-match semantics of `captures_at(.., offset)` stand in for + // the dropped `A` (anchored) modifier, since the keyword is known to sit exactly + // one char past the search offset. pattern: format!( - "{{.\\b(?<![$:>]){}\\s++[a-zA-Z_\\x7f-\\xff:][a-zA-Z0-9_\\x7f-\\xff:\\-]*+}}Ais", + "{{[^a-zA-Z0-9_$:>]{}\\s+[a-zA-Z_\\x7f-\\xff:][a-zA-Z0-9_\\x7f-\\xff:\\-]*}}is", r#type ), }, diff --git a/crates/shirabe-class-map-generator/src/php_file_parser.rs b/crates/shirabe-class-map-generator/src/php_file_parser.rs index ae98a5c..7d7c722 100644 --- a/crates/shirabe-class-map-generator/src/php_file_parser.rs +++ b/crates/shirabe-class-map-generator/src/php_file_parser.rs @@ -77,12 +77,21 @@ impl PhpFileParser { let contents = p.clean(); drop(p); + // Regex pattern compatibility: + // PHP uses `\b(?<![\\$:>])<keyword>` to require the keyword to start at a word boundary and + // not be preceded by `\`, `$`, `:` or `>` (so `MyClass::class`, `$class`, `\class`, + // `Foo->class` are skipped). The `regex` crate has no look-behind, so `\b` + the negative + // look-behind are fused into a single consuming class `(?:^|[^...])` that excludes both the + // identifier characters (reproducing `\b`) and the four operator characters. The consumed + // separator lands in match group 0 only; the named groups are unaffected. The PCRE + // possessive quantifiers (`++`, `*+`) are performance-only and become plain `+`/`*`. let pattern2 = format!( - r"(?ix) + r"{{ (?: - \b(?<![\\$:>])(?P<type>class|interface|trait{et}) \s++ (?P<name>[a-zA-Z_\x7f-\xff:][a-zA-Z0-9_\x7f-\xff:\-]*+) - | \b(?<![\\$:>])(?P<ns>namespace) (?P<nsname>\s++[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\s*+\\\\\s*+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+)? \s*+ [\{{;] - )", + (?:^|[^\\$:>a-zA-Z0-9_\x7f-\xff])(?P<type>class|interface|trait{et}) \s+ (?P<name>[a-zA-Z_\x7f-\xff:][a-zA-Z0-9_\x7f-\xff:\-]*) + | (?:^|[^\\$:>a-zA-Z0-9_\x7f-\xff])(?P<ns>namespace) (?P<nsname>\s+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*(?:\s*\\\s*[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)*)? \s* [\{{;] + ) + }}ix", et = extra_types ); let mut matches: IndexMap<_, _> = IndexMap::new(); diff --git a/crates/shirabe-php-shim/src/fs.rs b/crates/shirabe-php-shim/src/fs.rs index ee2bbee..5558f58 100644 --- a/crates/shirabe-php-shim/src/fs.rs +++ b/crates/shirabe-php-shim/src/fs.rs @@ -254,12 +254,15 @@ pub fn fopen(file: &str, mode: &str) -> Result<PhpResource, std::io::Error> { "w" | "a" | "x" | "c" => (false, true), _ => (true, true), // r+, w+, a+, x+, c+, rw, ... }; - // php://memory and php://temp are in-memory streams. + // php://memory and php://temp are in-memory streams. PHP's memory wrapper ignores the read / + // write restrictions implied by the mode: a memory stream opened with "w" is still readable + // (this is what Symfony's ApplicationTester relies on -- it opens "php://memory" with "w" and + // then rewinds + reads it back). So memory streams are always both readable and writable. if file == "php://memory" || file.starts_with("php://temp") { return Ok(StreamState::new( StreamBacking::Memory(std::io::Cursor::new(Vec::new())), - readable, - writable, + true, + true, mode.to_string(), file.to_string(), )); diff --git a/crates/shirabe-php-shim/src/preg.rs b/crates/shirabe-php-shim/src/preg.rs index 56aa402..e5bd52e 100644 --- a/crates/shirabe-php-shim/src/preg.rs +++ b/crates/shirabe-php-shim/src/preg.rs @@ -25,7 +25,12 @@ impl PregOffsetCaptureMatches { } pub fn preg_quote(str: &str, delimiter: Option<char>) -> String { - const SPECIAL: &str = ".\\+*?[^]$(){}=!<>|:-#"; + // Regex pattern compatibility: + // PHP's preg_quote escapes `<` and `>` (PCRE treats `\<`/`\>` as literals), but the `regex` + // crate reads `\<`/`\>` as start-of-word / end-of-word boundary assertions. `<` and `>` are + // already literal in the `regex` crate, so they are emitted unescaped to preserve the intended + // literal match. + const SPECIAL: &str = ".\\+*?[^]$(){}=!|:-#"; let mut out = String::new(); for c in str.chars() { if c == '\0' { @@ -43,7 +48,8 @@ pub fn preg_quote(str: &str, delimiter: Option<char>) -> String { // Returns whether the pattern matched; populates matches[0]=full match, matches[1..]=captures. // Optional groups that did not participate in the match are stored as None. pub fn preg_match(pattern: &str, subject: &str, matches: &mut Vec<Option<String>>) -> bool { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); matches.clear(); match re.captures(subject) { Some(caps) => { @@ -67,7 +73,8 @@ pub fn preg_split(pattern: &str, subject: &str) -> Vec<String> { // PREG_PATTERN_ORDER: the outer vec is indexed by capture group, the inner by // match occurrence. Non-participating groups are reported as "". pub fn preg_match_all(pattern: &str, subject: &str) -> Vec<Vec<String>> { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let group_count = re.captures_len(); let mut groups: Vec<Vec<String>> = vec![Vec::new(); group_count]; for caps in re.captures_iter(subject) { @@ -89,7 +96,8 @@ pub fn preg_match_all_set_order( subject: &str, matches: &mut Vec<Vec<String>>, ) -> usize { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let mut rows: Vec<Vec<String>> = Vec::new(); for caps in re.captures_iter(subject) { rows.push(php_match_row(&caps)); @@ -100,7 +108,8 @@ pub fn preg_match_all_set_order( } pub fn preg_grep(pattern: &str, input: &[String]) -> Vec<String> { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); input.iter().filter(|s| re.is_match(s)).cloned().collect() } @@ -109,7 +118,8 @@ pub fn preg_match_all_offset_capture( subject: &str, matches: &mut PregOffsetCaptureMatches, ) -> usize { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let group_count = re.captures_len(); matches.groups = vec![Vec::new(); group_count]; @@ -139,7 +149,8 @@ pub fn preg_replace_callback<F>( where F: FnMut(&[Option<String>]) -> anyhow::Result<String>, { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let mut out: Vec<u8> = Vec::new(); let mut last = 0; for caps in re.captures_iter(subject) { @@ -165,9 +176,18 @@ pub fn preg_match2( flags: i64, offset: usize, ) -> bool { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0; - let caps = re.captures_at(subject, offset); + // An anchored (`A`) pattern must match starting exactly at `offset`; the `regex` crate cannot + // anchor a `captures_at` search, so search the sub-slice beginning at `offset` and require the + // match to start at its head. + let caps = if anchored { + re.captures(&subject[offset..]) + .filter(|c| c.get(0).map(|m| m.start()) == Some(0)) + } else { + re.captures_at(subject, offset) + }; matches.clear(); if let Some(caps) = &caps { @@ -185,7 +205,8 @@ pub fn preg_match_all2( flags: i64, offset: usize, ) -> usize { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0; let group_count = re.captures_len(); let names: Vec<Option<&str>> = re.capture_names().collect(); @@ -223,7 +244,8 @@ pub fn preg_match_all_offset_capture2( flags: i64, offset: usize, ) -> usize { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0; let group_count = re.captures_len(); let names: Vec<Option<&str>> = re.capture_names().collect(); @@ -260,7 +282,8 @@ pub fn preg_replace2( limit: i64, count: Option<&mut usize>, ) -> String { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let limit = if limit < 0 { usize::MAX } else { @@ -298,7 +321,8 @@ pub fn preg_replace_callback2< count: Option<&mut usize>, flags: i64, ) -> String { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0; let names: Vec<Option<&str>> = re.capture_names().collect(); let limit = if limit < 0 { @@ -330,7 +354,8 @@ pub fn preg_replace_callback2< } pub fn preg_split2(pattern: &str, subject: &str, limit: i64, flags: i64) -> Vec<String> { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let no_empty = flags & PREG_SPLIT_NO_EMPTY != 0; let delim_capture = flags & PREG_SPLIT_DELIM_CAPTURE != 0; // `limit` counts the resulting pieces; a non-positive value means no limit. @@ -371,7 +396,8 @@ pub fn preg_split2(pattern: &str, subject: &str, limit: i64, flags: i64) -> Vec< } pub fn preg_grep2(pattern: &str, array: &[&str], flags: i64) -> Vec<String> { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let (re, _anchored) = + compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); let invert = flags & PREG_GREP_INVERT != 0; array .iter() @@ -386,7 +412,34 @@ pub fn preg_grep2(pattern: &str, array: &[&str], flags: i64) -> Vec<String> { // lookaround, backreferences) are not supported by `regex` and must be avoided // in the caller's pattern. // TODO(phase-c): replace with a faithful PCRE engine to restore full semantics. -fn compile_php_pattern(pattern: &str) -> anyhow::Result<regex::Regex> { +// PCRE treats `\<` and `\>` as escaped literal `<`/`>`, but the `regex` crate +// reads them as start/end-of-word boundary assertions. Rewrite those escapes to +// the literal characters so PCRE-sourced patterns (e.g. anything run through +// `preg_quote`, which escapes `<` and `>`) keep their original meaning. A `\\` +// escapes the following backslash, so `\\<` is left untouched. +fn translate_pcre_literals(inner: &str) -> String { + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.peek() { + Some('<') | Some('>') => { + out.push(chars.next().unwrap()); + } + Some('\\') => { + out.push('\\'); + out.push(chars.next().unwrap()); + } + _ => out.push('\\'), + } + } else { + out.push(c); + } + } + out +} + +fn compile_php_pattern(pattern: &str) -> anyhow::Result<(regex::Regex, bool)> { let delimiter = pattern .chars() .next() @@ -412,13 +465,20 @@ fn compile_php_pattern(pattern: &str) -> anyhow::Result<regex::Regex> { .filter(|c| matches!(c, 'i' | 'x' | 's' | 'm')) .collect(); + // PCRE's `A` (PCRE_ANCHORED) modifier requires the match to start exactly at the search offset. + // The `regex` crate has no per-search anchoring, so the offset-based callers + // (`preg_match2`/`preg_match_all2`) honour it by searching a sub-slice that begins at the offset; + // here we only surface the flag. + let anchored = modifiers.contains('A'); + + let inner = translate_pcre_literals(inner); let translated = if flags.is_empty() { - inner.to_string() + inner } else { format!("(?{flags}){inner}") }; - Ok(regex::Regex::new(&translated)?) + Ok((regex::Regex::new(&translated)?, anchored)) } // Expands a PHP preg replacement template against `caps`, appending bytes to diff --git a/crates/shirabe-php-shim/src/process.rs b/crates/shirabe-php-shim/src/process.rs index 402ad8b..b652bed 100644 --- a/crates/shirabe-php-shim/src/process.rs +++ b/crates/shirabe-php-shim/src/process.rs @@ -371,14 +371,19 @@ pub fn pcntl_signal_get_handler(_signal: i64) -> PhpMixed { todo!() } +unsafe extern "C" { + fn getuid() -> u32; + fn geteuid() -> u32; +} + pub fn posix_getuid() -> i64 { - // TODO(phase-d): getuid(2) is not reachable without a libc/syscall crate. - todo!() + // getuid(2) cannot fail; libc is already linked into every binary, so no extra crate is needed. + (unsafe { getuid() }) as i64 } pub fn posix_geteuid() -> i64 { - // TODO(phase-d): geteuid(2) is not reachable without a libc/syscall crate. - todo!() + // geteuid(2) cannot fail; libc is already linked into every binary, so no extra crate is needed. + (unsafe { geteuid() }) as i64 } pub fn posix_getpwuid(_uid: i64) -> PhpMixed { diff --git a/crates/shirabe-php-shim/src/stream.rs b/crates/shirabe-php-shim/src/stream.rs index f5582a7..a8ccbe5 100644 --- a/crates/shirabe-php-shim/src/stream.rs +++ b/crates/shirabe-php-shim/src/stream.rs @@ -112,9 +112,28 @@ pub fn stream_isatty(stream: PhpResource) -> bool { } pub fn stream_get_wrappers() -> Vec<String> { - // TODO(phase-d): the registered wrapper set depends on compiled-in extensions and runtime - // `stream_wrapper_register` calls; neither is modeled, so a fixed list would be inaccurate. - todo!() + // The full registered set depends on compiled-in extensions and runtime + // `stream_wrapper_register` calls, which are not modeled. We return the wrappers always + // registered by a stock PHP CLI build (the environment Composer's tests assume): the core + // `php`/`file`/`glob`/`data` wrappers, the always-present `phar` wrapper, plus the + // `compress.*`/`http`/`ftp` family. Consumers only use this to recognise `wrapper://` prefixes. + [ + "https", + "ftps", + "compress.zlib", + "compress.bzip2", + "php", + "file", + "glob", + "data", + "http", + "ftp", + "phar", + "zip", + ] + .iter() + .map(|s| s.to_string()) + .collect() } /// PHP `stream_copy_to_stream()`: copy the remaining bytes of `source` into `dest`, returning the diff --git a/crates/shirabe-php-shim/src/string.rs b/crates/shirabe-php-shim/src/string.rs index 48c72f4..9a82fe8 100644 --- a/crates/shirabe-php-shim/src/string.rs +++ b/crates/shirabe-php-shim/src/string.rs @@ -1327,10 +1327,142 @@ pub fn addcslashes(_string: &str, _charlist: &str) -> String { String::from_utf8_lossy(&out).into_owned() } -pub fn php_strip_whitespace(_path: &str) -> String { - // TODO(phase-d): PHP strips comments and redundant whitespace using the PHP tokenizer; no PHP - // tokenizer is available in the shim. - todo!() +pub fn php_strip_whitespace(path: &str) -> String { + // PHP `php_strip_whitespace()` tokenizes the source and re-emits it with comments removed and + // each run of whitespace collapsed to a single space. There is no PHP tokenizer in the shim, so + // this is a hand-written lexer that reproduces the observable effect for the cases the class-map + // generator depends on: it preserves single-quoted, double-quoted, backtick and heredoc/nowdoc + // string contents verbatim while dropping `//`, `#` and `/* */` comments and squeezing + // whitespace. On any read failure it returns an empty string, mirroring `@php_strip_whitespace`. + let contents = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(_) => return String::new(), + }; + + let b = contents; + let n = b.len(); + let mut out: Vec<u8> = Vec::with_capacity(n); + let mut i = 0usize; + + let push_space = |out: &mut Vec<u8>| { + if !out.is_empty() && *out.last().unwrap() != b' ' { + out.push(b' '); + } + }; + + while i < n { + let c = b[i]; + + // Line comments: // ... and # ... + if c == b'/' && i + 1 < n && b[i + 1] == b'/' { + i += 2; + while i < n && b[i] != b'\n' { + i += 1; + } + push_space(&mut out); + continue; + } + if c == b'#' { + i += 1; + while i < n && b[i] != b'\n' { + i += 1; + } + push_space(&mut out); + continue; + } + // Block comments: /* ... */ + if c == b'/' && i + 1 < n && b[i + 1] == b'*' { + i += 2; + while i + 1 < n && !(b[i] == b'*' && b[i + 1] == b'/') { + i += 1; + } + i += 2; + push_space(&mut out); + continue; + } + // String literals: '...', "...", `...` + if c == b'\'' || c == b'"' || c == b'`' { + out.push(c); + i += 1; + while i < n { + let d = b[i]; + out.push(d); + if d == b'\\' && i + 1 < n { + out.push(b[i + 1]); + i += 2; + continue; + } + i += 1; + if d == c { + break; + } + } + continue; + } + // Heredoc/Nowdoc: <<<LABEL ... LABEL + if c == b'<' && i + 2 < n && b[i + 1] == b'<' && b[i + 2] == b'<' { + let mut j = i + 3; + while j < n && (b[j] == b' ' || b[j] == b'\t') { + j += 1; + } + let nowdoc = j < n && b[j] == b'\''; + let quoted = j < n && (b[j] == b'\'' || b[j] == b'"'); + if quoted { + j += 1; + } + let label_start = j; + while j < n && (b[j].is_ascii_alphanumeric() || b[j] == b'_') { + j += 1; + } + let label = b[label_start..j].to_vec(); + if !label.is_empty() { + let _ = nowdoc; + // Emit verbatim from the opening marker until a line whose trimmed start matches the + // closing label. + let body_start = i; + let mut k = j; + // skip to end of the opening line + while k < n && b[k] != b'\n' { + k += 1; + } + let mut closed = n; + while k < n { + k += 1; // move past '\n' + let line_start = k; + let mut m = line_start; + while m < n && (b[m] == b' ' || b[m] == b'\t') { + m += 1; + } + if b[m..].starts_with(&label) { + let after = m + label.len(); + let boundary = + after >= n || !(b[after].is_ascii_alphanumeric() || b[after] == b'_'); + if boundary { + closed = after; + break; + } + } + while k < n && b[k] != b'\n' { + k += 1; + } + } + out.extend_from_slice(&b[body_start..closed.min(n)]); + i = closed.min(n); + continue; + } + } + // Whitespace runs collapse to a single space. + if c == b' ' || c == b'\t' || c == b'\r' || c == b'\n' { + i += 1; + push_space(&mut out); + continue; + } + + out.push(c); + i += 1; + } + + String::from_utf8_lossy(&out).into_owned() } pub fn hexdec(_s: &str) -> i64 { diff --git a/crates/shirabe-php-shim/src/var.rs b/crates/shirabe-php-shim/src/var.rs index adaacec..3fba9d6 100644 --- a/crates/shirabe-php-shim/src/var.rs +++ b/crates/shirabe-php-shim/src/var.rs @@ -120,11 +120,15 @@ pub fn is_numeric(value: &PhpMixed) -> bool { } } -pub fn is_callable(_value: &PhpMixed) -> bool { - // TODO(phase-d): PHP is_callable() checks whether the value names an existing function/method or - // is a closure. PhpMixed has no callable variant and the shim has no function registry, so - // callability cannot be determined. - todo!() +pub fn is_callable(value: &PhpMixed) -> bool { + match value { + // Scalars and null are never callable in PHP. + PhpMixed::Null | PhpMixed::Bool(_) | PhpMixed::Int(_) | PhpMixed::Float(_) => false, + // TODO(phase-d): PHP is_callable() checks whether a string names an existing function, or an + // array/object resolves to a method/__invoke. PhpMixed has no callable variant and the shim + // has no function/method registry, so callability of these cannot be determined. + _ => todo!(), + } } pub fn is_object(_value: &PhpMixed) -> bool { diff --git a/crates/shirabe/src/autoload/autoload_generator.rs b/crates/shirabe/src/autoload/autoload_generator.rs index ded7cee..1910136 100644 --- a/crates/shirabe/src/autoload/autoload_generator.rs +++ b/crates/shirabe/src/autoload/autoload_generator.rs @@ -355,10 +355,10 @@ impl AutoloadGenerator { .collect(); } - let classmap_list = autoloads + let classmap_list: Vec<PhpMixed> = autoloads .get("classmap") - .and_then(|v| v.as_list()) - .cloned() + .and_then(|v| v.as_array()) + .map(|m| m.values().cloned().collect()) .unwrap_or_default(); for dir in &classmap_list { let dir_str = dir.as_string().unwrap_or(""); @@ -647,13 +647,20 @@ impl AutoloadGenerator { ), )?; - // PHP: __DIR__ refers to the directory of AutoloadGenerator.php + // PHP: __DIR__ refers to the directory of AutoloadGenerator.php, an absolute path + // independent of the cwd. The ClassLoader.php and LICENSE templates are bundled from the + // upstream composer source tree, resolved at build time relative to this crate's manifest + // (mirroring the InstalledVersions.php include in FilesystemRepository). + let autoload_dir = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../composer/src/Composer/Autoload" + ); filesystem.safe_copy( - &format!("{}/ClassLoader.php", "composer/src/Composer/Autoload"), + &format!("{}/ClassLoader.php", autoload_dir), &format!("{}/ClassLoader.php", target_dir), )?; filesystem.safe_copy( - &format!("{}/../../../LICENSE", "composer/src/Composer/Autoload"), + &format!("{}/../../../LICENSE", autoload_dir), &format!("{}/LICENSE", target_dir), )?; @@ -1407,7 +1414,7 @@ impl AutoloadGenerator { let mut loader = ClassLoader::new(None); // PHP: $map = require $targetDir . '/autoload_namespaces.php'; - let map = shirabe_php_shim::php_require(&format!("{}/autoload_namespaces.php", target_dir)); + let map = require_generated_php_array(&format!("{}/autoload_namespaces.php", target_dir)); if let Some(map_arr) = map.as_array() { for (namespace, path) in map_arr { let paths: Vec<String> = if let PhpMixed::List(items) = path.clone() { @@ -1422,7 +1429,7 @@ impl AutoloadGenerator { } } - let map = shirabe_php_shim::php_require(&format!("{}/autoload_psr4.php", target_dir)); + let map = require_generated_php_array(&format!("{}/autoload_psr4.php", target_dir)); if let Some(map_arr) = map.as_array() { for (namespace, path) in map_arr { let paths: Vec<String> = if let PhpMixed::List(items) = path.clone() { @@ -1438,7 +1445,7 @@ impl AutoloadGenerator { } let class_map = - shirabe_php_shim::php_require(&format!("{}/autoload_classmap.php", target_dir)); + require_generated_php_array(&format!("{}/autoload_classmap.php", target_dir)); if class_map.as_bool() != Some(false) && !class_map.is_null() && let Some(cm) = class_map.as_array() @@ -1558,7 +1565,7 @@ impl AutoloadGenerator { if file_exists(&format!("{}/autoload_files.php", target_dir)) { maps.insert( "files".to_string(), - shirabe_php_shim::php_require(&format!("{}/autoload_files.php", target_dir)), + require_generated_php_array(&format!("{}/autoload_files.php", target_dir)), ); } @@ -1658,7 +1665,19 @@ impl AutoloadGenerator { install_path = substr(&install_path, 0, Some(-(suffix_to_remove.len() as i64))); } - let type_arr = type_value.as_array().cloned().unwrap_or_default(); + // PHP iterates `$autoload[$type]` as a PHP array regardless of whether it is a + // string-keyed map (psr-0/psr-4) or a numerically-indexed list (classmap/files/ + // exclude-from-classmap, which JSON decodes to `PhpMixed::List`). Normalize both into + // `(key, value)` pairs so list-form types are not silently skipped. + let type_arr: IndexMap<String, PhpMixed> = match type_value { + PhpMixed::Array(a) => a.clone(), + PhpMixed::List(l) => l + .iter() + .enumerate() + .map(|(i, v)| (i.to_string(), v.clone())) + .collect(), + _ => IndexMap::new(), + }; for (namespace, paths) in type_arr { let namespace = if ["psr-4", "psr-0"].contains(&r#type) { // normalize namespaces to ensure "\" becomes "" and others do not have leading separators as they are not needed @@ -1914,6 +1933,284 @@ impl AutoloadGenerator { } } +/// Evaluates one of the `autoload_namespaces.php` / `autoload_psr4.php` / `autoload_classmap.php` +/// files that `dump` just generated, returning the `return array(...)` payload with every path +/// expression resolved to an absolute string. This stands in for PHP's `require $file` (there is no +/// interpreter), exploiting the fact that the generated grammar is fixed and fully controlled here: +/// a `$vendorDir = <expr>;` / `$baseDir = <expr>;` header followed by `return array( ... );` whose +/// values are either a single path expression or `array(<expr>, ...)`. Each expression is a ` . ` +/// joined chain of `'literal'`, `$vendorDir`, `$baseDir`, `__DIR__`, and `dirname(...)` terms. +/// Returns `PhpMixed::Bool(false)` when the file is absent (mirroring a failed `require`). +fn require_generated_php_array(file: &str) -> PhpMixed { + let contents = match std::fs::read_to_string(file) { + Ok(c) => c, + Err(_) => return PhpMixed::Bool(false), + }; + + // __DIR__ inside the generated file is the directory that contains it. + let dir = std::path::Path::new(file) + .parent() + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + + let mut vendor_dir = String::new(); + let mut base_dir = String::new(); + for line in contents.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("$vendorDir = ") { + vendor_dir = eval_php_path_expr(rest.trim_end_matches(';'), &dir, "", ""); + } else if let Some(rest) = line.strip_prefix("$baseDir = ") { + base_dir = eval_php_path_expr(rest.trim_end_matches(';'), &dir, &vendor_dir, ""); + } + } + + let start = match contents.find("return array(") { + Some(i) => i + "return array(".len(), + None => return PhpMixed::Array(IndexMap::new()), + }; + let end = match contents.rfind(");") { + Some(i) => i, + None => return PhpMixed::Array(IndexMap::new()), + }; + let body = &contents[start..end]; + + let mut map: IndexMap<String, PhpMixed> = IndexMap::new(); + for entry in split_top_level_commas(body) { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let Some(arrow) = find_top_level_arrow(entry) else { + continue; + }; + let key_expr = entry[..arrow].trim(); + let value_expr = entry[arrow + 2..].trim(); + let key = eval_php_string_literal(key_expr); + + let value = if let Some(inner) = value_expr + .strip_prefix("array(") + .and_then(|s| s.strip_suffix(')')) + { + let items: Vec<PhpMixed> = split_top_level_commas(inner) + .into_iter() + .filter(|p| !p.trim().is_empty()) + .map(|p| { + PhpMixed::String(eval_php_path_expr(p.trim(), &dir, &vendor_dir, &base_dir)) + }) + .collect(); + PhpMixed::List(items) + } else { + PhpMixed::String(eval_php_path_expr(value_expr, &dir, &vendor_dir, &base_dir)) + }; + map.insert(key, value); + } + + PhpMixed::Array(map) +} + +/// Evaluates a single PHP path expression from a generated autoload file (e.g. +/// `$baseDir . '/lib'`, `dirname(__DIR__)`, `'phar://' . $vendorDir . '/x'`). +fn eval_php_path_expr(expr: &str, dir: &str, vendor_dir: &str, base_dir: &str) -> String { + let mut out = String::new(); + for term in split_concat_terms(expr) { + let term = term.trim(); + if term.starts_with('\'') { + out.push_str(&eval_php_string_literal(term)); + } else if term == "__DIR__" { + out.push_str(dir); + } else if term == "$vendorDir" { + out.push_str(vendor_dir); + } else if term == "$baseDir" { + out.push_str(base_dir); + } else if term.starts_with("dirname(") { + out.push_str(&eval_php_dirname(term, dir, vendor_dir, base_dir)); + } + } + out +} + +/// Evaluates a (possibly nested) `dirname(...)` call against the same variable bindings. +fn eval_php_dirname(term: &str, dir: &str, vendor_dir: &str, base_dir: &str) -> String { + let inner = term + .strip_prefix("dirname(") + .and_then(|s| s.strip_suffix(')')) + .unwrap_or(""); + let resolved = eval_php_path_expr(inner, dir, vendor_dir, base_dir); + let path = resolved.trim_end_matches('/'); + match path.rfind('/') { + Some(0) => "/".to_string(), + Some(i) => path[..i].to_string(), + None => ".".to_string(), + } +} + +/// Unescapes a single-quoted PHP string literal (`'...'`), handling `\\` and `\'`. +fn eval_php_string_literal(literal: &str) -> String { + let inner = literal + .trim() + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .unwrap_or(literal); + let mut out = String::new(); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('\\') => out.push('\\'), + Some('\'') => out.push('\''), + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } else { + out.push(c); + } + } + out +} + +/// Splits a PHP concatenation expression on top-level ` . ` separators, ignoring `.` inside string +/// literals and inside parenthesised `dirname(...)` calls. +fn split_concat_terms(expr: &str) -> Vec<String> { + let bytes: Vec<char> = expr.chars().collect(); + let mut terms = Vec::new(); + let mut current = String::new(); + let mut in_string = false; + let mut depth = 0i32; + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if in_string { + current.push(c); + if c == '\\' && i + 1 < bytes.len() { + current.push(bytes[i + 1]); + i += 2; + continue; + } + if c == '\'' { + in_string = false; + } + i += 1; + continue; + } + match c { + '\'' => { + in_string = true; + current.push(c); + } + '(' => { + depth += 1; + current.push(c); + } + ')' => { + depth -= 1; + current.push(c); + } + '.' if depth == 0 + && i > 0 + && bytes[i - 1] == ' ' + && i + 1 < bytes.len() + && bytes[i + 1] == ' ' => + { + terms.push(current.trim().to_string()); + current.clear(); + } + _ => current.push(c), + } + i += 1; + } + if !current.trim().is_empty() { + terms.push(current.trim().to_string()); + } + terms +} + +/// Splits an `array(...)`/`return array(...)` body on top-level commas, ignoring commas inside +/// string literals and nested parentheses. +fn split_top_level_commas(body: &str) -> Vec<String> { + let chars: Vec<char> = body.chars().collect(); + let mut parts = Vec::new(); + let mut current = String::new(); + let mut in_string = false; + let mut depth = 0i32; + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if in_string { + current.push(c); + if c == '\\' && i + 1 < chars.len() { + current.push(chars[i + 1]); + i += 2; + continue; + } + if c == '\'' { + in_string = false; + } + i += 1; + continue; + } + match c { + '\'' => { + in_string = true; + current.push(c); + } + '(' => { + depth += 1; + current.push(c); + } + ')' => { + depth -= 1; + current.push(c); + } + ',' if depth == 0 => { + parts.push(std::mem::take(&mut current)); + } + _ => current.push(c), + } + i += 1; + } + if !current.trim().is_empty() { + parts.push(current); + } + parts +} + +/// Finds the byte offset of the top-level ` => ` separator in an array entry, skipping string +/// literals and nested parentheses. +fn find_top_level_arrow(entry: &str) -> Option<usize> { + let bytes = entry.as_bytes(); + let mut in_string = false; + let mut depth = 0i32; + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if in_string { + if c == b'\\' { + i += 2; + continue; + } + if c == b'\'' { + in_string = false; + } + i += 1; + continue; + } + match c { + b'\'' => in_string = true, + b'(' => depth += 1, + b')' => depth -= 1, + b'=' if depth == 0 && i + 1 < bytes.len() && bytes[i + 1] == b'>' => { + return Some(i); + } + _ => {} + } + i += 1; + } + None +} + pub fn composer_require(_file_identifier: &str, _file: &str) { // TODO(phase-c): unportable — depends on the $GLOBALS superglobal // ($GLOBALS['__composer_autoload_files']) and PHP's `require $file` include diff --git a/crates/shirabe/src/package/handle.rs b/crates/shirabe/src/package/handle.rs index 9405692..7ef7e98 100644 --- a/crates/shirabe/src/package/handle.rs +++ b/crates/shirabe/src/package/handle.rs @@ -1205,6 +1205,15 @@ macro_rules! impl_real_package_test_setters { .set_extra(extra); } + /// For testing only: mirrors PHP `Package::setSuggests`. + pub fn __set_suggests(&self, suggests: indexmap::IndexMap<String, String>) { + self.0 + .borrow_mut() + .as_package_mut() + .expect("real package handle invariant") + .set_suggests(suggests); + } + /// For testing only: mirrors PHP `Package::setType`. pub fn __set_type(&self, r#type: String) { self.0 @@ -1240,6 +1249,39 @@ macro_rules! impl_real_package_test_setters { .expect("real package handle invariant") .set_target_dir(target_dir); } + + /// For testing only: mirrors PHP `Package::setAutoload`. + pub fn __set_autoload( + &self, + autoload: indexmap::IndexMap<String, shirabe_php_shim::PhpMixed>, + ) { + self.0 + .borrow_mut() + .as_package_mut() + .expect("real package handle invariant") + .set_autoload(autoload); + } + + /// For testing only: mirrors PHP `Package::setDevAutoload`. + pub fn __set_dev_autoload( + &self, + dev_autoload: indexmap::IndexMap<String, shirabe_php_shim::PhpMixed>, + ) { + self.0 + .borrow_mut() + .as_package_mut() + .expect("real package handle invariant") + .set_dev_autoload(dev_autoload); + } + + /// For testing only: mirrors PHP `Package::setIncludePaths`. + pub fn __set_include_paths(&self, include_paths: Vec<String>) { + self.0 + .borrow_mut() + .as_package_mut() + .expect("real package handle invariant") + .set_include_paths(include_paths); + } } }; } diff --git a/crates/shirabe/src/repository/vcs/git_driver.rs b/crates/shirabe/src/repository/vcs/git_driver.rs index 37f393c..cd08117 100644 --- a/crates/shirabe/src/repository/vcs/git_driver.rs +++ b/crates/shirabe/src/repository/vcs/git_driver.rs @@ -485,6 +485,12 @@ impl GitDriver { self.inner.write_cached_composer(identifier, composer) } + + /// For testing only. Mirrors GitDriverTest::setRepoDir, which uses reflection to + /// set the private `repoDir` property. + pub fn __set_repo_dir(&mut self, repo_dir: String) { + self.repo_dir = repo_dir; + } } impl crate::repository::vcs::VcsDriverInterface for GitDriver { diff --git a/crates/shirabe/src/util/git.rs b/crates/shirabe/src/util/git.rs index 142d5e2..4ea15b7 100644 --- a/crates/shirabe/src/util/git.rs +++ b/crates/shirabe/src/util/git.rs @@ -1125,7 +1125,7 @@ impl Git { } let result: Result<Option<String>> = (|| -> Result<Option<String>> { - let mut output_mixed = PhpMixed::String(String::new()); + let mut output_mixed = PhpMixed::Null; if is_local_path_repository { let mut output = String::new(); self.process.borrow_mut().execute_args( diff --git a/crates/shirabe/tests/autoload/autoload_generator_test.rs b/crates/shirabe/tests/autoload/autoload_generator_test.rs index 72ac999..a3b833a 100644 --- a/crates/shirabe/tests/autoload/autoload_generator_test.rs +++ b/crates/shirabe/tests/autoload/autoload_generator_test.rs @@ -1,267 +1,1806 @@ //! ref: composer/tests/Composer/Test/Autoload/AutoloadGeneratorTest.php -/// Creates the working/vendor temp directories, switches into the working dir, and -/// builds the AutoloadGenerator from a mocked Config/InstallationManager/ -/// InstalledRepository/EventDispatcher and a BufferIO. The mocks and temp-dir -/// helpers are not available here, so this remains a stub. -fn set_up() { - todo!() +use std::cell::RefCell; +use std::rc::Rc; + +use indexmap::IndexMap; +use serial_test::serial; +use tempfile::TempDir; + +use shirabe::autoload::AutoloadGenerator; +use shirabe::composer::{ComposerHandle, PartialOrFullComposer}; +use shirabe::config::Config; +use shirabe::event_dispatcher::EventDispatcher; +use shirabe::installer::{InstallationManager, InstallerInterface}; +use shirabe::io::{BufferIO, IOInterface}; +use shirabe::package::handle::{AliasPackageHandle, PackageHandle, RootPackageHandle}; +use shirabe::package::{Link, PackageInterfaceHandle, RootPackageInterfaceHandle}; +use shirabe::repository::{ + InstalledArrayRepository, InstalledRepositoryInterface, WritableRepositoryInterface, +}; +use shirabe::util::http_downloader::HttpDownloader; +use shirabe::util::r#loop::Loop; +use shirabe_external_packages::symfony::console::output::output_interface; +use shirabe_php_shim::PhpMixed; +use shirabe_semver::constraint::{AnyConstraint, MatchAllConstraint, SimpleConstraint}; + +use crate::config_stub::ConfigStubBuilder; + +/// The mock `InstallationManager::getInstallPath` used throughout the test: metapackages return +/// null, every other package returns `vendorDir/<name>(/<targetDir>)`. Registered as an installer +/// that supports every type so `InstallationManager::getInstallPath` routes to it. +#[derive(Debug)] +struct InstallPathStubInstaller { + vendor_dir: String, } -/// Restores the original working directory and removes the working/vendor -/// directories created by `set_up`, which is itself a stub. -fn tear_down() { - todo!() +#[async_trait::async_trait(?Send)] +impl InstallerInterface for InstallPathStubInstaller { + fn supports(&self, _package_type: &str) -> bool { + true + } + + fn is_installed( + &mut self, + _repo: &dyn InstalledRepositoryInterface, + _package: PackageInterfaceHandle, + ) -> bool { + true + } + + async fn download( + &mut self, + _package: PackageInterfaceHandle, + _prev_package: Option<PackageInterfaceHandle>, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + + async fn prepare( + &mut self, + _type: &str, + _package: PackageInterfaceHandle, + _prev_package: Option<PackageInterfaceHandle>, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + + async fn install( + &mut self, + _repo: &mut dyn InstalledRepositoryInterface, + _package: PackageInterfaceHandle, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + + async fn update( + &mut self, + _repo: &mut dyn InstalledRepositoryInterface, + _initial: PackageInterfaceHandle, + _target: PackageInterfaceHandle, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + + async fn uninstall( + &mut self, + _repo: &mut dyn InstalledRepositoryInterface, + _package: PackageInterfaceHandle, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + + async fn cleanup( + &mut self, + _type: &str, + _package: PackageInterfaceHandle, + _prev_package: Option<PackageInterfaceHandle>, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + + fn get_install_path(&mut self, package: PackageInterfaceHandle) -> Option<String> { + if package.get_type() == "metapackage" { + return None; + } + + let target_dir = package.get_target_dir(); + let suffix = match target_dir { + Some(dir) if !dir.is_empty() => format!("/{}", dir), + _ => String::new(), + }; + + Some(format!( + "{}/{}{}", + self.vendor_dir, + package.get_name(), + suffix + )) + } } -struct TearDown; +/// Mirrors the PHP `setUp`/`tearDown` lifecycle: a fresh temp working dir, a `composer-test-autoload` +/// vendor dir inside it, `chdir`ed into the working dir, plus the mocked Config/InstallationManager/ +/// repository/EventDispatcher and BufferIO. The temp tree is removed and the cwd restored on drop. +struct SetUp { + _temp_dir: TempDir, + prev_cwd: std::path::PathBuf, + working_dir: String, + vendor_dir: String, + repository: InstalledArrayRepository, + im: InstallationManager, + io: Rc<RefCell<BufferIO>>, + generator: AutoloadGenerator, + event_dispatcher: Rc<RefCell<EventDispatcher>>, +} -impl Drop for TearDown { +impl Drop for SetUp { fn drop(&mut self) { - tear_down(); + let _ = std::env::set_current_dir(&self.prev_cwd); + } +} + +fn null_path(s: &str) -> String { + s.to_string() +} + +fn set_up() -> SetUp { + let temp_dir = TempDir::new().unwrap(); + let working_dir = temp_dir.path().to_str().unwrap().to_string(); + let vendor_dir = format!("{}/composer-test-autoload", working_dir); + std::fs::create_dir_all(&vendor_dir).unwrap(); + + let prev_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&working_dir).unwrap(); + + let io = Rc::new(RefCell::new( + BufferIO::new(String::new(), output_interface::VERBOSITY_NORMAL, None).unwrap(), + )); + + // The PHP loop mock has its constructor disabled and is never exercised here, so a mock + // HttpDownloader (no real curl backend) stands in for the InstallationManager's loop. + let dispatcher_io: Rc<RefCell<dyn IOInterface>> = io.clone(); + let config_for_downloader = Rc::new(RefCell::new(Config::new(false, None))); + let http_downloader = Rc::new(RefCell::new(HttpDownloader::__new_mock( + dispatcher_io.clone(), + config_for_downloader, + ))); + let loop_ = Rc::new(RefCell::new(Loop::new(http_downloader, None))); + + let mut im = InstallationManager::new(loop_, dispatcher_io.clone(), None); + im.add_installer(Box::new(InstallPathStubInstaller { + vendor_dir: vendor_dir.clone(), + })); + + let repository = InstalledArrayRepository::new().unwrap(); + + // EventDispatcher constructor is disabled in PHP and dispatch is never called when run-scripts + // is off (the default), so a real dispatcher over an empty Composer is a faithful no-op stand-in. + let composer = + ComposerHandle::from_rc_unchecked(Rc::new(RefCell::new(PartialOrFullComposer::new_full()))); + let event_dispatcher = Rc::new(RefCell::new(EventDispatcher::new( + composer.upcast().downgrade(), + dispatcher_io.clone(), + None, + ))); + + let generator = AutoloadGenerator::new(event_dispatcher.clone(), Some(dispatcher_io)); + + SetUp { + _temp_dir: temp_dir, + prev_cwd, + working_dir, + vendor_dir, + repository, + im, + io, + generator, + event_dispatcher, + } +} + +impl SetUp { + /// Builds the mocked Config returning `vendor-dir`/`platform-check`/`use-include-path`, mirroring + /// the PHP `configValueMap`. Rebuilt per call because tests mutate `vendor_dir`. + fn config(&self) -> Config { + ConfigStubBuilder::new() + .with("vendor-dir", PhpMixed::String(self.vendor_dir.clone())) + .with("platform-check", PhpMixed::Bool(true)) + .with("use-include-path", PhpMixed::Bool(false)) + .build() + } + + fn ensure_dir(&self, path: &str) { + std::fs::create_dir_all(path).unwrap(); + } + + fn put(&self, path: &str, contents: &str) { + let p = std::path::Path::new(path); + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); + } + + fn set_canonical_packages(&mut self, packages: Vec<PackageInterfaceHandle>) { + for p in packages { + self.repository.add_package(p).unwrap(); + } } } -// These exercise AutoloadGenerator end-to-end: they build packages, write fixture files to -// a temp dir, run dump() through a mocked InstalledRepository/EventDispatcher and compare -// the generated autoload files. The integration setup (fixtures, mocks, filesystem) is not -// ported yet. +fn fixtures_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../composer/tests/Composer/Test/Autoload/Fixtures") + .canonicalize() + .unwrap() +} + +/// ref: AutoloadGeneratorTest::assertAutoloadFiles +#[track_caller] +fn assert_autoload_files(name: &str, dir: &str, r#type: &str) { + let a = fixtures_dir().join(format!("autoload_{}.php", name)); + let b = format!("{}/autoload_{}.php", dir, r#type); + assert_file_content_equals(a.to_str().unwrap(), &b); +} + +/// ref: AutoloadGeneratorTest::assertFileContentEquals +#[track_caller] +fn assert_file_content_equals(expected: &str, actual: &str) { + let exp = std::fs::read_to_string(expected) + .unwrap_or_else(|e| panic!("read {}: {}", expected, e)) + .replace('\r', ""); + let act = std::fs::read_to_string(actual) + .unwrap_or_else(|e| panic!("read {}: {}", actual, e)) + .replace('\r', ""); + assert_eq!(exp, act, "{} equals {}", expected, actual); +} + +fn match_all() -> AnyConstraint { + AnyConstraint::MatchAll(MatchAllConstraint::new(None)) +} + +fn constraint(operator: &str, version: &str) -> AnyConstraint { + AnyConstraint::Simple(SimpleConstraint::new( + operator.to_string(), + version.to_string(), + None, + )) +} + +fn link(source: &str, target: &str, constraint: AnyConstraint, description: Option<&str>) -> Link { + Link::new( + source.to_string(), + target.to_string(), + constraint, + description.map(|d| d.to_string()), + String::new(), + ) +} + +fn requires(links: Vec<(&str, Link)>) -> IndexMap<String, Link> { + links.into_iter().map(|(k, v)| (k.to_string(), v)).collect() +} + +fn autoload(entries: Vec<(&str, PhpMixed)>) -> IndexMap<String, PhpMixed> { + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect() +} + +fn str_list(items: &[&str]) -> PhpMixed { + PhpMixed::List( + items + .iter() + .map(|s| PhpMixed::String(s.to_string())) + .collect(), + ) +} + +fn str_map(entries: &[(&str, PhpMixed)]) -> PhpMixed { + PhpMixed::Array( + entries + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(), + ) +} + +fn pstr(v: &str) -> PhpMixed { + PhpMixed::String(v.to_string()) +} + +fn new_root_pkg(name: &str) -> RootPackageHandle { + RootPackageHandle::new(name.to_string(), null_path("1.0"), "1.0".to_string()) +} + +fn new_pkg(name: &str) -> PackageHandle { + PackageHandle::new(name.to_string(), "1.0".to_string(), "1.0".to_string()) +} + +fn dump( + s: &mut SetUp, + package: RootPackageInterfaceHandle, + scan_psr_packages: bool, + suffix: &str, +) -> anyhow::Result<shirabe_class_map_generator::class_map::ClassMap> { + let config = s.config(); + // Borrow splitting: take fields out so dump can hold &mut to several at once. + let SetUp { + repository, + im, + generator, + .. + } = s; + generator.dump( + &config, + repository, + package, + im, + "composer", + scan_psr_packages, + Some(suffix.to_string()), + None, + false, + ) +} + #[test] -#[ignore = "setUp needs mocked Config::get, InstallationManager::getInstallPath, InstalledRepositoryInterface::getCanonicalPackages and TestCase::getUniqueTmpDirectory/ensureDirectoryExistsAndClear; no mock infra exists"] +#[serial] fn test_root_package_autoloading() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[ + ("Main", pstr("src/")), + ("Lala", str_list(&["src/", "lib/"])), + ]), + ), + ( + "psr-4", + str_map(&[ + ("Acme\\Fruit\\", pstr("src-fruit/")), + ("Acme\\Cake\\", str_list(&["src-cake/", "lib-cake/"])), + ]), + ), + ("classmap", str_list(&["composersrc/"])), + ])); + + s.ensure_dir(&format!("{}/composer", s.working_dir)); + s.ensure_dir(&format!("{}/src/Lala/Test", s.working_dir)); + s.ensure_dir(&format!("{}/lib", s.working_dir)); + s.put( + &format!("{}/src/Lala/ClassMapMain.php", s.working_dir), + "<?php namespace Lala; class ClassMapMain {}", + ); + s.put( + &format!("{}/src/Lala/Test/ClassMapMainTest.php", s.working_dir), + "<?php namespace Lala\\Test; class ClassMapMainTest {}", + ); + + s.ensure_dir(&format!("{}/src-fruit", s.working_dir)); + s.ensure_dir(&format!("{}/src-cake", s.working_dir)); + s.ensure_dir(&format!("{}/lib-cake", s.working_dir)); + s.put( + &format!("{}/src-cake/ClassMapBar.php", s.working_dir), + "<?php namespace Acme\\Cake; class ClassMapBar {}", + ); + + s.ensure_dir(&format!("{}/composersrc", s.working_dir)); + s.put( + &format!("{}/composersrc/foo.php", s.working_dir), + "<?php class ClassMapFoo {}", + ); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), true, "_1").unwrap(); + + assert_autoload_files("main", &composer_out, "namespaces"); + assert_autoload_files("psr4", &composer_out, "psr4"); + assert_autoload_files("classmap", &composer_out, "classmap"); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_root_package_dev_autoloading() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![( + "psr-0", + str_map(&[("Main", pstr("src/"))]), + )])); + package.set_dev_autoload(autoload(vec![ + ("files", str_list(&["devfiles/foo.php"])), + ("psr-0", str_map(&[("Main", pstr("tests/"))])), + ])); + + s.ensure_dir(&format!("{}/composer", s.working_dir)); + s.ensure_dir(&format!("{}/src/Main", s.working_dir)); + s.put( + &format!("{}/src/Main/ClassMain.php", s.working_dir), + "<?php namespace Main; class ClassMain {}", + ); + s.ensure_dir(&format!("{}/devfiles", s.working_dir)); + s.put( + &format!("{}/devfiles/foo.php", s.working_dir), + "<?php function foo() { echo \"foo\"; }", + ); + + let composer_out = format!("{}/composer", s.vendor_dir); + s.generator.set_dev_mode(true); + dump(&mut s, package.into(), true, "_1").unwrap(); + + assert_autoload_files("main5", &composer_out, "namespaces"); + assert_autoload_files("classmap7", &composer_out, "classmap"); + assert_autoload_files("files2", &composer_out, "files"); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_root_package_dev_autoloading_disabled_by_default() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![( + "psr-0", + str_map(&[("Main", pstr("src/"))]), + )])); + package.set_dev_autoload(autoload(vec![("files", str_list(&["devfiles/foo.php"]))])); + + s.ensure_dir(&format!("{}/composer", s.working_dir)); + s.ensure_dir(&format!("{}/src/Main", s.working_dir)); + s.put( + &format!("{}/src/Main/ClassMain.php", s.working_dir), + "<?php namespace Main; class ClassMain {}", + ); + s.ensure_dir(&format!("{}/devfiles", s.working_dir)); + s.put( + &format!("{}/devfiles/foo.php", s.working_dir), + "<?php function foo() { echo \"foo\"; }", + ); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), true, "_1").unwrap(); + + assert_autoload_files("main4", &composer_out, "namespaces"); + assert_autoload_files("classmap7", &composer_out, "classmap"); + assert!(!std::path::Path::new(&format!("{}/autoload_files.php", composer_out)).is_file()); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_vendor_dir_same_as_working_dir() { - todo!() + let mut s = set_up(); + s.vendor_dir = s.working_dir.clone(); + // Re-register the install-path stub so getInstallPath uses the new vendor dir. + s.im.add_installer(Box::new(InstallPathStubInstaller { + vendor_dir: s.vendor_dir.clone(), + })); + + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[("Main", pstr("src/")), ("Lala", pstr("src/"))]), + ), + ( + "psr-4", + str_map(&[ + ("Acme\\Fruit\\", pstr("src-fruit/")), + ("Acme\\Cake\\", str_list(&["src-cake/", "lib-cake/"])), + ]), + ), + ("classmap", str_list(&["composersrc/"])), + ])); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/src/Main", s.vendor_dir)); + s.put( + &format!("{}/src/Main/Foo.php", s.vendor_dir), + "<?php namespace Main; class Foo {}", + ); + s.ensure_dir(&format!("{}/composersrc", s.vendor_dir)); + s.put( + &format!("{}/composersrc/foo.php", s.vendor_dir), + "<?php class ClassMapFoo {}", + ); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), true, "_2").unwrap(); + assert_autoload_files("main3", &composer_out, "namespaces"); + assert_autoload_files("psr4_3", &composer_out, "psr4"); + assert_autoload_files("classmap3", &composer_out, "classmap"); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_root_package_autoloading_alternative_vendor_dir() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[("Main", pstr("src/")), ("Lala", pstr("src/"))]), + ), + ( + "psr-4", + str_map(&[ + ("Acme\\Fruit\\", pstr("src-fruit/")), + ("Acme\\Cake\\", str_list(&["src-cake/", "lib-cake/"])), + ]), + ), + ("classmap", str_list(&["composersrc/"])), + ])); + + s.vendor_dir = format!("{}/subdir", s.vendor_dir); + s.im.add_installer(Box::new(InstallPathStubInstaller { + vendor_dir: s.vendor_dir.clone(), + })); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/src", s.working_dir)); + s.ensure_dir(&format!("{}/composersrc", s.working_dir)); + s.put( + &format!("{}/composersrc/foo.php", s.working_dir), + "<?php class ClassMapFoo {}", + ); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), false, "_3").unwrap(); + assert_autoload_files("main2", &composer_out, "namespaces"); + assert_autoload_files("psr4_2", &composer_out, "psr4"); + assert_autoload_files("classmap2", &composer_out, "classmap"); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] +#[ignore = "autoload_real.php/autoload_static.php fixtures track a newer Composer template (single blank lines + $filesToLoad/$requireFile block) than the current AutoloadGenerator port emits; needs production template alignment"] fn test_root_package_autoloading_with_target_dir() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[("Main\\Foo", pstr("")), ("Main\\Bar", pstr(""))]), + ), + ("classmap", str_list(&["Main/Foo/src", "lib"])), + ("files", str_list(&["foo.php", "Main/Foo/bar.php"])), + ])); + package.__set_target_dir(Some("Main/Foo/".to_string())); + + s.ensure_dir(&format!("{}/a", s.vendor_dir)); + s.ensure_dir(&format!("{}/src", s.working_dir)); + s.ensure_dir(&format!("{}/lib", s.working_dir)); + s.put( + &format!("{}/src/rootfoo.php", s.working_dir), + "<?php class ClassMapFoo {}", + ); + s.put( + &format!("{}/lib/rootbar.php", s.working_dir), + "<?php class ClassMapBar {}", + ); + s.put( + &format!("{}/foo.php", s.working_dir), + "<?php class FilesFoo {}", + ); + s.put( + &format!("{}/bar.php", s.working_dir), + "<?php class FilesBar {}", + ); + + let vendor = s.vendor_dir.clone(); + let composer_out = format!("{}/composer", vendor); + dump(&mut s, package.into(), false, "TargetDir").unwrap(); + + let fx = fixtures_dir(); + assert_file_content_equals( + fx.join("autoload_target_dir.php").to_str().unwrap(), + &format!("{}/autoload.php", vendor), + ); + assert_file_content_equals( + fx.join("autoload_real_target_dir.php").to_str().unwrap(), + &format!("{}/autoload_real.php", composer_out), + ); + assert_file_content_equals( + fx.join("autoload_static_target_dir.php").to_str().unwrap(), + &format!("{}/autoload_static.php", composer_out), + ); + assert_file_content_equals( + fx.join("autoload_files_target_dir.php").to_str().unwrap(), + &format!("{}/autoload_files.php", composer_out), + ); + assert_autoload_files("classmap6", &composer_out, "classmap"); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_duplicate_files_warning() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![( + "files", + str_list(&["foo.php", "bar.php", "./foo.php", "././foo.php"]), + )])); + + s.ensure_dir(&format!("{}/a", s.vendor_dir)); + s.ensure_dir(&format!("{}/src", s.working_dir)); + s.ensure_dir(&format!("{}/lib", s.working_dir)); + s.put( + &format!("{}/foo.php", s.working_dir), + "<?php class FilesFoo {}", + ); + s.put( + &format!("{}/bar.php", s.working_dir), + "<?php class FilesBar {}", + ); + + let vendor = s.vendor_dir.clone(); + let composer_out = format!("{}/composer", vendor); + dump(&mut s, package.into(), false, "FilesWarning").unwrap(); + + assert_file_content_equals( + fixtures_dir() + .join("autoload_files_duplicates.php") + .to_str() + .unwrap(), + &format!("{}/autoload_files.php", composer_out), + ); + let expected = "<warning>The following \"files\" autoload rules are included multiple times, this may cause issues and should be resolved:</warning>\n<warning> - $baseDir . '/foo.php'</warning>\n"; + assert_eq!(expected, s.io.borrow().get_output()); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_vendors_autoloading() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![ + ("a/a", link("a", "a/a", match_all(), None)), + ("b/b", link("a", "b/b", match_all(), None)), + ])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + let c = AliasPackageHandle::new(b.clone(), "1.2".to_string(), "1.2".to_string()); + a.__set_autoload(autoload(vec![( + "psr-0", + str_map(&[("A", pstr("src/")), ("A\\B", pstr("lib/"))]), + )])); + b.__set_autoload(autoload(vec![( + "psr-0", + str_map(&[("B\\Sub\\Name", pstr("src/"))]), + )])); + + s.set_canonical_packages(vec![a.into(), b.into(), c.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/lib", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b/src", s.vendor_dir)); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), false, "_5").unwrap(); + assert_autoload_files("vendors", &composer_out, "namespaces"); + assert!(std::path::Path::new(&format!("{}/autoload_classmap.php", composer_out)).exists()); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_vendors_autoloading_with_metapackages() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![("a/a", link("a", "a/a", match_all(), None))])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + let c = AliasPackageHandle::new(b.clone(), "1.2".to_string(), "1.2".to_string()); + a.__set_autoload(autoload(vec![( + "psr-0", + str_map(&[("A", pstr("src/")), ("A\\B", pstr("lib/"))]), + )])); + b.__set_autoload(autoload(vec![( + "psr-0", + str_map(&[("B\\Sub\\Name", pstr("src/"))]), + )])); + a.__set_type("metapackage".to_string()); + a.__set_requires(requires(vec![( + "b/b", + link("a/a", "b/b", match_all(), None), + )])); + + s.set_canonical_packages(vec![a.into(), b.into(), c.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/lib", s.vendor_dir)); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), false, "_5").unwrap(); + assert_autoload_files("vendors_meta", &composer_out, "namespaces"); + assert!(std::path::Path::new(&format!("{}/autoload_classmap.php", composer_out)).exists()); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_non_dev_autoload_exclusion_with_recursion() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![("a/a", link("a", "a/a", match_all(), None))])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + a.__set_autoload(autoload(vec![( + "psr-0", + str_map(&[("A", pstr("src/")), ("A\\B", pstr("lib/"))]), + )])); + a.__set_requires(requires(vec![( + "b/b", + link("a/a", "b/b", match_all(), None), + )])); + b.__set_autoload(autoload(vec![( + "psr-0", + str_map(&[("B\\Sub\\Name", pstr("src/"))]), + )])); + b.__set_requires(requires(vec![( + "a/a", + link("b/b", "a/a", match_all(), None), + )])); + + s.set_canonical_packages(vec![a.into(), b.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/lib", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b/src", s.vendor_dir)); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), false, "_5").unwrap(); + assert_autoload_files("vendors", &composer_out, "namespaces"); + assert!(std::path::Path::new(&format!("{}/autoload_classmap.php", composer_out)).exists()); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_non_dev_autoload_should_include_replaced_packages() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![("a/a", link("a", "a/a", match_all(), None))])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + a.__set_requires(requires(vec![( + "b/c", + link("a/a", "b/c", match_all(), None), + )])); + b.__set_autoload(autoload(vec![("psr-4", str_map(&[("B\\", pstr("src/"))]))])); + b.__set_replaces(requires(vec![( + "b/c", + link( + "b/b", + "b/c", + constraint("==", "1.0"), + Some(Link::TYPE_REPLACE), + ), + )])); + + s.set_canonical_packages(vec![a.into(), b.into()]); + + s.ensure_dir(&format!("{}/b/b/src/C", s.vendor_dir)); + s.put( + &format!("{}/b/b/src/C/C.php", s.vendor_dir), + "<?php namespace B\\C; class C {}", + ); + + let vendor = s.vendor_dir.clone(); + let class_map = dump(&mut s, package.into(), true, "_5").unwrap(); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert("B\\C\\C".to_string(), format!("{}/b/b/src/C/C.php", vendor)); + expected.insert( + "Composer\\InstalledVersions".to_string(), + format!("{}/composer/InstalledVersions.php", vendor), + ); + assert_eq!(&expected, class_map.get_map()); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_non_dev_autoload_exclusion_with_recursion_replace() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![("a/a", link("a", "a/a", match_all(), None))])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + a.__set_autoload(autoload(vec![( + "psr-0", + str_map(&[("A", pstr("src/")), ("A\\B", pstr("lib/"))]), + )])); + a.__set_requires(requires(vec![( + "c/c", + link("a/a", "c/c", match_all(), None), + )])); + b.__set_autoload(autoload(vec![( + "psr-0", + str_map(&[("B\\Sub\\Name", pstr("src/"))]), + )])); + b.__set_replaces(requires(vec![( + "c/c", + link("b/b", "c/c", match_all(), None), + )])); + + s.set_canonical_packages(vec![a.into(), b.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/lib", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b/src", s.vendor_dir)); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), false, "_5").unwrap(); + assert_autoload_files("vendors", &composer_out, "namespaces"); + assert!(std::path::Path::new(&format!("{}/autoload_classmap.php", composer_out)).exists()); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_non_dev_autoload_replaces_nested_requirements() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![("a/a", link("a", "a/a", match_all(), None))])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + let c = new_pkg("c/c"); + let d = new_pkg("d/d"); + let e = new_pkg("e/e"); + a.__set_autoload(autoload(vec![("classmap", str_list(&["src/A.php"]))])); + a.__set_requires(requires(vec![( + "b/b", + link("a/a", "b/b", match_all(), None), + )])); + b.__set_autoload(autoload(vec![("classmap", str_list(&["src/B.php"]))])); + b.__set_requires(requires(vec![( + "e/e", + link("b/b", "e/e", match_all(), None), + )])); + c.__set_autoload(autoload(vec![("classmap", str_list(&["src/C.php"]))])); + c.__set_replaces(requires(vec![( + "b/b", + link("c/c", "b/b", match_all(), None), + )])); + c.__set_requires(requires(vec![( + "d/d", + link("c/c", "d/d", match_all(), None), + )])); + d.__set_autoload(autoload(vec![("classmap", str_list(&["src/D.php"]))])); + e.__set_autoload(autoload(vec![("classmap", str_list(&["src/E.php"]))])); + + s.set_canonical_packages(vec![a.into(), b.into(), c.into(), d.into(), e.into()]); + + for (name, file, class) in [ + ("a/a", "A", "A"), + ("b/b", "B", "B"), + ("c/c", "C", "C"), + ("d/d", "D", "D"), + ("e/e", "E", "E"), + ] { + s.ensure_dir(&format!("{}/{}/src", s.vendor_dir, name)); + s.put( + &format!("{}/{}/src/{}.php", s.vendor_dir, name, file), + &format!("<?php class {} {{}}", class), + ); + } + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), false, "_5").unwrap(); + assert_autoload_files("classmap9", &composer_out, "classmap"); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] +#[ignore = "autoload_static.php getInitializer() fixture has a trailing blank line the current AutoloadGenerator template omits; needs production template alignment"] fn test_phar_autoload() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![("a/a", link("a", "a/a", match_all(), None))])); + package.set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[("Foo", pstr("foo.phar")), ("Bar", pstr("dir/bar.phar/src"))]), + ), + ( + "psr-4", + str_map(&[ + ("Baz\\", pstr("baz.phar")), + ("Qux\\", pstr("dir/qux.phar/src")), + ]), + ), + ])); + + let vendor_package = new_pkg("a/a"); + vendor_package.__set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[ + ("Lorem", pstr("lorem.phar")), + ("Ipsum", pstr("dir/ipsum.phar/src")), + ]), + ), + ( + "psr-4", + str_map(&[ + ("Dolor\\", pstr("dolor.phar")), + ("Sit\\", pstr("dir/sit.phar/src")), + ]), + ), + ])); + + s.set_canonical_packages(vec![vendor_package.into()]); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), true, "Phar").unwrap(); + assert_autoload_files("phar", &composer_out, "namespaces"); + assert_autoload_files("phar_psr4", &composer_out, "psr4"); + assert_autoload_files("phar_static", &composer_out, "static"); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_psr_to_class_map_ignores_non_existing_dir() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[("Prefix", pstr("foo/bar/non/existing/"))]), + ), + ( + "psr-4", + str_map(&[("Prefix\\", pstr("foo/bar/non/existing2/"))]), + ), + ])); + + let vendor = s.vendor_dir.clone(); + let composer_out = format!("{}/composer", vendor); + let class_map = dump(&mut s, package.into(), true, "_8").unwrap(); + assert!(std::path::Path::new(&format!("{}/autoload_classmap.php", composer_out)).exists()); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert( + "Composer\\InstalledVersions".to_string(), + format!("{}/composer/InstalledVersions.php", vendor), + ); + assert_eq!(&expected, class_map.get_map()); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_psr_to_class_map_ignores_non_psr_classes() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![ + ("psr-0", str_map(&[("psr0_", pstr("psr0/"))])), + ("psr-4", str_map(&[("psr4\\", pstr("psr4/"))])), + ])); + + s.ensure_dir(&format!("{}/psr0/psr0", s.working_dir)); + s.ensure_dir(&format!("{}/psr4", s.working_dir)); + s.put( + &format!("{}/psr0/psr0/match.php", s.working_dir), + "<?php class psr0_match {}", + ); + s.put( + &format!("{}/psr0/psr0/badfile.php", s.working_dir), + "<?php class psr0_badclass {}", + ); + s.put( + &format!("{}/psr4/match.php", s.working_dir), + "<?php namespace psr4; class match {}", + ); + s.put( + &format!("{}/psr4/badfile.php", s.working_dir), + "<?php namespace psr4; class badclass {}", + ); + + let vendor = s.vendor_dir.clone(); + let composer_out = format!("{}/composer", vendor); + dump(&mut s, package.into(), true, "_1").unwrap(); + assert!(std::path::Path::new(&format!("{}/autoload_classmap.php", composer_out)).exists()); + + let expected = format!( + "<?php\n\n// autoload_classmap.php @generated by Composer\n\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\n\nreturn array(\n 'Composer\\\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',\n 'psr0_match' => $baseDir . '/psr0/psr0/match.php',\n 'psr4\\\\match' => $baseDir . '/psr4/match.php',\n);\n" + ); + let actual = + std::fs::read_to_string(format!("{}/autoload_classmap.php", composer_out)).unwrap(); + assert_eq!(expected, actual); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_vendors_class_map_autoloading() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![ + ("a/a", link("a", "a/a", match_all(), None)), + ("b/b", link("a", "b/b", match_all(), None)), + ])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + a.__set_autoload(autoload(vec![("classmap", str_list(&["src/"]))])); + b.__set_autoload(autoload(vec![("classmap", str_list(&["src/", "lib/"]))])); + + s.set_canonical_packages(vec![a.into(), b.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b/lib", s.vendor_dir)); + s.put( + &format!("{}/a/a/src/a.php", s.vendor_dir), + "<?php class ClassMapFoo {}", + ); + s.put( + &format!("{}/b/b/src/b.php", s.vendor_dir), + "<?php class ClassMapBar {}", + ); + s.put( + &format!("{}/b/b/lib/c.php", s.vendor_dir), + "<?php class ClassMapBaz {}", + ); + + let vendor = s.vendor_dir.clone(); + let composer_out = format!("{}/composer", vendor); + let class_map = dump(&mut s, package.into(), false, "_6").unwrap(); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert( + "ClassMapBar".to_string(), + format!("{}/b/b/src/b.php", vendor), + ); + expected.insert( + "ClassMapBaz".to_string(), + format!("{}/b/b/lib/c.php", vendor), + ); + expected.insert( + "ClassMapFoo".to_string(), + format!("{}/a/a/src/a.php", vendor), + ); + expected.insert( + "Composer\\InstalledVersions".to_string(), + format!("{}/composer/InstalledVersions.php", vendor), + ); + assert_eq!(&expected, class_map.get_map()); + assert_autoload_files("classmap4", &composer_out, "classmap"); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_vendors_class_map_autoloading_with_target_dir() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![ + ("a/a", link("a", "a/a", match_all(), None)), + ("b/b", link("a", "b/b", match_all(), None)), + ])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + a.__set_autoload(autoload(vec![( + "classmap", + str_list(&["target/src/", "lib/"]), + )])); + a.__set_target_dir(Some("target".to_string())); + b.__set_autoload(autoload(vec![("classmap", str_list(&["src/"]))])); + + s.set_canonical_packages(vec![a.into(), b.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/target/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/target/lib", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b/src", s.vendor_dir)); + s.put( + &format!("{}/a/a/target/src/a.php", s.vendor_dir), + "<?php class ClassMapFoo {}", + ); + s.put( + &format!("{}/a/a/target/lib/b.php", s.vendor_dir), + "<?php class ClassMapBar {}", + ); + s.put( + &format!("{}/b/b/src/c.php", s.vendor_dir), + "<?php class ClassMapBaz {}", + ); + + let vendor = s.vendor_dir.clone(); + let composer_out = format!("{}/composer", vendor); + let class_map = dump(&mut s, package.into(), false, "_6").unwrap(); + assert!(std::path::Path::new(&format!("{}/autoload_classmap.php", composer_out)).exists()); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert( + "ClassMapBar".to_string(), + format!("{}/a/a/target/lib/b.php", vendor), + ); + expected.insert( + "ClassMapBaz".to_string(), + format!("{}/b/b/src/c.php", vendor), + ); + expected.insert( + "ClassMapFoo".to_string(), + format!("{}/a/a/target/src/a.php", vendor), + ); + expected.insert( + "Composer\\InstalledVersions".to_string(), + format!("{}/composer/InstalledVersions.php", vendor), + ); + assert_eq!(&expected, class_map.get_map()); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] +#[ignore = "the `classmap => ['./']` rule yields a scanned path containing `/./` (e.g. c/c/./foo/test.php) that the ClassMapGenerator port does not collapse the way PHP's normalizePath does; needs production path-normalization fix"] fn test_class_map_autoloading_empty_dir_and_exact_file() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![ + ("a/a", link("a", "a/a", match_all(), None)), + ("b/b", link("a", "b/b", match_all(), None)), + ("c/c", link("a", "c/c", match_all(), None)), + ])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + let c = new_pkg("c/c"); + a.__set_autoload(autoload(vec![("classmap", str_list(&[""]))])); + b.__set_autoload(autoload(vec![("classmap", str_list(&["test.php"]))])); + c.__set_autoload(autoload(vec![("classmap", str_list(&["./"]))])); + + s.set_canonical_packages(vec![a.into(), b.into(), c.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b", s.vendor_dir)); + s.ensure_dir(&format!("{}/c/c/foo", s.vendor_dir)); + s.put( + &format!("{}/a/a/src/a.php", s.vendor_dir), + "<?php class ClassMapFoo {}", + ); + s.put( + &format!("{}/b/b/test.php", s.vendor_dir), + "<?php class ClassMapBar {}", + ); + s.put( + &format!("{}/c/c/foo/test.php", s.vendor_dir), + "<?php class ClassMapBaz {}", + ); + + let vendor = s.vendor_dir.clone(); + let composer_out = format!("{}/composer", vendor); + let class_map = dump(&mut s, package.into(), false, "_7").unwrap(); + assert!(std::path::Path::new(&format!("{}/autoload_classmap.php", composer_out)).exists()); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert( + "ClassMapBar".to_string(), + format!("{}/b/b/test.php", vendor), + ); + expected.insert( + "ClassMapBaz".to_string(), + format!("{}/c/c/foo/test.php", vendor), + ); + expected.insert( + "ClassMapFoo".to_string(), + format!("{}/a/a/src/a.php", vendor), + ); + expected.insert( + "Composer\\InstalledVersions".to_string(), + format!("{}/composer/InstalledVersions.php", vendor), + ); + assert_eq!(&expected, class_map.get_map()); + assert_autoload_files("classmap5", &composer_out, "classmap"); + + let real = std::fs::read_to_string(format!("{}/autoload_real.php", composer_out)).unwrap(); + assert!(!real.contains("$loader->setClassMapAuthoritative(true);")); + assert!(!real.contains("$loader->setApcuPrefix(")); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_class_map_autoloading_authoritative_and_apcu() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![ + ("a/a", link("a", "a/a", match_all(), None)), + ("b/b", link("a", "b/b", match_all(), None)), + ("c/c", link("a", "c/c", match_all(), None)), + ])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + let c = new_pkg("c/c"); + a.__set_autoload(autoload(vec![("psr-4", str_map(&[("", pstr("src/"))]))])); + b.__set_autoload(autoload(vec![("psr-4", str_map(&[("", pstr("./"))]))])); + c.__set_autoload(autoload(vec![("psr-4", str_map(&[("", pstr("foo/"))]))])); + + s.set_canonical_packages(vec![a.into(), b.into(), c.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b", s.vendor_dir)); + s.ensure_dir(&format!("{}/c/c/foo", s.vendor_dir)); + s.put( + &format!("{}/a/a/src/ClassMapFoo.php", s.vendor_dir), + "<?php class ClassMapFoo {}", + ); + s.put( + &format!("{}/b/b/ClassMapBar.php", s.vendor_dir), + "<?php class ClassMapBar {}", + ); + s.put( + &format!("{}/c/c/foo/ClassMapBaz.php", s.vendor_dir), + "<?php class ClassMapBaz {}", + ); + + s.generator.set_class_map_authoritative(true); + s.generator.set_apcu(true, None); + let vendor = s.vendor_dir.clone(); + let composer_out = format!("{}/composer", vendor); + let class_map = dump(&mut s, package.into(), false, "_7").unwrap(); + assert!(std::path::Path::new(&format!("{}/autoload_classmap.php", composer_out)).exists()); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert( + "ClassMapBar".to_string(), + format!("{}/b/b/ClassMapBar.php", vendor), + ); + expected.insert( + "ClassMapBaz".to_string(), + format!("{}/c/c/foo/ClassMapBaz.php", vendor), + ); + expected.insert( + "ClassMapFoo".to_string(), + format!("{}/a/a/src/ClassMapFoo.php", vendor), + ); + expected.insert( + "Composer\\InstalledVersions".to_string(), + format!("{}/composer/InstalledVersions.php", vendor), + ); + assert_eq!(&expected, class_map.get_map()); + assert_autoload_files("classmap8", &composer_out, "classmap"); + + let real = std::fs::read_to_string(format!("{}/autoload_real.php", composer_out)).unwrap(); + assert!(real.contains("$loader->setClassMapAuthoritative(true);")); + assert!(real.contains("$loader->setApcuPrefix(")); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] fn test_class_map_autoloading_authoritative_and_apcu_prefix() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_requires(requires(vec![ + ("a/a", link("a", "a/a", match_all(), None)), + ("b/b", link("a", "b/b", match_all(), None)), + ("c/c", link("a", "c/c", match_all(), None)), + ])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + let c = new_pkg("c/c"); + a.__set_autoload(autoload(vec![("psr-4", str_map(&[("", pstr("src/"))]))])); + b.__set_autoload(autoload(vec![("psr-4", str_map(&[("", pstr("./"))]))])); + c.__set_autoload(autoload(vec![("psr-4", str_map(&[("", pstr("foo/"))]))])); + + s.set_canonical_packages(vec![a.into(), b.into(), c.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b", s.vendor_dir)); + s.ensure_dir(&format!("{}/c/c/foo", s.vendor_dir)); + s.put( + &format!("{}/a/a/src/ClassMapFoo.php", s.vendor_dir), + "<?php class ClassMapFoo {}", + ); + s.put( + &format!("{}/b/b/ClassMapBar.php", s.vendor_dir), + "<?php class ClassMapBar {}", + ); + s.put( + &format!("{}/c/c/foo/ClassMapBaz.php", s.vendor_dir), + "<?php class ClassMapBaz {}", + ); + + s.generator.set_class_map_authoritative(true); + s.generator + .set_apcu(true, Some("custom'Prefix".to_string())); + let vendor = s.vendor_dir.clone(); + let composer_out = format!("{}/composer", vendor); + let class_map = dump(&mut s, package.into(), false, "_7").unwrap(); + assert!(std::path::Path::new(&format!("{}/autoload_classmap.php", composer_out)).exists()); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert( + "ClassMapBar".to_string(), + format!("{}/b/b/ClassMapBar.php", vendor), + ); + expected.insert( + "ClassMapBaz".to_string(), + format!("{}/c/c/foo/ClassMapBaz.php", vendor), + ); + expected.insert( + "ClassMapFoo".to_string(), + format!("{}/a/a/src/ClassMapFoo.php", vendor), + ); + expected.insert( + "Composer\\InstalledVersions".to_string(), + format!("{}/composer/InstalledVersions.php", vendor), + ); + assert_eq!(&expected, class_map.get_map()); + assert_autoload_files("classmap8", &composer_out, "classmap"); + + let real = std::fs::read_to_string(format!("{}/autoload_real.php", composer_out)).unwrap(); + assert!(real.contains("$loader->setClassMapAuthoritative(true);")); + assert!(real.contains("$loader->setApcuPrefix('custom\\'Prefix');")); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[serial] +#[ignore = "autoload_real.php/autoload_static.php fixtures track a newer Composer template (single blank lines + $filesToLoad/$requireFile block) than the current AutoloadGenerator port emits; needs production template alignment"] fn test_files_autoload_generation() { - todo!() + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![("files", str_list(&["root.php"]))])); + package.set_requires(requires(vec![ + ("a/a", link("a", "a/a", match_all(), None)), + ("b/b", link("a", "b/b", match_all(), None)), + ("c/c", link("a", "c/c", match_all(), None)), + ])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + let c = new_pkg("c/c"); + a.__set_autoload(autoload(vec![("files", str_list(&["test.php"]))])); + b.__set_autoload(autoload(vec![("files", str_list(&["test2.php"]))])); + c.__set_autoload(autoload(vec![( + "files", + str_list(&["test3.php", "foo/bar/test4.php"]), + )])); + c.__set_target_dir(Some("foo/bar".to_string())); + + s.set_canonical_packages(vec![a.into(), b.into(), c.into()]); + + s.ensure_dir(&format!("{}/a/a", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b", s.vendor_dir)); + s.ensure_dir(&format!("{}/c/c/foo/bar", s.vendor_dir)); + s.put( + &format!("{}/a/a/test.php", s.vendor_dir), + "<?php function testFilesAutoloadGeneration1() {}", + ); + s.put( + &format!("{}/b/b/test2.php", s.vendor_dir), + "<?php function testFilesAutoloadGeneration2() {}", + ); + s.put( + &format!("{}/c/c/foo/bar/test3.php", s.vendor_dir), + "<?php function testFilesAutoloadGeneration3() {}", + ); + s.put( + &format!("{}/c/c/foo/bar/test4.php", s.vendor_dir), + "<?php function testFilesAutoloadGeneration4() {}", + ); + s.put( + &format!("{}/root.php", s.working_dir), + "<?php function testFilesAutoloadGenerationRoot() {}", + ); + + let vendor = s.vendor_dir.clone(); + let composer_out = format!("{}/composer", vendor); + dump(&mut s, package.into(), false, "FilesAutoload").unwrap(); + let fx = fixtures_dir(); + assert_file_content_equals( + fx.join("autoload_functions.php").to_str().unwrap(), + &format!("{}/autoload.php", vendor), + ); + assert_file_content_equals( + fx.join("autoload_real_functions.php").to_str().unwrap(), + &format!("{}/autoload_real.php", composer_out), + ); + assert_file_content_equals( + fx.join("autoload_static_functions.php").to_str().unwrap(), + &format!("{}/autoload_static.php", composer_out), + ); + assert_file_content_equals( + fx.join("autoload_files_functions.php").to_str().unwrap(), + &format!("{}/autoload_files.php", composer_out), + ); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_files_autoload_generation_remove_extra_entities_from_autoload_files() { - todo!() +#[serial] +fn test_override_vendors_autoloading() { + let mut s = set_up(); + let working_dir = s.working_dir.clone(); + let root_package = new_root_pkg("root/z"); + root_package.set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[("A\\B", pstr(&format!("{}/lib", working_dir)))]), + ), + ("classmap", str_list(&[&format!("{}/src", working_dir)])), + ])); + root_package.set_requires(requires(vec![ + ("a/a", link("z", "a/a", match_all(), None)), + ("b/b", link("z", "b/b", match_all(), None)), + ])); + + let a = new_pkg("a/a"); + let b = new_pkg("b/b"); + a.__set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[("A", pstr("src/")), ("A\\B", pstr("lib/"))]), + ), + ("classmap", str_list(&["classmap"])), + ])); + b.__set_autoload(autoload(vec![( + "psr-0", + str_map(&[("B\\Sub\\Name", pstr("src/"))]), + )])); + + s.set_canonical_packages(vec![a.into(), b.into()]); + + s.ensure_dir(&format!("{}/lib/A/B", s.working_dir)); + s.ensure_dir(&format!("{}/src/", s.working_dir)); + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/classmap", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/src", s.vendor_dir)); + s.ensure_dir(&format!("{}/a/a/lib/A/B", s.vendor_dir)); + s.ensure_dir(&format!("{}/b/b/src", s.vendor_dir)); + + s.put( + &format!("{}/lib/A/B/C.php", s.working_dir), + "<?php namespace A\\B; class C {}", + ); + s.put( + &format!("{}/src/classes.php", s.working_dir), + "<?php namespace Foo; class Bar {}", + ); + s.put( + &format!("{}/a/a/lib/A/B/C.php", s.vendor_dir), + "<?php namespace A\\B; class C {}", + ); + s.put( + &format!("{}/a/a/classmap/classes.php", s.vendor_dir), + "<?php namespace Foo; class Bar {}", + ); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, root_package.into(), true, "_9").unwrap(); + + let expected_namespace = "<?php\n\n// autoload_namespaces.php @generated by Composer\n\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\n\nreturn array(\n 'B\\\\Sub\\\\Name' => array($vendorDir . '/b/b/src'),\n 'A\\\\B' => array($baseDir . '/lib', $vendorDir . '/a/a/lib'),\n 'A' => array($vendorDir . '/a/a/src'),\n);\n"; + let expected_psr4 = "<?php\n\n// autoload_psr4.php @generated by Composer\n\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\n\nreturn array(\n);\n"; + let expected_classmap = "<?php\n\n// autoload_classmap.php @generated by Composer\n\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\n\nreturn array(\n 'A\\\\B\\\\C' => $baseDir . '/lib/A/B/C.php',\n 'Composer\\\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',\n 'Foo\\\\Bar' => $baseDir . '/src/classes.php',\n);\n"; + + assert_eq!( + expected_namespace, + std::fs::read_to_string(format!("{}/autoload_namespaces.php", composer_out)).unwrap() + ); + assert_eq!( + expected_psr4, + std::fs::read_to_string(format!("{}/autoload_psr4.php", composer_out)).unwrap() + ); + assert_eq!( + expected_classmap, + std::fs::read_to_string(format!("{}/autoload_classmap.php", composer_out)).unwrap() + ); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_files_autoload_order_by_dependencies() { - todo!() +#[serial] +fn test_include_path_file_generation() { + let mut s = set_up(); + let package = new_root_pkg("root/a"); + + let a = new_pkg("a/a"); + a.__set_include_paths(vec!["lib/".to_string()]); + let b = new_pkg("b/b"); + b.__set_include_paths(vec!["library".to_string()]); + let c = new_pkg("c"); + c.__set_include_paths(vec!["library".to_string()]); + + s.set_canonical_packages(vec![a.into(), b.into(), c.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), false, "_10").unwrap(); + + assert_file_content_equals( + fixtures_dir().join("include_paths.php").to_str().unwrap(), + &format!("{}/include_paths.php", composer_out), + ); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_override_vendors_autoloading() { - todo!() +#[serial] +fn test_include_path_file_without_paths_is_skipped() { + let mut s = set_up(); + let package = new_root_pkg("root/a"); + let a = new_pkg("a/a"); + s.set_canonical_packages(vec![a.into()]); + + s.ensure_dir(&format!("{}/composer", s.vendor_dir)); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), false, "_12").unwrap(); + + assert!(!std::path::Path::new(&format!("{}/include_paths.php", composer_out)).exists()); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_include_path_file_generation() { - todo!() +#[serial] +fn test_vendor_substring_path() { + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[("Foo", pstr("composer-test-autoload-src/src"))]), + ), + ( + "psr-4", + str_map(&[("Acme\\Foo\\", pstr("composer-test-autoload-src/src-psr4"))]), + ), + ])); + + s.ensure_dir(&format!("{}/a", s.vendor_dir)); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), false, "VendorSubstring").unwrap(); + + let expected_namespace = "<?php\n\n// autoload_namespaces.php @generated by Composer\n\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\n\nreturn array(\n 'Foo' => array($baseDir . '/composer-test-autoload-src/src'),\n);\n"; + let expected_psr4 = "<?php\n\n// autoload_psr4.php @generated by Composer\n\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\n\nreturn array(\n 'Acme\\\\Foo\\\\' => array($baseDir . '/composer-test-autoload-src/src-psr4'),\n);\n"; + + assert_eq!( + expected_namespace, + std::fs::read_to_string(format!("{}/autoload_namespaces.php", composer_out)).unwrap() + ); + assert_eq!( + expected_psr4, + std::fs::read_to_string(format!("{}/autoload_psr4.php", composer_out)).unwrap() + ); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_include_paths_are_prepended_in_autoload_file() { - todo!() +#[serial] +fn test_empty_paths() { + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![ + ("psr-0", str_map(&[("Foo", pstr(""))])), + ("psr-4", str_map(&[("Acme\\Foo\\", pstr(""))])), + ("classmap", str_list(&[""])), + ])); + + s.ensure_dir(&format!("{}/Foo", s.working_dir)); + s.put( + &format!("{}/Foo/Bar.php", s.working_dir), + "<?php namespace Foo; class Bar {}", + ); + s.put( + &format!("{}/class.php", s.working_dir), + "<?php namespace Classmap; class Foo {}", + ); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), true, "_15").unwrap(); + + let expected_namespace = "<?php\n\n// autoload_namespaces.php @generated by Composer\n\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\n\nreturn array(\n 'Foo' => array($baseDir . '/'),\n);\n"; + let expected_psr4 = "<?php\n\n// autoload_psr4.php @generated by Composer\n\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\n\nreturn array(\n 'Acme\\\\Foo\\\\' => array($baseDir . '/'),\n);\n"; + let expected_classmap = "<?php\n\n// autoload_classmap.php @generated by Composer\n\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\n\nreturn array(\n 'Classmap\\\\Foo' => $baseDir . '/class.php',\n 'Composer\\\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',\n 'Foo\\\\Bar' => $baseDir . '/Foo/Bar.php',\n);\n"; + + assert_eq!( + expected_namespace, + std::fs::read_to_string(format!("{}/autoload_namespaces.php", composer_out)).unwrap() + ); + assert_eq!( + expected_psr4, + std::fs::read_to_string(format!("{}/autoload_psr4.php", composer_out)).unwrap() + ); + assert_eq!( + expected_classmap, + std::fs::read_to_string(format!("{}/autoload_classmap.php", composer_out)).unwrap() + ); } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_include_paths_in_root_package() { - todo!() +#[serial] +#[ignore = "fixture assumes the symlinked composersrc/foo/bar tree (created via `ln -s` in PHP) and exercises exclude-from-classmap pattern matching that the port does not yet apply; symlink setup not replicated"] +fn test_exclude_from_classmap() { + let mut s = set_up(); + let package = new_root_pkg("root/a"); + package.set_autoload(autoload(vec![ + ( + "psr-0", + str_map(&[ + ("Main", pstr("src/")), + ("Lala", str_list(&["src/", "lib/"])), + ]), + ), + ( + "psr-4", + str_map(&[ + ("Acme\\Fruit\\", pstr("src-fruit/")), + ("Acme\\Cake\\", str_list(&["src-cake/", "lib-cake/"])), + ]), + ), + ("classmap", str_list(&["composersrc/"])), + ( + "exclude-from-classmap", + str_list(&[ + "/composersrc/foo/bar/", + "/composersrc/excludedTests/", + "/composersrc/ClassToExclude.php", + "/composersrc/*/excluded/excsubpath", + "**/excsubpath", + "composers", + "/src-ca/", + ]), + ), + ])); + + s.ensure_dir(&format!("{}/composer", s.working_dir)); + s.ensure_dir(&format!("{}/src/Lala/Test", s.working_dir)); + s.ensure_dir(&format!("{}/lib", s.working_dir)); + s.put( + &format!("{}/src/Lala/ClassMapMain.php", s.working_dir), + "<?php namespace Lala; class ClassMapMain {}", + ); + s.put( + &format!("{}/src/Lala/Test/ClassMapMainTest.php", s.working_dir), + "<?php namespace Lala\\Test; class ClassMapMainTest {}", + ); + + s.ensure_dir(&format!("{}/src-fruit", s.working_dir)); + s.ensure_dir(&format!("{}/src-cake", s.working_dir)); + s.ensure_dir(&format!("{}/lib-cake", s.working_dir)); + s.put( + &format!("{}/src-cake/ClassMapBar.php", s.working_dir), + "<?php namespace Acme\\Cake; class ClassMapBar {}", + ); + + s.ensure_dir(&format!("{}/composersrc", s.working_dir)); + s.ensure_dir(&format!("{}/composersrc/tests", s.working_dir)); + s.put( + &format!("{}/composersrc/foo.php", s.working_dir), + "<?php class ClassMapFoo {}", + ); + + s.ensure_dir(&format!("{}/composersrc/excludedTests", s.working_dir)); + s.put( + &format!("{}/composersrc/excludedTests/bar.php", s.working_dir), + "<?php class ClassExcludeMapFoo {}", + ); + s.put( + &format!("{}/composersrc/ClassToExclude.php", s.working_dir), + "<?php class ClassClassToExclude {}", + ); + s.ensure_dir(&format!( + "{}/composersrc/long/excluded/excsubpath", + s.working_dir + )); + s.put( + &format!( + "{}/composersrc/long/excluded/excsubpath/foo.php", + s.working_dir + ), + "<?php class ClassExcludeMapFoo2 {}", + ); + s.put( + &format!( + "{}/composersrc/long/excluded/excsubpath/bar.php", + s.working_dir + ), + "<?php class ClassExcludeMapBar {}", + ); + + s.ensure_dir(&format!("{}/composersrc/foo", s.working_dir)); + + let composer_out = format!("{}/composer", s.vendor_dir); + dump(&mut s, package.into(), true, "_1").unwrap(); + + assert_autoload_files("classmap", &composer_out, "classmap"); } +// These remain ignored: they need test infrastructure not yet ported. +// +// - testFilesAutoloadOrderByDependencies / testFilesAutoloadGeneration's `require autoload.php` +// + function_exists assertions: PHP runtime require is unportable (composer_require todo!()). +// - testFilesAutoloadGenerationRemoveExtraEntitiesFromAutoloadFiles: needs getCanonicalPackages +// returnValueMap over consecutive calls (the repo mock yields different package sets per call). +// - testIncludePathsArePrependedInAutoloadFile / testIncludePathsInRootPackage / +// testUseGlobalIncludePath: assert PHP's get_include_path() after `require autoload.php`. +// - testPreAndPostEventsAreDispatchedDuringAutoloadDump: EventDispatcher::dispatchScript spy. +// - testVendorDirExcludedFromWorkingDir / testUpLevelRelativePaths: chdir into a nested working dir +// with a custom getInstallPath using a different vendor dir. +// - testAutoloadRulesInPackageThatDoesNotExistOnDisk: exercises buildPackageMap/parseAutoloads +// directly plus a CompletePackage; multi-dump with mutation. +// - testGeneratesPlatformCheck: data-provider over many platform-requirement scenarios. +// - testAbsoluteSymlinkWith*: create real filesystem symlinks. + #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_include_path_file_without_paths_is_skipped() { +#[ignore = "require autoload.php + function_exists() assertions are unportable (composer_require todo!())"] +fn test_files_autoload_order_by_dependencies() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface plus EventDispatcher::dispatch expectations; no mock infra exists"] -fn test_pre_and_post_events_are_dispatched_during_autoload_dump() { +#[ignore = "needs getCanonicalPackages consecutive-call return values (different package set per dump)"] +fn test_files_autoload_generation_remove_extra_entities_from_autoload_files() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_use_global_include_path() { +#[ignore = "asserts PHP get_include_path() after require autoload.php"] +fn test_include_paths_are_prepended_in_autoload_file() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_vendor_dir_excluded_from_working_dir() { +#[ignore = "asserts PHP get_include_path() after require autoload.php"] +fn test_include_paths_in_root_package() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_up_level_relative_paths() { +#[ignore = "EventDispatcher::dispatchScript spy not modeled"] +fn test_pre_and_post_events_are_dispatched_during_autoload_dump() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_autoload_rules_in_package_that_does_not_exist_on_disk() { +#[ignore = "asserts PHP get_include_path()/require behavior with use-include-path"] +fn test_use_global_include_path() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_empty_paths() { +#[ignore = "needs nested working dir + custom getInstallPath vendor dir"] +fn test_vendor_dir_excluded_from_working_dir() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_vendor_substring_path() { +#[ignore = "needs nested working dir chdir + up-level relative path fixtures"] +fn test_up_level_relative_paths() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] -fn test_exclude_from_classmap() { +#[ignore = "exercises buildPackageMap/parseAutoloads directly with multi-dump mutation"] +fn test_autoload_rules_in_package_that_does_not_exist_on_disk() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[ignore = "data-provider over platform-requirement scenarios"] fn test_generates_platform_check() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[ignore = "creates real filesystem symlinks"] fn test_absolute_symlink_with_psr4_does_not_generate_warnings() { todo!() } #[test] -#[ignore = "setUp needs mocked Config/InstallationManager/InstalledRepositoryInterface and TestCase tmp-dir helpers; no mock infra exists"] +#[ignore = "creates real filesystem symlinks"] fn test_absolute_symlink_with_classmap_exclude_from_classmap() { todo!() } diff --git a/crates/shirabe/tests/autoload/main.rs b/crates/shirabe/tests/autoload/main.rs index ebfa8e3..cc67e9a 100644 --- a/crates/shirabe/tests/autoload/main.rs +++ b/crates/shirabe/tests/autoload/main.rs @@ -1,2 +1,5 @@ +#[path = "../common/config_stub.rs"] +mod config_stub; + mod autoload_generator_test; mod class_loader_test; diff --git a/crates/shirabe/tests/command/bump_command_test.rs b/crates/shirabe/tests/command/bump_command_test.rs index 4e9a49c..8866990 100644 --- a/crates/shirabe/tests/command/bump_command_test.rs +++ b/crates/shirabe/tests/command/bump_command_test.rs @@ -1,19 +1,79 @@ //! ref: composer/tests/Composer/Test/Command/BumpCommandTest.php -#[ignore = "missing TestCase::init_temp_composer, create_installed_json, create_composer_lock, and get_application_tester (ApplicationTester) infrastructure"] +use crate::test_case::{RunOptions, get_application_tester, init_temp_composer}; +use serial_test::serial; +use shirabe_php_shim::PhpMixed; + +#[ignore = "missing TestCase::create_installed_json / create_composer_lock infrastructure and full bump flow (require_composer reaches the network)"] #[test] fn test_bump() { todo!() } -#[ignore = "missing TestCase::init_temp_composer and get_application_tester (ApplicationTester) infrastructure"] #[test] +#[serial] fn test_bump_fails_on_non_existing_composer_file() { - todo!() + let tear_down = init_temp_composer(Some(&serde_json::json!({})), None, None, false); + let composer_json_path = tear_down.working_dir().join("composer.json"); + std::fs::remove_file(&composer_json_path).unwrap(); + + let mut app_tester = get_application_tester(); + let status_code = app_tester + .run( + vec![(PhpMixed::from("command"), PhpMixed::from("bump"))], + RunOptions { + capture_stderr_separately: true, + ..RunOptions::default() + }, + ) + .unwrap(); + + assert_eq!(1, status_code); + let error_output = app_tester.get_error_output(); + assert!( + error_output.contains("./composer.json is not readable."), + "expected error output to mention composer.json not readable, got: {:?}", + error_output, + ); + + drop(tear_down); } -#[ignore = "missing TestCase::init_temp_composer and get_application_tester (ApplicationTester) infrastructure"] #[test] +#[serial] +#[ignore = "BumpCommand::initialize constructs a full Composer (Factory::create_http_downloader \ + -> CurlDownloader::new -> curl_multi_init), and the curl subsystem in shirabe-php-shim \ + is still todo!(). Only reachable once the HTTP/curl layer is ported. The companion \ + test_bump_fails_on_non_existing_composer_file covers the same error-output capture path \ + without reaching curl (the missing composer.json makes Composer construction a no-op)."] fn test_bump_fails_on_write_error_to_composer_file() { - todo!() + if shirabe_php_shim::function_exists("posix_getuid") && shirabe_php_shim::posix_getuid() == 0 { + // ref: $this->markTestSkipped('Cannot run as root'); + return; + } + + let tear_down = init_temp_composer(Some(&serde_json::json!({})), None, None, false); + let composer_json_path = tear_down.working_dir().join("composer.json"); + shirabe_php_shim::chmod(&composer_json_path.to_string_lossy(), 0o444); + + let mut app_tester = get_application_tester(); + let status_code = app_tester + .run( + vec![(PhpMixed::from("command"), PhpMixed::from("bump"))], + RunOptions { + capture_stderr_separately: true, + ..RunOptions::default() + }, + ) + .unwrap(); + + assert_eq!(1, status_code); + let error_output = app_tester.get_error_output(); + assert!( + error_output.contains("./composer.json is not writable."), + "expected error output to mention composer.json not writable, got: {:?}", + error_output, + ); + + drop(tear_down); } diff --git a/crates/shirabe/tests/installer/main.rs b/crates/shirabe/tests/installer/main.rs index 931e70b..a5ca671 100644 --- a/crates/shirabe/tests/installer/main.rs +++ b/crates/shirabe/tests/installer/main.rs @@ -1,5 +1,7 @@ #[path = "../common/downloader_stub.rs"] mod downloader_stub; +#[path = "../common/io_mock.rs"] +mod io_mock; #[path = "../common/test_case.rs"] mod test_case; diff --git a/crates/shirabe/tests/installer/suggested_packages_reporter_test.rs b/crates/shirabe/tests/installer/suggested_packages_reporter_test.rs index b0654fe..fa3c8fb 100644 --- a/crates/shirabe/tests/installer/suggested_packages_reporter_test.rs +++ b/crates/shirabe/tests/installer/suggested_packages_reporter_test.rs @@ -6,12 +6,22 @@ use std::rc::Rc; use indexmap::IndexMap; use shirabe::installer::SuggestedPackagesReporter; use shirabe::io::IOInterface; +use shirabe::io::io_interface; use shirabe::io::null_io::NullIO; +use shirabe::repository::{InstalledRepository, LockArrayRepository, RepositoryInterfaceHandle}; -/// Builds an IO mock and a SuggestedPackagesReporter over it. The IO mock -/// (`getIOMock`) is not available here, so this remains a stub. -fn set_up() { - todo!() +use crate::io_mock::{Expectation, IOMock, IOMockGuard, get_io_mock}; +use crate::test_case::get_package; + +/// ref: SuggestedPackagesReporterTest::setUp. +/// +/// Builds an IO mock and a SuggestedPackagesReporter sharing it. The IOMockGuard runs +/// assert_complete when it drops at the end of the test scope. +fn set_up() -> (Rc<RefCell<IOMock>>, SuggestedPackagesReporter, IOMockGuard) { + let (mock, guard) = get_io_mock(io_interface::NORMAL).unwrap(); + let io: Rc<RefCell<dyn IOInterface>> = mock.clone(); + let reporter = SuggestedPackagesReporter::new(io); + (mock, reporter, guard) } /// ref: SuggestedPackagesReporterTest::getSuggestedPackageArray @@ -28,12 +38,17 @@ fn reporter() -> SuggestedPackagesReporter { SuggestedPackagesReporter::new(io) } -// These construct a SuggestedPackagesReporter with a mocked IO and assert its accumulated -// suggestions and formatted output; mocking is not available here. -#[ignore = "asserts IO mock output via getIOMock()->expects(); no IO mocking infrastructure exists and BufferIO::new/get_output are todo!()"] #[test] fn test_constructor() { - todo!() + let (mock, mut reporter, _guard) = set_up(); + mock.borrow_mut() + .expects(vec![Expectation::text("b")], true) + .unwrap(); + + reporter.add_package("a".to_string(), "b".to_string(), "c".to_string()); + reporter + .output(SuggestedPackagesReporter::MODE_LIST, None, None) + .unwrap(); } #[test] @@ -77,44 +92,185 @@ fn test_add_package_appends() { ); } -#[ignore = "addSuggestionsFromPackage test mocks Package::getSuggests; set_suggests only exists on RootPackageHandle, so the non-root Package fixture with suggests cannot be expressed"] #[test] fn test_add_suggestions_from_package() { - todo!() + let mut reporter = reporter(); + + // PHP mocks getSuggests/getPrettyName; here a real package carries the suggests and name. + let package = get_package("package-pretty-name", "1.0.0"); + let mut suggests = IndexMap::new(); + suggests.insert("target-a".to_string(), "reason-a".to_string()); + suggests.insert("target-b".to_string(), "reason-b".to_string()); + package.__set_suggests(suggests); + + reporter.add_suggestions_from_package(package); + + let mut expected_a = IndexMap::new(); + expected_a.insert("source".to_string(), "package-pretty-name".to_string()); + expected_a.insert("target".to_string(), "target-a".to_string()); + expected_a.insert("reason".to_string(), "reason-a".to_string()); + let mut expected_b = IndexMap::new(); + expected_b.insert("source".to_string(), "package-pretty-name".to_string()); + expected_b.insert("target".to_string(), "target-b".to_string()); + expected_b.insert("reason".to_string(), "reason-b".to_string()); + assert_eq!(&vec![expected_a, expected_b], reporter.get_packages()); } -#[ignore = "asserts IO mock output via getIOMock()->expects(); no IO mocking infrastructure exists and BufferIO::new/get_output are todo!()"] #[test] fn test_output() { - todo!() + let (mock, mut reporter, _guard) = set_up(); + reporter.add_package("a".to_string(), "b".to_string(), "c".to_string()); + + mock.borrow_mut() + .expects( + vec![ + Expectation::text("a suggests:"), + Expectation::text(" - b: c"), + Expectation::text(""), + ], + true, + ) + .unwrap(); + + reporter + .output(SuggestedPackagesReporter::MODE_BY_PACKAGE, None, None) + .unwrap(); } -#[ignore = "asserts IO mock output via getIOMock()->expects(); no IO mocking infrastructure exists and BufferIO::new/get_output are todo!()"] #[test] fn test_output_with_no_suggestion_reason() { - todo!() + let (mock, mut reporter, _guard) = set_up(); + reporter.add_package("a".to_string(), "b".to_string(), "".to_string()); + + mock.borrow_mut() + .expects( + vec![ + Expectation::text("a suggests:"), + Expectation::text(" - b"), + Expectation::text(""), + ], + true, + ) + .unwrap(); + + reporter + .output(SuggestedPackagesReporter::MODE_BY_PACKAGE, None, None) + .unwrap(); } -#[ignore = "asserts IO mock output via getIOMock()->expects(); no IO mocking infrastructure exists and BufferIO::new/get_output are todo!()"] #[test] fn test_output_ignores_formatting() { - todo!() + let (mock, mut reporter, _guard) = set_up(); + reporter.add_package( + "source".to_string(), + "target1".to_string(), + "\x1b[1;37;42m Like us\r\non Facebook \x1b[0m".to_string(), + ); + reporter.add_package( + "source".to_string(), + "target2".to_string(), + "<bg=green>Like us on Facebook</>".to_string(), + ); + + mock.borrow_mut() + .expects( + vec![ + Expectation::text("source suggests:"), + Expectation::text(" - target1: [1;37;42m Like us on Facebook [0m"), + Expectation::text(" - target2: <bg=green>Like us on Facebook</>"), + Expectation::text(""), + ], + true, + ) + .unwrap(); + + reporter + .output(SuggestedPackagesReporter::MODE_BY_PACKAGE, None, None) + .unwrap(); } -#[ignore = "asserts IO mock output via getIOMock()->expects(); no IO mocking infrastructure exists and BufferIO::new/get_output are todo!()"] #[test] fn test_output_multiple_packages() { - todo!() + let (mock, mut reporter, _guard) = set_up(); + reporter.add_package("a".to_string(), "b".to_string(), "c".to_string()); + reporter.add_package( + "source package".to_string(), + "target".to_string(), + "because reasons".to_string(), + ); + + mock.borrow_mut() + .expects( + vec![ + Expectation::text("a suggests:"), + Expectation::text(" - b: c"), + Expectation::text(""), + Expectation::text("source package suggests:"), + Expectation::text(" - target: because reasons"), + Expectation::text(""), + ], + true, + ) + .unwrap(); + + reporter + .output(SuggestedPackagesReporter::MODE_BY_PACKAGE, None, None) + .unwrap(); } -#[ignore = "asserts IO mock output via getIOMock()->expects() and uses getMockBuilder mocks of InstalledRepository/PackageInterface; no mocking infrastructure exists"] #[test] fn test_output_skip_installed_packages() { - todo!() + let (mock, mut reporter, _guard) = set_up(); + + // PHP mocks two PackageInterfaces returning getNames() ['x','y'] and ['b']; only the 'b' + // match is consequential (it filters the 'a' -> 'b' suggestion). Real packages carry a + // single name, so the immaterial 'y' name is omitted. + let package1 = get_package("x", "1.0.0"); + let package2 = get_package("b", "1.0.0"); + let installed = LockArrayRepository::new(vec![package1, package2]).unwrap(); + let mut repository = InstalledRepository::new(vec![RepositoryInterfaceHandle::new(installed)]); + + reporter.add_package("a".to_string(), "b".to_string(), "c".to_string()); + reporter.add_package( + "source package".to_string(), + "target".to_string(), + "because reasons".to_string(), + ); + + mock.borrow_mut() + .expects( + vec![ + Expectation::text("source package suggests:"), + Expectation::text(" - target: because reasons"), + Expectation::text(""), + ], + true, + ) + .unwrap(); + + reporter + .output( + SuggestedPackagesReporter::MODE_BY_PACKAGE, + Some(&mut repository), + None, + ) + .unwrap(); } -#[ignore = "uses getMockBuilder mock of InstalledRepository with ->expects($this->exactly(0)) call-count assertion; no mocking infrastructure exists"] #[test] fn test_output_not_getting_installed_packages_when_no_suggestions() { - todo!() + let (_mock, reporter, _guard) = set_up(); + + // PHP asserts getPackages() is called exactly 0 times. With no suggestions queued, + // get_filtered_suggestions short-circuits before touching the repository. + let installed = LockArrayRepository::new(vec![]).unwrap(); + let mut repository = InstalledRepository::new(vec![RepositoryInterfaceHandle::new(installed)]); + + reporter + .output( + SuggestedPackagesReporter::MODE_BY_PACKAGE, + Some(&mut repository), + None, + ) + .unwrap(); } diff --git a/crates/shirabe/tests/package/archiver/archive_manager_test.rs b/crates/shirabe/tests/package/archiver/archive_manager_test.rs index fdc4bf0..df83f56 100644 --- a/crates/shirabe/tests/package/archiver/archive_manager_test.rs +++ b/crates/shirabe/tests/package/archiver/archive_manager_test.rs @@ -1,59 +1,137 @@ //! ref: composer/tests/Composer/Test/Package/Archiver/ArchiveManagerTest.php -/// Builds an ArchiveManager via Factory/DownloadManager/Loop and derives targetDir under a -/// unique tmp dir (testDir); none of that factory/fixture infrastructure is ported. -/// Returns (test_dir, target_dir). -#[allow(dead_code)] -fn set_up() -> (String, String) { - todo!() -} +use std::cell::RefCell; +use std::rc::Rc; -#[allow(dead_code)] -fn tear_down(_test_dir: &str) { - // Removes testDir created in set_up. - todo!() -} +use indexmap::IndexMap; +use shirabe::config::Config; +use shirabe::downloader::DownloadManager; +use shirabe::io::IOInterface; +use shirabe::io::null_io::NullIO; +use shirabe::package::CompletePackageInterfaceHandle; +use shirabe::package::archiver::{ArchiveManager, PharArchiver, ZipArchiver}; +use shirabe::package::handle::CompletePackageHandle; +use shirabe::util::http_downloader::HttpDownloader; +use shirabe::util::r#loop::Loop; +use shirabe_php_shim::realpath; +use tempfile::TempDir; -#[allow(dead_code)] -struct TearDown { +// ref: ArchiverTestCase::setUp + ArchiveManagerTest::setUp. +// +// The PHP test builds the ArchiveManager via Factory/FactoryMock; here we construct it directly +// with the same archivers (ZipArchiver + PharArchiver) that Factory::createArchiveManager adds. +struct TestCase { + manager: ArchiveManager, test_dir: String, + target_dir: String, + _test_dir_guard: TempDir, } -impl Drop for TearDown { - fn drop(&mut self) { - tear_down(&self.test_dir); +impl TestCase { + fn set_up() -> Self { + let guard = TempDir::new().unwrap(); + let test_dir = guard.path().to_string_lossy().to_string(); + + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new())); + let config = Rc::new(RefCell::new(Config::new(false, None))); + // The filename/unknown-format tests never drive the download path, so a mock + // HttpDownloader (no curl backend) is sufficient to satisfy Loop's dependency. + let http_downloader = Rc::new(RefCell::new(HttpDownloader::__new_mock(io.clone(), config))); + let dm = Rc::new(RefCell::new(DownloadManager::new(io.clone(), false, None))); + let r#loop = Rc::new(RefCell::new(Loop::new(http_downloader, None))); + + let mut manager = ArchiveManager::new(dm, r#loop); + manager.add_archiver(Box::new(ZipArchiver::new())); + manager.add_archiver(Box::new(PharArchiver::new())); + + let target_dir = format!("{}/composer_archiver_tests", test_dir); + + Self { + manager, + test_dir, + target_dir, + _test_dir_guard: guard, + } + } + + // ref: ArchiverTestCase::setupPackage. + fn setup_package(&self) -> CompletePackageInterfaceHandle { + let package = CompletePackageHandle::new( + "archivertest/archivertest".to_string(), + "master".to_string(), + "master".to_string(), + ); + package.set_source_url(Some(realpath(&self.test_dir).unwrap_or_default())); + package.set_source_reference(Some("master".to_string())); + package.__set_source_type(Some("git".to_string())); + + package.into() } } -// These drive ArchiveManager end-to-end (building tar archives via PharData, todo!()) and -// the filename-derivation helpers over packages; the archiving and fixture setup are not -// ported. #[test] -#[ignore = "needs the Factory/FactoryMock-built ArchiveManager (createDownloadManager/createHttpDownloader/FactoryMock::createConfig) in set_up, which is unported"] fn test_unknown_format() { - todo!() + let mut test_case = TestCase::set_up(); + let package = test_case.setup_package(); + + let result = test_case.manager.archive( + package, + "__unknown_format__".to_string(), + test_case.target_dir.clone(), + None, + false, + ); + + let err = result.expect_err("expected RuntimeException for unknown format"); + assert!( + err.downcast_ref::<shirabe_php_shim::RuntimeException>() + .is_some() + ); } +// ref: ArchiveManagerTest::testArchiveTar / testArchiveCustomFileName. +// +// These drive ArchiveManager::archive end-to-end for the 'tar' format, which dispatches to +// PharArchiver::archive. That builds the archive via PharData, whose build_from_iterator is +// todo!() in the php-shim, so the archiving path cannot run yet. #[test] -#[ignore = "needs setupGitRepo + PharData tar archiving and the Factory/FactoryMock-built ArchiveManager (set_up), which is unported"] +#[ignore = "needs PharData tar archiving (build_from_iterator is todo!() in the php-shim) for ArchiveManager::archive('tar', ...)"] fn test_archive_tar() { todo!() } #[test] -#[ignore = "needs setupGitRepo + PharData tar archiving and the Factory/FactoryMock-built ArchiveManager (set_up), which is unported"] +#[ignore = "needs PharData tar archiving (build_from_iterator is todo!() in the php-shim) for ArchiveManager::archive('tar', ...)"] fn test_archive_custom_file_name() { todo!() } #[test] -#[ignore = "needs the Factory/FactoryMock-built ArchiveManager (createDownloadManager/createHttpDownloader/FactoryMock::createConfig) in set_up, which is unported"] fn test_get_package_filename_parts() { - todo!() + let test_case = TestCase::set_up(); + let package = test_case.setup_package(); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert("base".to_string(), "archivertest-archivertest".to_string()); + expected.insert("version".to_string(), "master".to_string()); + expected.insert("source_reference".to_string(), "4f26ae".to_string()); + + assert_eq!( + expected, + test_case + .manager + .get_package_filename_parts(package) + .unwrap() + ); } #[test] -#[ignore = "needs the Factory/FactoryMock-built ArchiveManager (createDownloadManager/createHttpDownloader/FactoryMock::createConfig) in set_up, which is unported"] fn test_get_package_filename() { - todo!() + let test_case = TestCase::set_up(); + let package = test_case.setup_package(); + + assert_eq!( + "archivertest-archivertest-master-4f26ae", + test_case.manager.get_package_filename(package).unwrap() + ); } diff --git a/crates/shirabe/tests/repository/vcs/git_driver_test.rs b/crates/shirabe/tests/repository/vcs/git_driver_test.rs index 56c2755..7c30b21 100644 --- a/crates/shirabe/tests/repository/vcs/git_driver_test.rs +++ b/crates/shirabe/tests/repository/vcs/git_driver_test.rs @@ -1,16 +1,24 @@ //! ref: composer/tests/Composer/Test/Repository/Vcs/GitDriverTest.php -// Every case constructs a GitDriver with a mocked ProcessExecutor (and an HttpDownloader -// that reaches curl_multi_init, todo!()) to feed git command output; mocking is not -// available here. +use std::cell::RefCell; +use std::rc::Rc; use indexmap::IndexMap; +use serial_test::serial; use shirabe::config::Config; +use shirabe::io::IOInterface; +use shirabe::repository::vcs::GitDriver; use shirabe::util::filesystem::Filesystem; +use shirabe::util::http_downloader::HttpDownloaderMockHandler; use shirabe::util::platform::Platform; -use shirabe_php_shim::PhpMixed; +use shirabe::util::process_executor::MockHandler; +use shirabe_php_shim::{PhpMixed, RuntimeException}; use tempfile::TempDir; +use crate::http_downloader_mock::{HttpDownloaderMockGuard, get_http_downloader_mock}; +use crate::io_stub::IOStub; +use crate::process_executor_mock::{ProcessExecutorMockGuard, cmd_full, get_process_executor_mock}; + struct SetUp { home: TempDir, config: Config, @@ -64,73 +72,255 @@ impl Drop for TearDown { } #[test] -#[ignore = "requires ProcessExecutor mock (getProcessExecutorMock/expects) and Reflection setRepoDir, neither available"] +#[serial] fn test_get_root_identifier_from_remote_local_repository() { let SetUp { home, - config: _, + config, network_env, } = set_up(); + let home_path = home.path().to_string_lossy().into_owned(); let _tear_down = TearDown::new(home.path().to_path_buf(), network_env); - todo!() + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(IOStub::new())); + + let (http_downloader, _http_guard): (_, HttpDownloaderMockGuard) = + get_http_downloader_mock(vec![], false, HttpDownloaderMockHandler::default()); + + let stdout = "* main\n 2.2\n 1.10"; + + let (process, _process_guard): (_, ProcessExecutorMockGuard) = get_process_executor_mock( + vec![cmd_full(["git", "branch", "--no-color"], 0, stdout, "")], + true, + MockHandler::default(), + ); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(home_path.clone())); + + let mut driver = GitDriver::new(repo_config, io, config, http_downloader, process); + driver.__set_repo_dir(home_path); + + assert_eq!("main", driver.get_root_identifier().unwrap()); } #[test] -#[ignore = "requires ProcessExecutor mock (getProcessExecutorMock/expects), IO mock and Reflection setRepoDir, none available"] +#[serial] fn test_get_root_identifier_from_remote() { let SetUp { home, - config: _, + config, network_env, } = set_up(); + let home_path = home.path().to_string_lossy().into_owned(); let _tear_down = TearDown::new(home.path().to_path_buf(), network_env); - todo!() + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(IOStub::new())); + + let (http_downloader, _http_guard): (_, HttpDownloaderMockGuard) = + get_http_downloader_mock(vec![], false, HttpDownloaderMockHandler::default()); + + let stdout = "* remote origin\n Fetch URL: https://example.org/acme.git\n Push URL: https://example.org/acme.git\n HEAD branch: main\n Remote branches:\n 1.10 tracked\n 2.2 tracked\n main tracked"; + + let (process, _process_guard): (_, ProcessExecutorMockGuard) = get_process_executor_mock( + vec![ + cmd_full(["git", "remote", "-v"], 0, "", ""), + cmd_full( + [ + "git", + "remote", + "set-url", + "origin", + "--", + "https://example.org/acme.git", + ], + 0, + "", + "", + ), + cmd_full(["git", "remote", "show", "origin"], 0, stdout, ""), + cmd_full( + [ + "git", + "remote", + "set-url", + "origin", + "--", + "https://example.org/acme.git", + ], + 0, + "", + "", + ), + ], + false, + MockHandler::default(), + ); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert( + "url".to_string(), + PhpMixed::String("https://example.org/acme.git".to_string()), + ); + + let mut driver = GitDriver::new(repo_config, io, config, http_downloader, process); + driver.__set_repo_dir(home_path); + + assert_eq!("main", driver.get_root_identifier().unwrap()); } #[test] -#[ignore = "requires ProcessExecutor mock (getProcessExecutorMock/expects) and Reflection setRepoDir, neither available"] +#[serial] fn test_get_root_identifier_from_local_with_network_disabled() { let SetUp { home, - config: _, + config, network_env, } = set_up(); + let home_path = home.path().to_string_lossy().into_owned(); let _tear_down = TearDown::new(home.path().to_path_buf(), network_env); - todo!() + + Platform::put_env("COMPOSER_DISABLE_NETWORK", "1"); + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(IOStub::new())); + + let (http_downloader, _http_guard): (_, HttpDownloaderMockGuard) = + get_http_downloader_mock(vec![], false, HttpDownloaderMockHandler::default()); + + let stdout = "* main\n 2.2\n 1.10"; + + let (process, _process_guard): (_, ProcessExecutorMockGuard) = get_process_executor_mock( + vec![cmd_full(["git", "branch", "--no-color"], 0, stdout, "")], + false, + MockHandler::default(), + ); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert( + "url".to_string(), + PhpMixed::String("https://example.org/acme.git".to_string()), + ); + + let mut driver = GitDriver::new(repo_config, io, config, http_downloader, process); + driver.__set_repo_dir(home_path); + + assert_eq!("main", driver.get_root_identifier().unwrap()); } #[test] -#[ignore = "requires ProcessExecutor mock (getProcessExecutorMock/expects), IOInterface mock and Reflection setRepoDir, none available"] fn test_get_branches_filter_invalid_branch_names() { let SetUp { home, - config: _, + config, network_env, } = set_up(); + let home_path = home.path().to_string_lossy().into_owned(); let _tear_down = TearDown::new(home.path().to_path_buf(), network_env); - todo!() + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(IOStub::new())); + + let (http_downloader, _http_guard): (_, HttpDownloaderMockGuard) = + get_http_downloader_mock(vec![], false, HttpDownloaderMockHandler::default()); + + // Branches starting with a - character are not valid git branch names. + // Still assert that they get filtered to prevent issues later on. + let stdout = "* main 089681446ba44d6d9004350192486f2ceb4eaa06 commit\n 2.2 12681446ba44d6d9004350192486f2ceb4eaa06 commit\n -h 089681446ba44d6d9004350192486f2ceb4eaa06 commit"; + + let (process, _process_guard): (_, ProcessExecutorMockGuard) = get_process_executor_mock( + vec![cmd_full( + ["git", "branch", "--no-color", "--no-abbrev", "-v"], + 0, + stdout, + "", + )], + false, + MockHandler::default(), + ); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert( + "url".to_string(), + PhpMixed::String("https://example.org/acme.git".to_string()), + ); + + let mut driver = GitDriver::new(repo_config, io, config, http_downloader, process); + driver.__set_repo_dir(home_path); + + let branches = driver.get_branches().unwrap(); + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert( + "main".to_string(), + "089681446ba44d6d9004350192486f2ceb4eaa06".to_string(), + ); + expected.insert( + "2.2".to_string(), + "12681446ba44d6d9004350192486f2ceb4eaa06".to_string(), + ); + assert_eq!(expected, branches); } #[test] -#[ignore = "requires ProcessExecutor mock (getProcessExecutorMock) and IOInterface/HttpDownloader mocks, none available"] fn test_file_get_content_invalid_identifier() { let SetUp { home, - config: _, + config, network_env, } = set_up(); let _tear_down = TearDown::new(home.path().to_path_buf(), network_env); - todo!() + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(IOStub::new())); + + let (http_downloader, _http_guard): (_, HttpDownloaderMockGuard) = + get_http_downloader_mock(vec![], false, HttpDownloaderMockHandler::default()); + + let (process, _process_guard): (_, ProcessExecutorMockGuard) = + 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("https://example.org/acme.git".to_string()), + ); + + let mut driver = GitDriver::new(repo_config, io, config, http_downloader, process); + + assert_eq!(None, driver.get_file_content("file.txt", "h").unwrap()); + + let err = driver.get_file_content("file.txt", "-h").unwrap_err(); + assert!(err.downcast_ref::<RuntimeException>().is_some()); } #[test] -#[ignore = "requires ProcessExecutor mock (getProcessExecutorMock) and IOInterface/HttpDownloader mocks, none available"] fn test_get_change_date_invalid_identifier() { let SetUp { home, - config: _, + config, network_env, } = set_up(); let _tear_down = TearDown::new(home.path().to_path_buf(), network_env); - todo!() + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(IOStub::new())); + + let (http_downloader, _http_guard): (_, HttpDownloaderMockGuard) = + get_http_downloader_mock(vec![], false, HttpDownloaderMockHandler::default()); + + let (process, _process_guard): (_, ProcessExecutorMockGuard) = + 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("https://example.org/acme.git".to_string()), + ); + + let mut driver = GitDriver::new(repo_config, io, config, http_downloader, process); + + let err = driver.get_change_date("-n1 --format=%at HEAD").unwrap_err(); + assert!(err.downcast_ref::<RuntimeException>().is_some()); } diff --git a/crates/shirabe/tests/util/bitbucket_test.rs b/crates/shirabe/tests/util/bitbucket_test.rs index 3e3fad8..d57a49a 100644 --- a/crates/shirabe/tests/util/bitbucket_test.rs +++ b/crates/shirabe/tests/util/bitbucket_test.rs @@ -1,100 +1,660 @@ //! ref: composer/tests/Composer/Test/Util/BitbucketTest.php -// These mock IO/Config/HttpDownloader to drive Bitbucket's OAuth/access-token flow; mocking -// is not available and a real HttpDownloader reaches curl_multi_init (todo!()). +use std::cell::RefCell; +use std::rc::Rc; -#[allow(dead_code)] -fn set_up() { - // Builds mocked IO/HttpDownloader/Config and records time(); mocking is not available. - todo!() +use indexmap::IndexMap; +use shirabe::config::{Config, ConfigSourceInterface}; +use shirabe::io::IOInterface; +use shirabe::io::io_interface; +use shirabe::util::Bitbucket; +use shirabe::util::http_downloader::{ + HttpDownloader, HttpDownloaderMockExpectation, HttpDownloaderMockHandler, +}; +use shirabe::util::process_executor::MockHandler; +use shirabe_php_shim::{PhpMixed, time}; + +use crate::config_stub::ConfigStubBuilder; +use crate::http_downloader_mock::{expect_full, get_http_downloader_mock}; +use crate::io_mock::{Expectation, IOMock, get_io_mock}; +use crate::process_executor_mock::get_process_executor_mock; + +const USERNAME: &str = "username"; +const PASSWORD: &str = "password"; +const CONSUMER_KEY: &str = "consumer_key"; +const CONSUMER_SECRET: &str = "consumer_secret"; +const MESSAGE: &str = "mymessage"; +const ORIGIN: &str = "bitbucket.org"; +const TOKEN: &str = "bitbuckettoken"; + +// Records add/removeConfigSetting calls and serves a configurable getName, mirroring +// the PHPUnit mock of ConfigSourceInterface used throughout BitbucketTest. +#[derive(Debug, Clone, Default)] +struct ConfigSourceCalls { + added: Vec<(String, PhpMixed)>, + removed: Vec<String>, +} + +#[derive(Debug)] +struct ConfigSourceMock { + name: String, + calls: Rc<RefCell<ConfigSourceCalls>>, +} + +impl ConfigSourceInterface for ConfigSourceMock { + fn add_repository( + &mut self, + _name: &str, + _config: PhpMixed, + _append: bool, + ) -> anyhow::Result<()> { + unreachable!() + } + fn insert_repository( + &mut self, + _name: &str, + _config: PhpMixed, + _reference_name: &str, + _offset: i64, + ) -> anyhow::Result<()> { + unreachable!() + } + fn set_repository_url(&mut self, _name: &str, _url: &str) -> anyhow::Result<()> { + unreachable!() + } + fn remove_repository(&mut self, _name: &str) -> anyhow::Result<()> { + unreachable!() + } + fn add_config_setting(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.calls + .borrow_mut() + .added + .push((name.to_string(), value)); + Ok(()) + } + fn remove_config_setting(&mut self, name: &str) -> anyhow::Result<()> { + self.calls.borrow_mut().removed.push(name.to_string()); + Ok(()) + } + fn add_property(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { + unreachable!() + } + fn remove_property(&mut self, _name: &str) -> anyhow::Result<()> { + unreachable!() + } + fn add_link(&mut self, _type: &str, _name: &str, _value: &str) -> anyhow::Result<()> { + unreachable!() + } + fn remove_link(&mut self, _type: &str, _name: &str) -> anyhow::Result<()> { + unreachable!() + } + fn get_name(&self) -> String { + self.name.clone() + } +} + +// Mirrors BitbucketTest::setUp: a DEBUG-verbosity IOMock, a mocked HttpDownloader, a +// real Config, the captured `time()`, and the Bitbucket under test. The mock guards +// run their assert_complete on drop at the end of the test scope. +struct Fixture { + io: Rc<RefCell<IOMock>>, + config: Rc<RefCell<Config>>, + http_downloader: Rc<RefCell<HttpDownloader>>, + time: i64, + bitbucket: Bitbucket, + _io_guard: crate::io_mock::IOMockGuard, + _http_guard: crate::http_downloader_mock::HttpDownloaderMockGuard, +} + +fn set_up_with_config_and_http( + config: Rc<RefCell<Config>>, + http_expectations: Vec<HttpDownloaderMockExpectation>, +) -> Fixture { + let (io_mock, io_guard) = get_io_mock(io_interface::DEBUG).unwrap(); + let (http_downloader, http_guard) = get_http_downloader_mock( + http_expectations, + true, + HttpDownloaderMockHandler::default(), + ); + + let io: Rc<RefCell<dyn IOInterface>> = io_mock.clone(); + let time = time(); + let bitbucket = Bitbucket::new( + io, + config.clone(), + None, + Some(http_downloader.clone()), + Some(time), + ) + .unwrap(); + + Fixture { + io: io_mock, + config, + http_downloader, + time, + bitbucket, + _io_guard: io_guard, + _http_guard: http_guard, + } +} + +// The OAuth2 token request as built by Bitbucket::request_access_token. +fn access_token_request_options() -> IndexMap<String, PhpMixed> { + let mut http = IndexMap::new(); + http.insert("method".to_string(), PhpMixed::String("POST".to_string())); + http.insert( + "content".to_string(), + PhpMixed::String("grant_type=client_credentials".to_string()), + ); + let mut options: IndexMap<String, PhpMixed> = IndexMap::new(); + options.insert("retry-auth-failure".to_string(), PhpMixed::Bool(false)); + options.insert("http".to_string(), PhpMixed::Array(http)); + options +} + +fn access_token_body() -> String { + format!( + "{{\"access_token\": \"{}\", \"scopes\": \"repository\", \"expires_in\": 3600, \"refresh_token\": \"refreshtoken\", \"token_type\": \"bearer\"}}", + TOKEN + ) +} + +// Installs recording config/auth config sources and returns their shared call logs, +// mirroring BitbucketTest::setExpectationsForStoringAccessToken. +struct StoreAccessTokenMocks { + config_source: Rc<RefCell<ConfigSourceCalls>>, + auth_config_source: Rc<RefCell<ConfigSourceCalls>>, +} + +fn set_expectations_for_storing_access_token( + config: &Rc<RefCell<Config>>, +) -> StoreAccessTokenMocks { + let config_source = Rc::new(RefCell::new(ConfigSourceCalls::default())); + let auth_config_source = Rc::new(RefCell::new(ConfigSourceCalls::default())); + config + .borrow_mut() + .set_config_source(Box::new(ConfigSourceMock { + name: "config-source".to_string(), + calls: config_source.clone(), + })); + config + .borrow_mut() + .set_auth_config_source(Box::new(ConfigSourceMock { + name: "auth-config-source".to_string(), + calls: auth_config_source.clone(), + })); + StoreAccessTokenMocks { + config_source, + auth_config_source, + } +} + +fn expected_stored_token(time: i64) -> PhpMixed { + let mut consumer = IndexMap::new(); + consumer.insert( + "consumer-key".to_string(), + PhpMixed::String(CONSUMER_KEY.to_string()), + ); + consumer.insert( + "consumer-secret".to_string(), + PhpMixed::String(CONSUMER_SECRET.to_string()), + ); + consumer.insert( + "access-token".to_string(), + PhpMixed::String(TOKEN.to_string()), + ); + consumer.insert( + "access-token-expiration".to_string(), + PhpMixed::Int(time + 3600), + ); + PhpMixed::Array(consumer) +} + +fn assert_stored_access_token(mocks: &StoreAccessTokenMocks, time: i64, remove_basic_auth: bool) { + assert_eq!( + mocks.config_source.borrow().removed, + vec![format!("bitbucket-oauth.{}", ORIGIN)], + ); + assert_eq!( + mocks.auth_config_source.borrow().added, + vec![( + format!("bitbucket-oauth.{}", ORIGIN), + expected_stored_token(time) + )], + ); + if remove_basic_auth { + assert_eq!( + mocks.auth_config_source.borrow().removed, + vec![format!("http-basic.{}", ORIGIN)], + ); + } } #[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mocks for HttpDownloader/Config (no mock infrastructure)"] fn test_request_access_token_with_valid_oauth_consumer() { - todo!() + let config = ConfigStubBuilder::new().build_shared(); + let mut f = set_up_with_config_and_http( + config, + vec![expect_full( + Bitbucket::OAUTH2_ACCESS_TOKEN_URL, + Some(access_token_request_options()), + 200, + access_token_body(), + vec![], + )], + ); + + f.io.borrow_mut() + .expects( + vec![Expectation::auth( + ORIGIN, + CONSUMER_KEY, + Some(CONSUMER_SECRET.to_string()), + )], + false, + ) + .unwrap(); + + let mocks = set_expectations_for_storing_access_token(&f.config); + + assert_eq!( + f.bitbucket + .request_token(ORIGIN, CONSUMER_KEY, CONSUMER_SECRET) + .unwrap(), + TOKEN + ); + + assert_stored_access_token(&mocks, f.time, false); } #[test] -#[ignore = "needs getMockBuilder mock for Config (no mock infrastructure)"] fn test_request_access_token_with_valid_oauth_consumer_and_valid_stored_access_token() { - todo!() + let time = time(); + let mut stored = IndexMap::new(); + stored.insert( + "access-token".to_string(), + PhpMixed::String(TOKEN.to_string()), + ); + stored.insert( + "access-token-expiration".to_string(), + PhpMixed::Int(time + 1800), + ); + stored.insert( + "consumer-key".to_string(), + PhpMixed::String(CONSUMER_KEY.to_string()), + ); + stored.insert( + "consumer-secret".to_string(), + PhpMixed::String(CONSUMER_SECRET.to_string()), + ); + let mut oauth = IndexMap::new(); + oauth.insert(ORIGIN.to_string(), PhpMixed::Array(stored)); + + let config = ConfigStubBuilder::new() + .with("bitbucket-oauth", PhpMixed::Array(oauth)) + .build_shared(); + let mut f = set_up_with_config_and_http(config, vec![]); + f.time = time; + + assert_eq!( + f.bitbucket + .request_token(ORIGIN, CONSUMER_KEY, CONSUMER_SECRET) + .unwrap(), + TOKEN + ); + + // testGetTokenWithAccessToken (@depends): the same Bitbucket now returns the token. + assert_eq!(f.bitbucket.get_token(), TOKEN); } #[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mocks for HttpDownloader/Config (no mock infrastructure)"] fn test_request_access_token_with_valid_oauth_consumer_and_expired_access_token() { - todo!() + let time = time(); + let mut stored = IndexMap::new(); + stored.insert( + "access-token".to_string(), + PhpMixed::String("randomExpiredToken".to_string()), + ); + stored.insert( + "access-token-expiration".to_string(), + PhpMixed::Int(time - 400), + ); + stored.insert( + "consumer-key".to_string(), + PhpMixed::String(CONSUMER_KEY.to_string()), + ); + stored.insert( + "consumer-secret".to_string(), + PhpMixed::String(CONSUMER_SECRET.to_string()), + ); + let mut oauth = IndexMap::new(); + oauth.insert(ORIGIN.to_string(), PhpMixed::Array(stored)); + + let config = ConfigStubBuilder::new() + .with("bitbucket-oauth", PhpMixed::Array(oauth)) + .build_shared(); + let mut f = set_up_with_config_and_http( + config, + vec![expect_full( + Bitbucket::OAUTH2_ACCESS_TOKEN_URL, + Some(access_token_request_options()), + 200, + access_token_body(), + vec![], + )], + ); + f.time = time; + + f.io.borrow_mut() + .expects( + vec![Expectation::auth( + ORIGIN, + CONSUMER_KEY, + Some(CONSUMER_SECRET.to_string()), + )], + false, + ) + .unwrap(); + + let mocks = set_expectations_for_storing_access_token(&f.config); + + assert_eq!( + f.bitbucket + .request_token(ORIGIN, CONSUMER_KEY, CONSUMER_SECRET) + .unwrap(), + TOKEN + ); + + assert_stored_access_token(&mocks, f.time, false); } #[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mocks for HttpDownloader/Config (no mock infrastructure)"] fn test_request_access_token_with_username_and_password() { - todo!() + let config = ConfigStubBuilder::new().build_shared(); + // A 400 status makes the mocked HttpDownloader raise a TransportException(400), + // matching PHP's willThrowException for the BAD REQUEST case. + let mut f = set_up_with_config_and_http( + config, + vec![expect_full( + Bitbucket::OAUTH2_ACCESS_TOKEN_URL, + Some(access_token_request_options()), + 400, + "", + vec![], + )], + ); + + f.io + .borrow_mut() + .expects( + vec![ + Expectation::auth(ORIGIN, USERNAME, Some(PASSWORD.to_string())), + Expectation::text("Invalid OAuth consumer provided."), + Expectation::text("This can have three reasons:"), + Expectation::text( + "1. You are authenticating with a bitbucket username/password combination", + ), + Expectation::text( + "2. You are using an OAuth consumer, but didn't configure a (dummy) callback url", + ), + Expectation::text( + "3. You are using an OAuth consumer, but didn't configure it as private consumer", + ), + ], + true, + ) + .unwrap(); + + assert_eq!( + f.bitbucket + .request_token(ORIGIN, USERNAME, PASSWORD) + .unwrap(), + "" + ); } #[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mocks for HttpDownloader/Config (no mock infrastructure)"] fn test_request_access_token_with_username_and_password_with_unauthorized_response() { - todo!() + let config = ConfigStubBuilder::new().build_shared(); + let mut f = set_up_with_config_and_http( + config, + vec![expect_full( + Bitbucket::OAUTH2_ACCESS_TOKEN_URL, + Some(access_token_request_options()), + 401, + "", + vec![], + )], + ); + + f.io + .borrow_mut() + .expects( + vec![ + Expectation::auth(ORIGIN, USERNAME, Some(PASSWORD.to_string())), + Expectation::text("Invalid OAuth consumer provided."), + Expectation::text( + "You can also add it manually later by using \"composer config --global --auth bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>\"", + ), + ], + true, + ) + .unwrap(); + + assert_eq!( + f.bitbucket + .request_token(ORIGIN, USERNAME, PASSWORD) + .unwrap(), + "" + ); } #[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mocks for HttpDownloader/Config (no mock infrastructure)"] fn test_request_access_token_with_username_and_password_with_not_found_response() { - todo!() + let config = ConfigStubBuilder::new().build_shared(); + let mut f = set_up_with_config_and_http( + config, + vec![expect_full( + Bitbucket::OAUTH2_ACCESS_TOKEN_URL, + Some(access_token_request_options()), + 404, + "", + vec![], + )], + ); + + f.io.borrow_mut() + .expects( + vec![Expectation::auth( + ORIGIN, + USERNAME, + Some(PASSWORD.to_string()), + )], + false, + ) + .unwrap(); + + let result = f.bitbucket.request_token(ORIGIN, USERNAME, PASSWORD); + assert!( + result.is_err(), + "expected a TransportException to propagate" + ); } #[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mocks for HttpDownloader/Config (no mock infrastructure)"] fn test_username_password_authentication_flow() { - todo!() + let url = format!("https://{}/site/oauth2/access_token", ORIGIN); + let body = format!( + "{{\"access_token\": \"{}\", \"scopes\": \"repository\", \"expires_in\": 3600, \"refresh_token\": \"refresh_token\", \"token_type\": \"bearer\"}}", + TOKEN + ); + // PHP matches the URL with `$this->anything()` for options, so match any options. + let config = ConfigStubBuilder::new().build_shared(); + let mut f = + set_up_with_config_and_http(config, vec![expect_full(url, None, 200, body, vec![])]); + + f.io.borrow_mut() + .expects( + vec![ + Expectation::text(MESSAGE), + Expectation::ask("Consumer Key (hidden): ", CONSUMER_KEY), + Expectation::ask("Consumer Secret (hidden): ", CONSUMER_SECRET), + ], + false, + ) + .unwrap(); + + let mocks = set_expectations_for_storing_access_token(&f.config); + + assert!( + f.bitbucket + .authorize_oauth_interactively(ORIGIN, Some(MESSAGE)) + .unwrap() + ); + + assert_stored_access_token(&mocks, f.time, true); } #[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mock for Config (no mock infrastructure)"] fn test_authorize_oauth_interactively_with_empty_username() { - todo!() + let config = ConfigStubBuilder::new().build_shared(); + let mut f = set_up_with_config_and_http(config, vec![]); + + // getAuthConfigSource() is consulted while printing the instructions. + let auth_calls = Rc::new(RefCell::new(ConfigSourceCalls::default())); + f.config + .borrow_mut() + .set_auth_config_source(Box::new(ConfigSourceMock { + name: "auth-config-source".to_string(), + calls: auth_calls, + })); + + f.io.borrow_mut() + .expects(vec![Expectation::ask("Consumer Key (hidden): ", "")], false) + .unwrap(); + + assert!( + !f.bitbucket + .authorize_oauth_interactively(ORIGIN, Some(MESSAGE)) + .unwrap() + ); } #[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mock for Config (no mock infrastructure)"] fn test_authorize_oauth_interactively_with_empty_password() { - todo!() + let config = ConfigStubBuilder::new().build_shared(); + let mut f = set_up_with_config_and_http(config, vec![]); + + let auth_calls = Rc::new(RefCell::new(ConfigSourceCalls::default())); + f.config + .borrow_mut() + .set_auth_config_source(Box::new(ConfigSourceMock { + name: "auth-config-source".to_string(), + calls: auth_calls, + })); + + f.io.borrow_mut() + .expects( + vec![ + Expectation::text(MESSAGE), + Expectation::ask("Consumer Key (hidden): ", CONSUMER_KEY), + Expectation::ask("Consumer Secret (hidden): ", ""), + ], + false, + ) + .unwrap(); + + assert!( + !f.bitbucket + .authorize_oauth_interactively(ORIGIN, Some(MESSAGE)) + .unwrap() + ); } #[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mocks for HttpDownloader/Config (no mock infrastructure)"] fn test_authorize_oauth_interactively_with_request_access_token_failure() { - todo!() -} + let url = format!("https://{}/site/oauth2/access_token", ORIGIN); + let config = ConfigStubBuilder::new().build_shared(); + // A 400 status makes the mocked HttpDownloader raise a TransportException(400). + let mut f = set_up_with_config_and_http(config, vec![expect_full(url, None, 400, "", vec![])]); -#[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mocks for HttpDownloader/Config to construct Bitbucket (no mock infrastructure)"] -fn test_get_token_without_access_token() { - todo!() + let auth_calls = Rc::new(RefCell::new(ConfigSourceCalls::default())); + f.config + .borrow_mut() + .set_auth_config_source(Box::new(ConfigSourceMock { + name: "auth-config-source".to_string(), + calls: auth_calls, + })); + + f.io.borrow_mut() + .expects( + vec![ + Expectation::text(MESSAGE), + Expectation::ask("Consumer Key (hidden): ", CONSUMER_KEY), + Expectation::ask("Consumer Secret (hidden): ", CONSUMER_SECRET), + ], + false, + ) + .unwrap(); + + assert!( + !f.bitbucket + .authorize_oauth_interactively(ORIGIN, Some(MESSAGE)) + .unwrap() + ); } #[test] -#[ignore = "needs getMockBuilder mock for Config and @depends-injected Bitbucket from a mock-based test (no mock infrastructure)"] -fn test_get_token_with_access_token() { - todo!() +fn test_get_token_without_access_token() { + let config = ConfigStubBuilder::new().build_shared(); + let f = set_up_with_config_and_http(config, vec![]); + assert_eq!(f.bitbucket.get_token(), ""); } #[test] -#[ignore = "needs getIOMock/IOMock and getMockBuilder mocks for HttpDownloader/Config to construct Bitbucket (no mock infrastructure)"] fn test_authorize_oauth_with_wrong_origin_url() { - todo!() + let config = ConfigStubBuilder::new().build_shared(); + let mut f = set_up_with_config_and_http(config, vec![]); + assert!(!f.bitbucket.authorize_oauth(&format!("non-{}", ORIGIN))); } #[test] -#[ignore = "needs getIOMock/IOMock, getProcessExecutorMock and getMockBuilder mocks for HttpDownloader/Config (no mock infrastructure)"] fn test_authorize_oauth_without_available_git_config_token() { - todo!() + let config = ConfigStubBuilder::new().build_shared(); + let (io_mock, _io_guard) = get_io_mock(io_interface::DEBUG).unwrap(); + let (http_downloader, _http_guard) = + get_http_downloader_mock(vec![], true, HttpDownloaderMockHandler::default()); + let (process, _process_guard) = get_process_executor_mock( + vec![], + false, + MockHandler { + r#return: -1, + ..Default::default() + }, + ); + + let io: Rc<RefCell<dyn IOInterface>> = io_mock.clone(); + let time = time(); + let mut bitbucket = + Bitbucket::new(io, config, Some(process), Some(http_downloader), Some(time)).unwrap(); + + assert!(!bitbucket.authorize_oauth(ORIGIN)); } #[test] -#[ignore = "needs getIOMock/IOMock, getProcessExecutorMock and getMockBuilder mocks for HttpDownloader/Config (no mock infrastructure)"] fn test_authorize_oauth_with_available_git_config_token() { - todo!() + let config = ConfigStubBuilder::new().build_shared(); + let (io_mock, _io_guard) = get_io_mock(io_interface::DEBUG).unwrap(); + let (http_downloader, _http_guard) = + get_http_downloader_mock(vec![], true, HttpDownloaderMockHandler::default()); + let (process, _process_guard) = + get_process_executor_mock(vec![], false, MockHandler::default()); + + let io: Rc<RefCell<dyn IOInterface>> = io_mock.clone(); + let time = time(); + let mut bitbucket = + Bitbucket::new(io, config, Some(process), Some(http_downloader), Some(time)).unwrap(); + + assert!(bitbucket.authorize_oauth(ORIGIN)); } |
