diff options
Diffstat (limited to 'crates/shirabe-php-shim/src')
| -rw-r--r-- | crates/shirabe-php-shim/src/datetime.rs | 99 | ||||
| -rw-r--r-- | crates/shirabe-php-shim/src/zip.rs | 46 |
2 files changed, 132 insertions, 13 deletions
diff --git a/crates/shirabe-php-shim/src/datetime.rs b/crates/shirabe-php-shim/src/datetime.rs index e41028e..f44bb7b 100644 --- a/crates/shirabe-php-shim/src/datetime.rs +++ b/crates/shirabe-php-shim/src/datetime.rs @@ -1,11 +1,55 @@ static DEFAULT_TIMEZONE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None); -pub fn date_create<Tz: chrono::TimeZone>(s: &str) -> chrono::ParseResult<chrono::DateTime<Tz>> { - // TODO(phase-d): PHP `date_create` accepts the full strtotime() grammar (RFC2822, ISO8601, - // VCS-specific and relative formats), which requires a dedicated date parser not available - // here. The generic `Tz` also has no constructor from a parsed `FixedOffset`/`Utc` value. - let _ = s; - todo!() +/// Parse the subset of the strtotime()/date_create() grammar that Composer actually emits. +/// +/// Supported: ISO8601/RFC3339 (`2023-01-15T12:34:56Z`, `...+00:00`), `Y-m-d H:i:s`, `Y-m-d`, +/// and `@<unixtime>`. Inputs without an explicit offset are interpreted as UTC, matching the +/// default timezone this shim assumes elsewhere. Anything else returns `None` rather than +/// guessing, mirroring PHP returning `false` on unrecognized input. +fn parse_to_fixed(s: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> { + let s = s.trim(); + if s.is_empty() { + return None; + } + + // `@<unixtime>` (optionally with a fractional part) denotes a Unix timestamp in UTC. + if let Some(rest) = s.strip_prefix('@') { + let secs = if let Some((whole, _frac)) = rest.split_once('.') { + whole.parse::<i64>().ok()? + } else { + rest.parse::<i64>().ok()? + }; + let utc = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)?; + return Some(utc.fixed_offset()); + } + + // RFC3339 / ISO8601 with an explicit offset or trailing `Z`. + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) { + return Some(dt); + } + + // `Y-m-d H:i:s` and `Y-m-d`, both interpreted as UTC. + let naive = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .ok() + .or_else(|| { + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") + .ok() + .and_then(|d| d.and_hms_opt(0, 0, 0)) + })?; + let utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc); + Some(utc.fixed_offset()) +} + +pub fn date_create<Tz: chrono::TimeZone>(s: &str) -> chrono::ParseResult<chrono::DateTime<Tz>> +where + chrono::DateTime<Tz>: From<chrono::DateTime<chrono::FixedOffset>>, +{ + match parse_to_fixed(s) { + Some(dt) => Ok(dt.into()), + // PHP `date_create` returns `false` here; the closest faithful signal under chrono's + // `ParseResult` is a parse error, produced by replaying a deliberately invalid parse. + None => Err(chrono::DateTime::parse_from_rfc3339("").unwrap_err()), + } } /// PHP: \DATE_RFC3339 ("Y-m-d\TH:i:sP"). @@ -26,10 +70,45 @@ pub fn date_format_to_strftime(format: &str) -> &'static str { } } -pub fn strtotime(_time: &str) -> Option<i64> { - // TODO(phase-d): requires the full strtotime() grammar (absolute, relative and compound - // expressions); a partial parser would silently mis-handle unsupported inputs. - todo!() +/// Subset of strtotime() covering what Composer passes: `now`, the absolute formats handled by +/// `parse_to_fixed`, and simple single-unit relative offsets such as `-8 days` / `+3 hours`. +/// +/// Returns `None` for anything else, matching PHP `strtotime()` returning `false`, rather than +/// silently mis-handling an unsupported expression. +pub fn strtotime(time: &str) -> Option<i64> { + let trimmed = time.trim(); + + if trimmed.eq_ignore_ascii_case("now") { + return Some(self::time()); + } + + if let Some(dt) = parse_to_fixed(trimmed) { + return Some(dt.timestamp()); + } + + parse_relative(trimmed).map(|delta| self::time() + delta) +} + +/// Parse a single-unit relative offset like `-8 days`, `+3hours`, `1 week`, returning the signed +/// number of seconds. Whitespace between the count and unit is optional, as PHP accepts both. +fn parse_relative(s: &str) -> Option<i64> { + let s = s.trim(); + let split = s + .find(|c: char| c.is_ascii_alphabetic()) + .filter(|&i| i > 0)?; + let (count_part, unit_part) = s.split_at(split); + let count: i64 = count_part.trim().parse().ok()?; + + let unit = unit_part.trim().to_ascii_lowercase(); + let per_unit = match unit.as_str() { + "sec" | "secs" | "second" | "seconds" => 1, + "min" | "mins" | "minute" | "minutes" => 60, + "hour" | "hours" => 60 * 60, + "day" | "days" => 24 * 60 * 60, + "week" | "weeks" => 7 * 24 * 60 * 60, + _ => return None, + }; + Some(count * per_unit) } pub fn time() -> i64 { diff --git a/crates/shirabe-php-shim/src/zip.rs b/crates/shirabe-php-shim/src/zip.rs index 6b4d87b..d1db9e5 100644 --- a/crates/shirabe-php-shim/src/zip.rs +++ b/crates/shirabe-php-shim/src/zip.rs @@ -1,9 +1,21 @@ +use crate::ErrorException; use crate::PhpMixed; use crate::{StreamBacking, StreamState}; use indexmap::IndexMap; use std::cell::RefCell; use zip::write::SimpleFileOptions; +/// Test-only behaviour mirroring PHPUnit's `getMockBuilder('ZipArchive')->getMock()`, where +/// `open`/`extractTo`/`count` are stubbed via `->willReturn(...)`/`->willThrowException(...)`. +/// Held in [`ZipArchive::mock`]; always `None` in production. +#[derive(Debug, Clone)] +pub struct ZipArchiveMock { + pub open: Result<(), i64>, + pub count: i64, + /// `Ok(bool)` for `extractTo` returning a bool, `Err(..)` to throw an `\ErrorException`. + pub extract_to: Result<bool, String>, +} + /// Internal backing of an opened `ZipArchive`. PHP's `ZipArchive` multiplexes /// reading an existing archive and building a new one through the same handle; /// the `zip` crate splits these into `ZipArchive` (read) and `ZipWriter` (write), @@ -25,6 +37,8 @@ enum ZipState { pub struct ZipArchive { pub num_files: i64, state: RefCell<ZipState>, + /// Test-only mock state. `None` in production; set via [`ZipArchive::__mock`] in tests. + mock: Option<ZipArchiveMock>, } impl Default for ZipArchive { @@ -38,10 +52,24 @@ impl ZipArchive { Self { num_files: 0, state: RefCell::new(ZipState::Closed), + mock: None, + } + } + + /// For testing only. Builds a mocked ZipArchive whose `open`/`count`/`extract_to` return the + /// configured values, mirroring PHPUnit's `getMockBuilder('ZipArchive')->getMock()`. + pub fn __mock(mock: ZipArchiveMock) -> Self { + Self { + num_files: mock.count, + state: RefCell::new(ZipState::Closed), + mock: Some(mock), } } pub fn open(&mut self, filename: &str, flags: i64) -> Result<(), i64> { + if let Some(mock) = &self.mock { + return mock.open; + } if flags & Self::CREATE != 0 { let file = match std::fs::File::create(filename) { Ok(f) => f, @@ -79,6 +107,9 @@ impl ZipArchive { } pub fn count(&self) -> i64 { + if let Some(mock) = &self.mock { + return mock.count; + } self.num_files } @@ -113,12 +144,21 @@ impl ZipArchive { Some(stat) } - pub fn extract_to(&self, path: &str) -> bool { + pub fn extract_to(&self, path: &str) -> Result<bool, ErrorException> { + if let Some(mock) = &self.mock { + return mock.extract_to.clone().map_err(|message| ErrorException { + message, + code: 0, + severity: 1, + filename: String::new(), + lineno: 0, + }); + } let mut state = self.state.borrow_mut(); let ZipState::Reader(archive) = &mut *state else { - return false; + return Ok(false); }; - archive.extract(path).is_ok() + Ok(archive.extract(path).is_ok()) } pub fn locate_name(&self, name: &str) -> Option<i64> { |
