aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs16
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs8
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/input/array_input.rs15
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/input/input.rs17
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs7
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs6
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/output/console_output.rs7
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs7
-rw-r--r--crates/shirabe/src/command/init_command.rs33
-rw-r--r--crates/shirabe/src/console/application.rs4
-rw-r--r--crates/shirabe/tests/command/init_command_test.rs452
-rw-r--r--crates/shirabe/tests/command/main.rs3
-rw-r--r--crates/shirabe/tests/common/test_case.rs274
13 files changed, 785 insertions, 64 deletions
diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs
index f2c2a00..9eb8fff 100644
--- a/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs
@@ -66,12 +66,11 @@ impl QuestionHelper {
return Ok(Ok(self.get_default_answer(question)));
}
- // TODO(phase-b): `$input instanceof StreamableInputInterface` cannot be
- // expressed as a trait-object-to-trait-object downcast, and no concrete
- // streamable input type is wired up yet. The stream is left unset so the
- // helper falls back to STDIN. Revisit once StreamableInputInterface has a
- // concrete implementor and the PhpResource/PhpMixed split is resolved.
- let _ = &mut self.input_stream;
+ if let Some(streamable) = input.as_streamable()
+ && let Some(stream) = streamable.get_stream()
+ {
+ self.input_stream = Some(stream);
+ }
let result: anyhow::Result<Result<PhpMixed, MissingInputException>> = (|| {
if question.get_validator().is_none() {
@@ -344,10 +343,7 @@ impl QuestionHelper {
input_stream: &shirabe_php_shim::PhpResource,
autocomplete: &dyn Fn(&str) -> Vec<PhpMixed>,
) -> String {
- // TODO(phase-b): Cursor takes Option<PhpMixed>, but the input stream is a
- // PhpResource and PhpMixed has no resource variant. Defaulting to STDIN
- // until the PhpResource/PhpMixed stream representation is unified.
- let cursor = Cursor::new(Rc::clone(&output), None);
+ let cursor = Cursor::new(Rc::clone(&output), Some(input_stream.clone()));
let mut full_choice = String::new();
let mut ret = String::new();
diff --git a/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs
index d7ddafc..0784c7b 100644
--- a/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs
@@ -619,14 +619,18 @@ impl InputInterface for ArgvInput {
fn set_interactive(&mut self, interactive: bool) {
self.inner.set_interactive(interactive)
}
+
+ fn as_streamable(&self) -> Option<&dyn StreamableInputInterface> {
+ Some(self)
+ }
}
impl StreamableInputInterface for ArgvInput {
- fn set_stream(&mut self, stream: PhpMixed) {
+ fn set_stream(&mut self, stream: shirabe_php_shim::PhpResource) {
self.inner.set_stream(stream)
}
- fn get_stream(&self) -> Option<PhpMixed> {
+ fn get_stream(&self) -> Option<shirabe_php_shim::PhpResource> {
self.inner.get_stream()
}
}
diff --git a/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs
index 3fc9abb..327650b 100644
--- a/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs
@@ -5,6 +5,7 @@ use crate::symfony::console::exception::invalid_option_exception::InvalidOptionE
use crate::symfony::console::input::input::Input;
use crate::symfony::console::input::input_definition::InputDefinition;
use crate::symfony::console::input::input_interface::InputInterface;
+use crate::symfony::console::input::streamable_input_interface::StreamableInputInterface;
use indexmap::IndexMap;
use shirabe_php_shim::PhpMixed;
@@ -373,6 +374,20 @@ impl InputInterface for ArrayInput {
fn set_interactive(&mut self, interactive: bool) {
self.inner.set_interactive(interactive)
}
+
+ fn as_streamable(&self) -> Option<&dyn StreamableInputInterface> {
+ Some(self)
+ }
+}
+
+impl StreamableInputInterface for ArrayInput {
+ fn set_stream(&mut self, stream: shirabe_php_shim::PhpResource) {
+ self.inner.set_stream(stream)
+ }
+
+ fn get_stream(&self) -> Option<shirabe_php_shim::PhpResource> {
+ self.inner.get_stream()
+ }
}
/// PHP `(array) $values` cast: a string becomes a single-element array.
diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input.rs b/crates/shirabe-external-packages/src/symfony/console/input/input.rs
index d13ab6c..d365f8d 100644
--- a/crates/shirabe-external-packages/src/symfony/console/input/input.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/input/input.rs
@@ -4,7 +4,7 @@ use crate::symfony::console::exception::invalid_argument_exception::InvalidArgum
use crate::symfony::console::exception::runtime_exception::RuntimeException;
use crate::symfony::console::input::input_definition::InputDefinition;
use indexmap::IndexMap;
-use shirabe_php_shim::PhpMixed;
+use shirabe_php_shim::{PhpMixed, PhpResource};
/// Input is the base class for all concrete Input classes.
///
@@ -16,7 +16,7 @@ use shirabe_php_shim::PhpMixed;
#[derive(Debug, Clone)]
pub struct Input {
pub(crate) definition: InputDefinition,
- pub(crate) stream: PhpMixed,
+ pub(crate) stream: Option<PhpResource>,
pub(crate) options: IndexMap<String, PhpMixed>,
pub(crate) arguments: IndexMap<String, PhpMixed>,
pub(crate) interactive: bool,
@@ -26,7 +26,7 @@ impl Input {
pub fn new(definition: Option<InputDefinition>) -> anyhow::Result<Self> {
let mut input = Input {
definition: InputDefinition::new(vec![])?,
- stream: PhpMixed::Null,
+ stream: None,
options: IndexMap::new(),
arguments: IndexMap::new(),
interactive: true,
@@ -238,14 +238,11 @@ impl Input {
}
}
- pub fn set_stream(&mut self, stream: PhpMixed) {
- self.stream = stream;
+ pub fn set_stream(&mut self, stream: PhpResource) {
+ self.stream = Some(stream);
}
- pub fn get_stream(&self) -> Option<PhpMixed> {
- match &self.stream {
- PhpMixed::Null => None,
- other => Some(other.clone()),
- }
+ pub fn get_stream(&self) -> Option<PhpResource> {
+ self.stream.clone()
}
}
diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs
index c115df0..ccb5a98 100644
--- a/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs
@@ -1,6 +1,7 @@
//! ref: composer/vendor/symfony/console/Input/InputInterface.php
use crate::symfony::console::input::input_definition::InputDefinition;
+use crate::symfony::console::input::streamable_input_interface::StreamableInputInterface;
use shirabe_php_shim::PhpMixed;
pub trait InputInterface: std::fmt::Debug + shirabe_php_shim::AsAny {
@@ -41,4 +42,10 @@ pub trait InputInterface: std::fmt::Debug + shirabe_php_shim::AsAny {
fn is_interactive(&self) -> bool;
fn set_interactive(&mut self, interactive: bool);
+
+ /// Models PHP's `$input instanceof StreamableInputInterface` check. Streamable inputs override
+ /// this to return `Some(self)`; everything else falls back to `None`.
+ fn as_streamable(&self) -> Option<&dyn StreamableInputInterface> {
+ None
+ }
}
diff --git a/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs b/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs
index f687e0e..d4bdbfd 100644
--- a/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs
@@ -1,10 +1,10 @@
//! ref: composer/vendor/symfony/console/Input/StreamableInputInterface.php
use crate::symfony::console::input::input_interface::InputInterface;
-use shirabe_php_shim::PhpMixed;
+use shirabe_php_shim::PhpResource;
pub trait StreamableInputInterface: InputInterface {
- fn set_stream(&mut self, stream: PhpMixed);
+ fn set_stream(&mut self, stream: PhpResource);
- fn get_stream(&self) -> Option<PhpMixed>;
+ fn get_stream(&self) -> Option<PhpResource>;
}
diff --git a/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs b/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs
index 0808fef..dd6840d 100644
--- a/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs
@@ -110,6 +110,13 @@ impl ConsoleOutput {
shirabe_php_shim::stripos(&shirabe_php_shim::implode(";", &checks), "OS400").is_some()
}
+ /// For testing only. Overwrites the inner `StreamOutput`'s private `stream` field, mirroring
+ /// what `Symfony\Component\Console\Tester\TesterTrait::initOutput` does via reflection on the
+ /// parent `StreamOutput::$stream` property of a `ConsoleOutput`.
+ pub fn __set_stream(&mut self, stream: shirabe_php_shim::PhpResource) {
+ self.inner.__set_stream(stream);
+ }
+
fn open_output_stream() -> shirabe_php_shim::PhpResource {
if !Self::has_stdout_support() {
return shirabe_php_shim::php_fopen_resource("php://output", "w");
diff --git a/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs b/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs
index 569b348..fa63557 100644
--- a/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs
@@ -66,6 +66,13 @@ impl StreamOutput {
&self.stream
}
+ /// For testing only. Overwrites the private `stream` field, mirroring what
+ /// `Symfony\Component\Console\Tester\TesterTrait::initOutput` does via reflection on the
+ /// `StreamOutput::$stream` property.
+ pub fn __set_stream(&mut self, stream: shirabe_php_shim::PhpResource) {
+ self.stream = stream;
+ }
+
/// Returns true if the stream supports colorization.
///
/// Colorization is disabled if not supported by the stream:
diff --git a/crates/shirabe/src/command/init_command.rs b/crates/shirabe/src/command/init_command.rs
index a04836d..68b4b69 100644
--- a/crates/shirabe/src/command/init_command.rs
+++ b/crates/shirabe/src/command/init_command.rs
@@ -623,28 +623,25 @@ impl Command for InitCommand {
}
),
// PHP: function ($value) use ($self, $author) { ... $author = $self->parseAuthorString($value); ... }
- // TODO(phase-c): IOInterface::ask_and_validate takes a `Box<dyn Fn> + 'static`
- // validator, so it cannot borrow &self to call self.parse_author_string; and
- // ask_and_validate itself is a deferred QuestionHelper todo!() (see console_io). The
- // validator body below therefore stays a placeholder. (parse_author_string and
- // is_valid_email are stateless, so a future fix can make them associated functions and
- // call them from the closure once the helper interaction is modelled.)
+ // The validator is a `'static` closure and cannot borrow `&self`, but
+ // parse_author_string is stateless (it only delegates to the stateless
+ // is_valid_email), so a fresh InitCommand stands in for `$self` here.
Box::new(move |value: PhpMixed| -> anyhow::Result<PhpMixed> {
- let value_str = value.as_string().unwrap_or("").to_string();
- if value_str == "n" || value_str == "no" {
+ let value_str = value.as_string().map(|s| s.to_string());
+ if value_str.as_deref() == Some("n") || value_str.as_deref() == Some("no") {
return Ok(PhpMixed::Null);
}
- let value_or_default = if value_str.is_empty() {
- author_for_validate.clone()
- } else {
- value_str
+ // PHP: $value = $value ?: $author
+ let value = match &value_str {
+ Some(s) if !s.is_empty() => s.clone(),
+ _ => author_for_validate.clone(),
};
- // PHP: $author = $self->parseAuthorString($value); return $author['email'] === null
- // ? $author['name'] : sprintf('%s <%s>', $author['name'], $author['email']);
- // TODO(phase-c): see the closure note above — cannot reach parse_author_string from
- // this 'static validator yet.
- let _ = value_or_default;
- Ok(PhpMixed::Null)
+ let parsed = InitCommand::new().parse_author_string(&value)?;
+ let name = parsed.get("name").cloned().flatten().unwrap_or_default();
+ match parsed.get("email").cloned().flatten() {
+ None => Ok(PhpMixed::String(name)),
+ Some(email) => Ok(PhpMixed::String(format!("{} <{}>", name, email))),
+ }
}),
None,
PhpMixed::String(author_default),
diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs
index 8112e18..1a661df 100644
--- a/crates/shirabe/src/console/application.rs
+++ b/crates/shirabe/src/console/application.rs
@@ -2585,6 +2585,10 @@ impl ApplicationHandle {
outcome
}
+
+ pub fn set_catch_exceptions(&self, boolean: bool) {
+ self.0.borrow_mut().set_catch_exceptions(boolean);
+ }
}
impl ApplicationHandle {
diff --git a/crates/shirabe/tests/command/init_command_test.rs b/crates/shirabe/tests/command/init_command_test.rs
index 6b118f3..1c2fca6 100644
--- a/crates/shirabe/tests/command/init_command_test.rs
+++ b/crates/shirabe/tests/command/init_command_test.rs
@@ -1,12 +1,10 @@
//! ref: composer/tests/Composer/Test/Command/InitCommandTest.php
-// The run cases (testRunCommand, testRunCommandInvalid, testRunGuessNameFromDirSanitizesDir,
-// testInteractiveRun) drive the full command via ApplicationTester / initTempComposer, which
-// does not exist here; they remain reason'd-ignore. The unit-style cases call the helper
-// methods directly via `__`-prefixed test-only wrappers.
-
+use crate::test_case::{RunOptions, get_application_tester, init_temp_composer};
+use serial_test::serial;
use shirabe::command::init_command::InitCommand;
-use shirabe_php_shim::{PhpMixed, server_set};
+use shirabe::json::JsonFile;
+use shirabe_php_shim::{PhpMixed, server_set, server_unset};
use tempfile::TempDir;
fn set_up() {
@@ -14,6 +12,45 @@ fn set_up() {
server_set("COMPOSER_DEFAULT_EMAIL", "john@example.com".to_string());
}
+/// const DEFAULT_AUTHORS in PHP.
+fn default_authors() -> serde_json::Value {
+ serde_json::json!({ "name": "John Smith", "email": "john@example.com" })
+}
+
+/// Reads CWD's `composer.json` like PHP's `(new JsonFile(...))->read()`, projected onto a
+/// `serde_json::Value` so the comparison ignores object key order (matching PHPUnit's `assertEquals`
+/// on arrays) while staying order-sensitive for lists.
+fn read_composer_json(dir: &std::path::Path) -> serde_json::Value {
+ let mut file = JsonFile::new(
+ dir.join("composer.json").to_string_lossy().to_string(),
+ None,
+ None,
+ )
+ .unwrap();
+ serde_json::to_value(file.read().unwrap()).unwrap()
+}
+
+/// `['command' => 'init', '--no-interaction' => true] + $arguments`.
+fn non_interactive_input(arguments: Vec<(PhpMixed, PhpMixed)>) -> Vec<(PhpMixed, PhpMixed)> {
+ let mut input = vec![
+ (PhpMixed::from("command"), PhpMixed::from("init")),
+ (PhpMixed::from("--no-interaction"), PhpMixed::Bool(true)),
+ ];
+ input.extend(arguments);
+ input
+}
+
+fn opt(name: &str, value: &str) -> (PhpMixed, PhpMixed) {
+ (PhpMixed::from(name), PhpMixed::from(value))
+}
+
+fn opt_list(name: &str, values: &[&str]) -> (PhpMixed, PhpMixed) {
+ (
+ PhpMixed::from(name),
+ PhpMixed::List(values.iter().map(|v| PhpMixed::from(*v)).collect()),
+ )
+}
+
/// @return iterable<string, array{0: string, 1: string|null, 2: string}>
fn valid_author_string_provider() -> Vec<(&'static str, Option<&'static str>, &'static str)> {
vec![
@@ -54,7 +91,6 @@ fn valid_author_string_provider() -> Vec<(&'static str, Option<&'static str>, &'
]
}
-#[ignore]
#[test]
fn test_parse_valid_author_string() {
set_up();
@@ -73,7 +109,6 @@ fn test_parse_valid_author_string() {
}
}
-#[ignore]
#[test]
fn test_parse_empty_author_string() {
set_up();
@@ -83,7 +118,6 @@ fn test_parse_empty_author_string() {
assert!(result.is_err());
}
-#[ignore]
#[test]
fn test_parse_author_string_with_invalid_email() {
set_up();
@@ -123,39 +157,416 @@ fn test_namespace_from_missing_package_name() {
assert_eq!(None, namespace);
}
-#[ignore = "needs TestCase::init_temp_composer and get_application_tester (ApplicationTester) infrastructure, not implemented"]
+/// @return iterable<string, array{0: array<string, mixed>, 1: array<string, mixed>}>
+///
+/// Empty `require` is `{}` (PHP's `new \stdClass`), not `[]`, to match what the command writes:
+/// shirabe's `json_decode` keeps the empty object/array distinction that PHP collapses.
+fn run_data_provider() -> Vec<(serde_json::Value, Vec<(PhpMixed, PhpMixed)>)> {
+ vec![
+ // name argument
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ }),
+ vec![opt("--name", "test/pkg")],
+ ),
+ // name and author arguments
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "require": {},
+ "authors": [{ "name": "Mr. Test", "email": "test@example.org" }],
+ }),
+ vec![
+ opt("--name", "test/pkg"),
+ opt("--author", "Mr. Test <test@example.org>"),
+ ],
+ ),
+ // name and author arguments without email
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "require": {},
+ "authors": [{ "name": "Mr. Test" }],
+ }),
+ vec![opt("--name", "test/pkg"), opt("--author", "Mr. Test")],
+ ),
+ // single repository argument
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ "repositories": [{ "type": "vcs", "url": "http://packages.example.com" }],
+ }),
+ vec![
+ opt("--name", "test/pkg"),
+ opt_list(
+ "--repository",
+ &["{\"type\":\"vcs\",\"url\":\"http://packages.example.com\"}"],
+ ),
+ ],
+ ),
+ // multiple repository arguments
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ "repositories": [
+ { "type": "vcs", "url": "http://vcs.example.com" },
+ { "type": "composer", "url": "http://composer.example.com" },
+ {
+ "type": "composer",
+ "url": "http://composer2.example.com",
+ "options": { "ssl": { "verify_peer": "true" } },
+ },
+ ],
+ }),
+ vec![
+ opt("--name", "test/pkg"),
+ opt_list(
+ "--repository",
+ &[
+ "{\"type\":\"vcs\",\"url\":\"http://vcs.example.com\"}",
+ "{\"type\":\"composer\",\"url\":\"http://composer.example.com\"}",
+ "{\"type\":\"composer\",\"url\":\"http://composer2.example.com\",\"options\":{\"ssl\":{\"verify_peer\":\"true\"}}}",
+ ],
+ ),
+ ],
+ ),
+ // stability argument
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ "minimum-stability": "dev",
+ }),
+ vec![opt("--name", "test/pkg"), opt("--stability", "dev")],
+ ),
+ // require one argument
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": { "first/pkg": "1.0.0" },
+ }),
+ vec![
+ opt("--name", "test/pkg"),
+ opt_list("--require", &["first/pkg:1.0.0"]),
+ ],
+ ),
+ // require multiple arguments
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": { "first/pkg": "1.0.0", "second/pkg": "^3.4" },
+ }),
+ vec![
+ opt("--name", "test/pkg"),
+ opt_list("--require", &["first/pkg:1.0.0", "second/pkg:^3.4"]),
+ ],
+ ),
+ // require-dev one argument
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ "require-dev": { "first/pkg": "1.0.0" },
+ }),
+ vec![
+ opt("--name", "test/pkg"),
+ opt_list("--require-dev", &["first/pkg:1.0.0"]),
+ ],
+ ),
+ // require-dev multiple arguments
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ "require-dev": { "first/pkg": "1.0.0", "second/pkg": "^3.4" },
+ }),
+ vec![
+ opt("--name", "test/pkg"),
+ opt_list("--require-dev", &["first/pkg:1.0.0", "second/pkg:^3.4"]),
+ ],
+ ),
+ // autoload argument
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ "autoload": { "psr-4": { "Test\\Pkg\\": "testMapping/" } },
+ }),
+ vec![opt("--name", "test/pkg"), opt("--autoload", "testMapping/")],
+ ),
+ // homepage argument
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ "homepage": "https://example.org/",
+ }),
+ vec![
+ opt("--name", "test/pkg"),
+ opt("--homepage", "https://example.org/"),
+ ],
+ ),
+ // description argument
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ "description": "My first example package",
+ }),
+ vec![
+ opt("--name", "test/pkg"),
+ opt("--description", "My first example package"),
+ ],
+ ),
+ // type argument
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ "type": "project",
+ }),
+ vec![opt("--name", "test/pkg"), opt("--type", "project")],
+ ),
+ // license argument
+ (
+ serde_json::json!({
+ "name": "test/pkg",
+ "authors": [default_authors()],
+ "require": {},
+ "license": "MIT",
+ }),
+ vec![opt("--name", "test/pkg"), opt("--license", "MIT")],
+ ),
+ ]
+}
+
#[test]
+#[serial]
+#[ignore = "drives InitCommand, which calls get_git_config -> ProcessExecutor::run_process; \
+ that path is not ported (shim is_callable and Process::start/run are todo!())"]
fn test_run_command() {
set_up();
- todo!()
+ for (expected, arguments) in run_data_provider() {
+ let tear_down = init_temp_composer(None, None, None, true);
+ let dir = tear_down.working_dir();
+ std::fs::remove_file(dir.join("composer.json")).unwrap();
+ std::fs::remove_file(dir.join("auth.json")).unwrap();
+
+ let mut app_tester = get_application_tester();
+ app_tester
+ .run(non_interactive_input(arguments), RunOptions::default())
+ .unwrap();
+
+ assert_eq!(0, app_tester.get_status_code());
+
+ assert_eq!(expected, read_composer_json(&dir));
+ }
+}
+
+/// Either the run throws (optionally carrying a message), or it returns exit code 1 and writes a
+/// message matching the regex to stderr.
+enum InvalidExpectation {
+ Throws(Option<&'static str>),
+ StderrMatches(&'static str),
+}
+
+/// @return iterable<string, array{0: class-string<\Throwable>|null, 1: string|null, 2: array<string, mixed>}>
+fn run_invalid_data_provider() -> Vec<(InvalidExpectation, Vec<(PhpMixed, PhpMixed)>)> {
+ vec![
+ // invalid name argument
+ (
+ InvalidExpectation::Throws(None),
+ vec![opt("--name", "test")],
+ ),
+ // invalid author argument
+ (
+ InvalidExpectation::Throws(None),
+ vec![
+ opt("--name", "test/pkg"),
+ opt("--author", "Mr. Test <test>"),
+ ],
+ ),
+ // invalid stability argument
+ (
+ InvalidExpectation::StderrMatches(
+ r"minimum-stability\s+:\s+Does not have a value in the enumeration",
+ ),
+ vec![opt("--name", "test/pkg"), opt("--stability", "bogus")],
+ ),
+ // invalid require argument
+ (
+ InvalidExpectation::Throws(Some(
+ "Option first is missing a version constraint, use e.g. first:^1.0",
+ )),
+ vec![opt("--name", "test/pkg"), opt_list("--require", &["first"])],
+ ),
+ // invalid require-dev argument
+ (
+ InvalidExpectation::Throws(Some(
+ "Option first is missing a version constraint, use e.g. first:^1.0",
+ )),
+ vec![
+ opt("--name", "test/pkg"),
+ opt_list("--require-dev", &["first"]),
+ ],
+ ),
+ // invalid homepage argument
+ (
+ InvalidExpectation::StderrMatches(r"homepage\s*:\s*Invalid URL format"),
+ vec![opt("--name", "test/pkg"), opt("--homepage", "not-a-url")],
+ ),
+ ]
}
-#[ignore = "needs TestCase::init_temp_composer and get_application_tester (ApplicationTester) infrastructure, not implemented"]
#[test]
+#[serial]
+#[ignore = "drives InitCommand, which calls get_git_config -> ProcessExecutor::run_process; \
+ that path is not ported (shim is_callable and Process::start/run are todo!())"]
fn test_run_command_invalid() {
set_up();
- todo!()
+ for (expectation, arguments) in run_invalid_data_provider() {
+ let tear_down = init_temp_composer(None, None, None, true);
+ let dir = tear_down.working_dir();
+ std::fs::remove_file(dir.join("composer.json")).unwrap();
+ std::fs::remove_file(dir.join("auth.json")).unwrap();
+
+ let mut app_tester = get_application_tester();
+ let options = RunOptions {
+ capture_stderr_separately: true,
+ ..Default::default()
+ };
+ let result = app_tester.run(non_interactive_input(arguments), options);
+
+ match expectation {
+ InvalidExpectation::Throws(message) => {
+ let error = result.expect_err("expected the command to surface an exception");
+ if let Some(message) = message {
+ let rendered = format!("{:#}", error);
+ assert!(
+ rendered.contains(message),
+ "error {:?} did not contain {:?}",
+ rendered,
+ message
+ );
+ }
+ }
+ InvalidExpectation::StderrMatches(pattern) => {
+ result.unwrap();
+ assert_eq!(1, app_tester.get_status_code());
+ let regex = regex::Regex::new(pattern).unwrap();
+ let stderr = app_tester.get_error_output();
+ assert!(
+ regex.is_match(&stderr),
+ "stderr {:?} did not match {:?}",
+ stderr,
+ pattern
+ );
+ }
+ }
+ }
}
-#[ignore = "needs TestCase::init_temp_composer and get_application_tester (ApplicationTester) infrastructure, not implemented"]
#[test]
+#[serial]
+#[ignore = "drives InitCommand, which calls get_git_config -> ProcessExecutor::run_process; \
+ that path is not ported (shim is_callable and Process::start/run are todo!())"]
fn test_run_guess_name_from_dir_sanitizes_dir() {
set_up();
- todo!()
+ let tear_down = init_temp_composer(None, None, None, true);
+
+ let dir_name = "_foo_--bar__baz.--..qux__";
+ std::fs::create_dir(dir_name).unwrap();
+ std::env::set_current_dir(dir_name).unwrap();
+
+ server_set("COMPOSER_DEFAULT_VENDOR", ".vendorName".to_string());
+
+ let mut app_tester = get_application_tester();
+ let result = app_tester.run(non_interactive_input(vec![]), RunOptions::default());
+
+ server_unset("COMPOSER_DEFAULT_VENDOR");
+ result.unwrap();
+
+ assert_eq!(0, app_tester.get_status_code());
+
+ let expected = serde_json::json!({
+ "name": "vendor-name/foo-bar_baz.qux",
+ "authors": [default_authors()],
+ "require": {},
+ });
+ assert_eq!(
+ expected,
+ read_composer_json(&std::env::current_dir().unwrap())
+ );
+
+ drop(tear_down);
}
-#[ignore = "needs TestCase::init_temp_composer and get_application_tester (ApplicationTester with set_inputs) infrastructure, not implemented"]
#[test]
+#[serial]
+#[ignore = "drives InitCommand, which calls get_git_config -> ProcessExecutor::run_process; \
+ that path is not ported (shim is_callable and Process::start/run are todo!())"]
fn test_interactive_run() {
set_up();
- todo!()
+ let tear_down = init_temp_composer(None, None, None, true);
+ let dir = tear_down.working_dir();
+ std::fs::remove_file(dir.join("composer.json")).unwrap();
+ std::fs::remove_file(dir.join("auth.json")).unwrap();
+
+ let mut app_tester = get_application_tester();
+ app_tester.set_inputs(vec![
+ "vendor/pkg".to_string(), // Pkg name
+ "my description".to_string(), // Description
+ "Mr. Test <test@example.org>".to_string(), // Author
+ "stable".to_string(), // Minimum stability
+ "library".to_string(), // Type
+ "AGPL-3.0-only".to_string(), // License
+ "no".to_string(), // Define dependencies
+ "no".to_string(), // Define dev dependencies
+ "n".to_string(), // Add PSR-4 autoload mapping
+ "".to_string(), // Confirm generation
+ ]);
+
+ app_tester
+ .run(
+ vec![(PhpMixed::from("command"), PhpMixed::from("init"))],
+ RunOptions::default(),
+ )
+ .unwrap();
+
+ assert_eq!(0, app_tester.get_status_code());
+
+ let expected = serde_json::json!({
+ "name": "vendor/pkg",
+ "description": "my description",
+ "type": "library",
+ "license": "AGPL-3.0-only",
+ "authors": [{ "name": "Mr. Test", "email": "test@example.org" }],
+ "minimum-stability": "stable",
+ "require": {},
+ });
+ assert_eq!(expected, read_composer_json(&dir));
}
-#[ignore]
#[test]
fn test_format_authors() {
set_up();
@@ -185,8 +596,9 @@ fn test_format_authors() {
assert_eq!(expected, authors[0]);
}
-#[ignore]
#[test]
+#[ignore = "calls get_git_config -> ProcessExecutor::run_process, which is not ported \
+ (shim is_callable and Process::start/run are todo!())"]
fn test_get_git_config() {
set_up();
@@ -196,7 +608,6 @@ fn test_get_git_config() {
assert!(git_config.contains_key("user.email"));
}
-#[ignore]
#[test]
fn test_add_vendor_ignore() {
set_up();
@@ -212,7 +623,6 @@ fn test_add_vendor_ignore() {
assert!(content.contains("/vendor/"));
}
-#[ignore]
#[test]
fn test_has_vendor_ignore() {
set_up();
diff --git a/crates/shirabe/tests/command/main.rs b/crates/shirabe/tests/command/main.rs
index 287fb27..78345fe 100644
--- a/crates/shirabe/tests/command/main.rs
+++ b/crates/shirabe/tests/command/main.rs
@@ -1,3 +1,6 @@
+#[path = "../common/test_case.rs"]
+mod test_case;
+
mod about_command_test;
mod archive_command_test;
mod audit_command_test;
diff --git a/crates/shirabe/tests/common/test_case.rs b/crates/shirabe/tests/common/test_case.rs
index 71c49a9..4902448 100644
--- a/crates/shirabe/tests/common/test_case.rs
+++ b/crates/shirabe/tests/common/test_case.rs
@@ -4,11 +4,25 @@
//! `#[path = "../common/test_case.rs"] mod test_case;`.
#![allow(dead_code)]
+use shirabe::console::application::ApplicationHandle;
use shirabe::package::handle::{
CompleteAliasPackageHandle, CompletePackageHandle, PackageInterfaceHandle,
};
+use shirabe::util::platform::Platform;
+use shirabe_external_packages::symfony::console::input::array_input::ArrayInput;
+use shirabe_external_packages::symfony::console::input::input_interface::InputInterface;
+use shirabe_external_packages::symfony::console::input::streamable_input_interface::StreamableInputInterface;
+use shirabe_external_packages::symfony::console::output::console_output::ConsoleOutput;
+use shirabe_external_packages::symfony::console::output::console_output_interface::ConsoleOutputInterface;
+use shirabe_external_packages::symfony::console::output::output_interface::OutputInterface;
+use shirabe_external_packages::symfony::console::output::stream_output::StreamOutput;
+use shirabe_php_shim::{PhpMixed, PhpResource};
use shirabe_semver::constraint::{AnyConstraint, SimpleConstraint};
use shirabe_semver::version_parser::VersionParser;
+use std::cell::RefCell;
+use std::path::PathBuf;
+use std::rc::Rc;
+use tempfile::TempDir;
/// ref: TestCase::getPackage (default class CompletePackage)
pub fn get_package(name: &str, version: &str) -> PackageInterfaceHandle {
@@ -35,3 +49,263 @@ pub fn get_version_constraint(operator: &str, version: &str) -> AnyConstraint {
Some(format!("{} {}", operator, version)),
))
}
+
+/// ref: TestCase::initTempComposer plus the running TearDown.
+///
+/// Creates a fresh temp dir, chdir()s into it, points `COMPOSER_HOME` at it, and writes
+/// `composer.json`/`auth.json` (and `composer.lock` when given). The returned guard restores the
+/// previous cwd / `COMPOSER_HOME` and removes the temp tree on drop, mirroring PHPUnit's `tearDown`.
+pub struct TearDown {
+ temp_dir: TempDir,
+ prev_cwd: PathBuf,
+ prev_composer_home: Option<String>,
+}
+
+impl TearDown {
+ /// The temp directory created by `init_temp_composer`. Equivalent to the `$dir` returned by PHP.
+ pub fn working_dir(&self) -> PathBuf {
+ self.temp_dir.path().to_path_buf()
+ }
+}
+
+impl Drop for TearDown {
+ fn drop(&mut self) {
+ // Restore the cwd before the TempDir field is dropped so the tree (possibly the cwd itself)
+ // can be removed cleanly, even on a panicking test.
+ let _ = std::env::set_current_dir(&self.prev_cwd);
+ match &self.prev_composer_home {
+ Some(value) => Platform::put_env("COMPOSER_HOME", value),
+ None => Platform::clear_env("COMPOSER_HOME"),
+ }
+ }
+}
+
+/// ref: TestCase::initTempComposer
+pub fn init_temp_composer(
+ composer_json: Option<&serde_json::Value>,
+ auth_json: Option<&serde_json::Value>,
+ composer_lock: Option<&serde_json::Value>,
+ setup_repositories: bool,
+) -> TearDown {
+ let temp_dir = TempDir::new().unwrap();
+ let dir = temp_dir.path().to_path_buf();
+
+ let prev_cwd = std::env::current_dir().unwrap();
+ let prev_composer_home = Platform::get_env("COMPOSER_HOME");
+
+ Platform::put_env("COMPOSER_HOME", &format!("{}/composer-home", dir.display()));
+ Platform::put_env("COMPOSER_DISABLE_XDEBUG_WARN", "1");
+
+ let mut composer_json = composer_json
+ .cloned()
+ .unwrap_or_else(|| serde_json::json!({}));
+ let auth_json = auth_json.cloned().unwrap_or_else(|| serde_json::json!({}));
+
+ if setup_repositories && let Some(repositories) = composer_json.get("repositories").cloned() {
+ let packagist_false = serde_json::json!({ "packagist.org": false });
+ let already_present = match &repositories {
+ serde_json::Value::Object(map) => map.contains_key("packagist.org"),
+ serde_json::Value::Array(list) => list.iter().any(|r| r == &packagist_false),
+ _ => false,
+ };
+ if !already_present {
+ match composer_json
+ .get_mut("repositories")
+ .and_then(|r| r.as_array_mut())
+ {
+ Some(list) => list.push(packagist_false),
+ None => {
+ if let Some(map) = composer_json
+ .get_mut("repositories")
+ .and_then(|r| r.as_object_mut())
+ {
+ map.insert("packagist.org".to_string(), serde_json::Value::Bool(false));
+ }
+ }
+ }
+ }
+ }
+
+ std::env::set_current_dir(&dir).unwrap();
+ std::fs::write(
+ dir.join("composer.json"),
+ serde_json::to_string_pretty(&composer_json).unwrap(),
+ )
+ .unwrap();
+ std::fs::write(
+ dir.join("auth.json"),
+ serde_json::to_string_pretty(&auth_json).unwrap(),
+ )
+ .unwrap();
+ if let Some(composer_lock) = composer_lock {
+ std::fs::write(
+ dir.join("composer.lock"),
+ serde_json::to_string_pretty(composer_lock).unwrap(),
+ )
+ .unwrap();
+ }
+
+ TearDown {
+ temp_dir,
+ prev_cwd,
+ prev_composer_home,
+ }
+}
+
+/// ref: TestCase::getApplicationTester
+pub fn get_application_tester() -> ApplicationTester {
+ let application = ApplicationHandle::new("Composer".to_string(), "".to_string()).unwrap();
+ application.set_catch_exceptions(false);
+ ApplicationTester::new(application)
+}
+
+/// ref: Symfony\Component\Console\Tester\ApplicationTester::run options.
+///
+/// Lives in the test harness (not `shirabe_external_packages`) because the tester drives shirabe's
+/// own `ApplicationHandle`, which the external-packages crate cannot depend on.
+#[derive(Debug, Default)]
+pub struct RunOptions {
+ pub interactive: Option<bool>,
+ pub decorated: Option<bool>,
+ pub verbosity: Option<i64>,
+ pub capture_stderr_separately: bool,
+}
+
+/// ref: Symfony\Component\Console\Tester\ApplicationTester (with TesterTrait inlined).
+///
+/// The shared `TesterTrait` logic is inlined here; revisit common extraction when `CommandTester`
+/// is ported.
+pub struct ApplicationTester {
+ application: ApplicationHandle,
+ inputs: Vec<String>,
+ status_code: Option<i32>,
+ output: Option<Rc<RefCell<dyn OutputInterface>>>,
+ /// Handles retained before injection so `get_display`/`get_error_output` can read the memory
+ /// streams without relying on a `get_stream()` accessor across the ConsoleOutput composition gap.
+ output_stream: Option<PhpResource>,
+ error_stream: Option<PhpResource>,
+ capture_streams_independently: bool,
+}
+
+impl ApplicationTester {
+ pub fn new(application: ApplicationHandle) -> Self {
+ Self {
+ application,
+ inputs: Vec::new(),
+ status_code: None,
+ output: None,
+ output_stream: None,
+ error_stream: None,
+ capture_streams_independently: false,
+ }
+ }
+
+ pub fn set_inputs(&mut self, inputs: Vec<String>) -> &mut Self {
+ self.inputs = inputs;
+ self
+ }
+
+ pub fn run(
+ &mut self,
+ input: Vec<(PhpMixed, PhpMixed)>,
+ options: RunOptions,
+ ) -> anyhow::Result<i32> {
+ let mut array_input = ArrayInput::new(input, None)?;
+ if let Some(interactive) = options.interactive {
+ array_input.set_interactive(interactive);
+ }
+ if !self.inputs.is_empty() {
+ array_input.set_stream(Self::create_stream(&self.inputs));
+ }
+
+ self.init_output(&options);
+
+ let input: Rc<RefCell<dyn InputInterface>> = Rc::new(RefCell::new(array_input));
+ let output = self.output.clone().expect("init_output initializes output");
+
+ let status_code = self.application.run(Some(input), Some(output))?;
+ self.status_code = Some(status_code);
+
+ Ok(status_code)
+ }
+
+ fn init_output(&mut self, options: &RunOptions) {
+ self.capture_streams_independently = options.capture_stderr_separately;
+
+ if !self.capture_streams_independently {
+ let stream = shirabe_php_shim::php_fopen_resource("php://memory", "w");
+ self.output_stream = Some(stream.clone());
+ self.error_stream = None;
+
+ let output = StreamOutput::new(stream, None, None, None)
+ .unwrap()
+ .expect("php://memory is a valid stream");
+ if let Some(decorated) = options.decorated {
+ output.set_decorated(decorated);
+ }
+ if let Some(verbosity) = options.verbosity {
+ output.set_verbosity(verbosity);
+ }
+ self.output = Some(Rc::new(RefCell::new(output)));
+ } else {
+ let stdout = shirabe_php_shim::php_fopen_resource("php://memory", "w");
+ let stderr = shirabe_php_shim::php_fopen_resource("php://memory", "w");
+ self.output_stream = Some(stdout.clone());
+ self.error_stream = Some(stderr.clone());
+
+ let mut output =
+ ConsoleOutput::new(options.verbosity, options.decorated, None).unwrap();
+
+ let error_output = StreamOutput::new(stderr, None, None, None)
+ .unwrap()
+ .expect("php://memory is a valid stream");
+ error_output.set_formatter(output.get_formatter());
+ error_output.set_verbosity(output.get_verbosity());
+ error_output.set_decorated(output.is_decorated());
+
+ output.set_error_output(Rc::new(RefCell::new(error_output)));
+ output.__set_stream(stdout);
+
+ self.output = Some(Rc::new(RefCell::new(output)));
+ }
+ }
+
+ fn create_stream(inputs: &[String]) -> PhpResource {
+ let stream = shirabe_php_shim::php_fopen_resource("php://memory", "r+");
+ for input in inputs {
+ shirabe_php_shim::fwrite_resource(
+ &stream,
+ &format!("{}{}", input, shirabe_php_shim::PHP_EOL),
+ );
+ }
+ shirabe_php_shim::rewind(&stream);
+ stream
+ }
+
+ pub fn get_status_code(&self) -> i32 {
+ self.status_code
+ .expect("status code not initialized; did you run() before requesting it?")
+ }
+
+ pub fn get_display(&self) -> String {
+ let stream = self
+ .output_stream
+ .as_ref()
+ .expect("output not initialized; did you run() before requesting the display?");
+ shirabe_php_shim::rewind(stream);
+ shirabe_php_shim::stream_get_contents(stream).unwrap_or_default()
+ }
+
+ pub fn get_error_output(&self) -> String {
+ assert!(
+ self.capture_streams_independently,
+ "The error output is not available when the tester is run without \"capture_stderr_separately\" option set."
+ );
+ let stream = self
+ .error_stream
+ .as_ref()
+ .expect("error output not initialized; did you run() before requesting it?");
+ shirabe_php_shim::rewind(stream);
+ shirabe_php_shim::stream_get_contents(stream).unwrap_or_default()
+ }
+}