diff --git a/README.md b/README.md index 817f44a..b581cb3 100644 --- a/README.md +++ b/README.md @@ -1,626 +1,656 @@ -# WebFiori CLI -Class library that can help in writing command line based applications with minimum dependencies using PHP. - -

- - - - - - - - - - - - - - - -

- -## Content -* [Supported PHP Versions](#supported-php-versions) -* [Features](#features) -* [Quick Start](#quick-start) -* [Sample Application](#sample-application) -* [Installation](#installation) -* [Basic Usage](#basic-usage) - * [Simple Command Example](#simple-command-example) - * [Command with Arguments](#command-with-arguments) - * [Multi-Command Application](#multi-command-application) -* [Creating and Running Commands](#creating-and-running-commands) - * [Creating a Command](#creating-a-command) - * [Running a Command](#running-a-command) - * [Arguments](#arguments) - * [Adding Arguments to Commands](#adding-arguments-to-commands) - * [Accessing Argument Value](#accessing-argument-value) -* [Advanced Features](#advanced-features) - * [Interactive Mode](#interactive-mode) - * [Input and Output Streams](#input-and-output-streams) - * [ANSI Colors and Formatting](#ansi-colors-and-formatting) - * [Progress Bars](#progress-bars) - * [Table Display](#table-display) -* [The `help` Command](#the-help-command) - * [Setting Help Instructions](#setting-help-instructions) - * [Running `help` Command](#running-help-command) - * [General Help](#general-help) - * [Command-Specific Help](#command-specific-help) -* [Unit-Testing Commands](#unit-testing-commands) -* [Examples](#examples) - -## Supported PHP Versions -| Build Status | -|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| -| | -| | -| | -| | -| | - -## Features -* **Easy Command Creation**: Simple class-based approach to building CLI commands -* **Argument Handling**: Support for required and optional arguments with validation -* **Interactive Mode**: Keep your application running and execute multiple commands -* **ANSI Output**: Rich text formatting with colors and styles -* **Input/Output Streams**: Custom input and output stream implementations -* **Progress Bars**: Built-in progress indicators for long-running operations -* **Table Display**: Format and display data in clean, readable tables -* **Help System**: Automatic help generation for commands and arguments -* **Unit Testing**: Built-in testing utilities for command validation -* **Minimal Dependencies**: Lightweight library with minimal external requirements - -## Quick Start - -Get up and running in minutes: - -```bash -# Install via Composer -composer require webfiori/cli - -# Create your first command -php -r " -require 'vendor/autoload.php'; -use WebFiori\Cli\Command; -use WebFiori\Cli\Runner; - -class HelloCommand extends Command { - public function __construct() { - parent::__construct('hello', [], 'Say hello to the world'); - } - public function exec(): int { - \$this->println('Hello, World!'); - return 0; - } -} - -\$runner = new Runner(); -\$runner->register(new HelloCommand()); -exit(\$runner->start()); -" hello -``` - -## Sample Application - -A complete sample application with multiple examples can be found here: **[📁 View Sample Application](https://github.com/WebFiori/cli/tree/main/examples)** - -The sample application includes: -- **[Basic Commands](https://github.com/WebFiori/cli/tree/main/examples/01-basic-hello-world)** - Simple command creation -- **[Arguments Handling](https://github.com/WebFiori/cli/tree/main/examples/02-arguments-and-options)** - Working with command arguments -- **[User Input](https://github.com/WebFiori/cli/tree/main/examples/03-user-input)** - Building interactive applications -- **[Multi-Command Apps](https://github.com/WebFiori/cli/tree/main/examples/10-multi-command-app)** - Complex applications with multiple commands -- **[Progress Bars](https://github.com/WebFiori/cli/tree/main/examples/07-progress-bars)** - Visual progress indicators -- **[Table Display](https://github.com/WebFiori/cli/tree/main/examples/06-table-display)** - Formatting data in tables -- **[Database Operations](https://github.com/WebFiori/cli/tree/main/examples/09-database-ops)** - Database CLI commands - -## Installation - -Install WebFiori CLI using Composer: - -```bash -composer require webfiori/cli -``` - -Or add it to your `composer.json`: - -```json -{ - "require": { - "webfiori/cli": "*" - } -} -``` - -## Basic Usage - -### Simple Command Example - -Create a basic command that outputs a message: - -```php -println("Hello from WebFiori CLI!"); - return 0; - } -} - -$runner = new Runner(); -$runner->register(new GreetCommand()); -exit($runner->start()); -``` - -**Usage:** -```bash -php app.php greet -# Output: Hello from WebFiori CLI! -``` - -**[📖 View Complete Example](https://github.com/WebFiori/cli/tree/main/examples/01-basic-hello-world)** - -### Command with Arguments - -Create a command that accepts and processes arguments: - -```php - [ - Option::OPTIONAL => false, - Option::DESCRIPTION => 'Name of the person to greet' - ], - '--title' => [ - Option::OPTIONAL => true, - Option::DEFAULT => 'Friend', - Option::DESCRIPTION => 'Title to use (Mr, Ms, Dr, etc.)' - ] - ], 'Greet a specific person'); - } - - public function exec(): int { - $name = $this->getArgValue('--name'); - $title = $this->getArgValue('--title'); - - $this->println("Hello %s %s!", $title, $name); - return 0; - } -} -``` - -**Usage:** -```bash -php app.php greet-person --name=John --title=Mr -# Output: Hello Mr John! - -php app.php greet-person --name=Sarah -# Output: Hello Friend Sarah! -``` - -**[📖 View Complete Example](https://github.com/WebFiori/cli/tree/main/examples/02-arguments-and-options)** - -### Multi-Command Application - -Build applications with multiple commands: - -```php -register(new GreetCommand()); -$runner->register(new PersonalGreetCommand()); -$runner->register(new FileProcessCommand()); -$runner->register(new DatabaseCommand()); - -// Set application info -$runner->setAppName('My CLI App'); -$runner->setAppVersion('1.0.0'); - -exit($runner->start()); -``` - -**Usage:** -```bash -php app.php help # Show all available commands -php app.php greet # Run greet command -php app.php greet-person --name=Bob # Run greet-person command -php app.php -i # Start interactive mode -``` - -**[📖 View Complete Example](https://github.com/WebFiori/cli/tree/main/examples/10-multi-command-app)** - -## Creating and Running Commands - -### Creating a Command - -First step in creating new command is to create a new class that extends the class `WebFiori\Cli\Command`. The class `Command` is a utility class which has methods that can be used to read inputs, send outputs and use command line arguments. - -The class has one abstract method that must be implemented. The code that will exist in the body of the method will represent the logic of the command. - -``` php -println("Hi People!"); - return 0; - } - -} - -``` - -### Running a Command - -The class `WebFiori\Cli\Runner` is the class which is used to manage the logic of executing the commands. In order to run a command, an instance of this class must be created and used to register the command and start running the application. - -To register a command, the method `Runner::register()` is used. To start the application, the method `Runner::start()` is used. - -``` php -// File src/main.php -require_once '../vendor/autoload.php'; - -use WebFiori\Cli\Runner; -use SampleCommand; - - -$runner = new Runner(); -$runner->register(new SampleCommand()); -exit($runner->start()); -``` - -Now if terminal is opened and following command is executed: - -``` bash -php main.php say-hi -``` - -The output will be the string `Hi People!`. - -### Arguments - -Arguments is a way that can be used to pass values from the terminal to PHP process. They can be used to configure execution of the command. For example, a command might require some kind of file as input. - -#### Adding Arguments to Commands - -Arguments can be added in the constructor of the class as follows: - -``` php - [ - Option::OPTIONAL => true - ] - ]); - } - - public function exec(): int { - $this->println("Hi People!"); - return 0; - } - -} - -``` - -Arguments can be provided as an associative array or array of objects of type `WebFiori\Cli\Argument`. In case of associative array, Index is name of the argument and the value of the index is sub-associative array of options. Each argument can have the following options: -* `optional`: A boolean. if set to true, it means that the argument is optional. Default is false. -* `default`: An optional default value for the argument to use if it is not provided. -* `description`: A description of the argument which will be shown if the command `help` is executed. -* `values`: A set of values that the argument can have. If provided, only the values on the list will be allowed. - -The class `WebFiori\Cli\Option` can be used to access the options. - -#### Accessing Argument Value - -Accessing the value of an argument is performed using the method `Command::getArgValue(string $argName)`. If argument is provided, the method will return its value as `string`. If not provided, `null` is returned. - -``` php - [ - Option::OPTIONAL => true - ] - ]); - } - - public function exec(): int { - $personName = $this->getArgValue('--person-name'); - - if ($personName !== null) { - $this->println("Hi %s!", $personName); - } else { - $this->println("Hi People!"); - } - - return 0; - } - -} - -``` - -## Advanced Features - -### Interactive Mode - -Interactive mode is a way that can be used to keep your application running and execute more than one command using same PHP process. To start the application in interactive mode, add the argument `-i` when starting the application as follows: - -``` bash -php main.php -i -``` - -This will show following output in terminal: - -``` bash ->> Running in interactive mode. ->> Type command name or 'exit' to close. ->> -``` - -**[📖 View Interactive Mode Example](https://github.com/WebFiori/cli/tree/main/examples/05-interactive-commands)** - -### Input and Output Streams - -WebFiori CLI supports custom input and output streams for advanced use cases: - -```php -use WebFiori\Cli\Streams\FileInputStream; -use WebFiori\Cli\Streams\FileOutputStream; - -// Read from file instead of stdin -$command->setInputStream(new FileInputStream('input.txt')); - -// Write to file instead of stdout -$command->setOutputStream(new FileOutputStream('output.txt')); -``` - -**[📖 View Streams Example](https://github.com/WebFiori/cli/tree/main/examples/08-file-processing)** - -### ANSI Colors and Formatting - -Add colors and formatting to your CLI output: - -```php -public function exec(): int { - $this->println("This is %s text", 'normal'); - $this->println("This is {{bold}}bold{{/bold}} text"); - $this->println("This is {{red}}red{{/red}} text"); - $this->println("This is {{bg-blue}}{{white}}white on blue{{/white}}{{/bg-blue}} text"); - return 0; -} -``` - -**[📖 View Formatting Example](https://github.com/WebFiori/cli/tree/main/examples/04-output-formatting)** - -### Progress Bars - -Display progress for long-running operations: - -```php -use WebFiori\Cli\Progress\ProgressBar; - -public function exec(): int { - $items = range(1, 100); - - $this->withProgressBar($items, function($item, $bar) { - // Process each item - usleep(50000); // Simulate work - $bar->setMessage("Processing item {$item}"); - }); - - return 0; -} -``` - -**[📖 View Progress Bar Example](https://github.com/WebFiori/cli/tree/main/examples/07-progress-bars)** - -### Table Display - -Display data in formatted tables: - -```php -public function exec(): int { - $data = [ - ['Ahmed Hassan', 30, 'Cairo'], - ['Sarah Johnson', 25, 'Los Angeles'] - ]; - $headers = ['Name', 'Age', 'City']; - - $this->table($data, $headers); - - return 0; -} -``` - -**[📖 View Table Display Example](https://github.com/WebFiori/cli/tree/main/examples/06-table-display)** - -## The `help` Command -One of the commands which comes by default with the library is the `help` command. It can be used to display help instructions for all registered commands. - -> Note: In order to use this command, it must be registered using the method `Runner::register()`. - -### Setting Help Instructions - -Help instructions are provided by the developer who created the command during its implementation. Instructions can be set on the constructor of the class that extends the class `WebFiori\Cli\Command` as a description. The description can be set for the command and its arguments. - -``` php - [ - Option::DESCRIPTION => 'Name of someone to greet.', - Option::OPTIONAL => true - ] - ], 'A command to show greetings.'); - } - - public function exec(): int { - $name = $this->getArgValue('--person-name'); - - if ($name === null) { - $this->println("Hello World!"); - } else { - $this->println("Hello %s!", $name); - } - - return 0; - } -} - -``` - -### Running `help` Command - -Help command can be used in two ways, one way is to display a general help for the application and another one for specific command. - -#### General Help - -To show general help of the application, following command can be executed. - -``` bash -php main.php help -``` - -Output of this command will be as follows: - -``` -Usage: - command [arg1 arg2="val" arg3...] - -Global Arguments: - --ansi:[Optional] Force the use of ANSI output. -Available Commands: - help: Display CLI Help. To display help for specific command, use the argument "--command-name" with this command. - hello: A command to show greetings. - open-file: Reads a text file and display its content. - -``` - -> Note: Depending on registered commands, output may differ. - -#### Command-Specific Help - -To show help instructions for a specific command, the name of the command can be included using the argument `--command-name` as follows: - -``` bash -php main.php help --command-name=hello -``` - -Output of this command will be as follows: - -``` -hello: A command to show greetings. - Supported Arguments: - --person-name:[Optional] Name of someone to greet. -``` - -## Unit-Testing Commands - -The library provides the helper class `WebFiori\Cli\CommandTestCase` which can be used to write unit tests for different commands. The developer has to only extend the class and use utility methods to write tests. The class is based on PHPUnit. - -The class has two methods which can be used to execute tests: - -* `CommandTestCase::executeSingleCommand()`: Used to run one command at a time and return its output. -* `CommandTestCase::executeMultiCommand()`: Used to register multiple commands, set default command and/or run one of registered commands. - -First method is good to verify the output of one specific command. The second one is useful to simulate the execution of an application with multiple commands. - -Both methods support simulating arguments vector and user inputs. - - -``` php -namespace tests\cli; - -use WebFiori\Cli\CommandTestCase; - -class HelloCommandTest extends CommandTestCase { - /** - * @test - */ - public function test00() { - - //Verify test results - - $this->assertEquals([ - "Hello World!\n" - ], $this->executeSingleCommand(new HelloWorldCommand())); - $this->assertEquals(0, $this->getExitCode()); - } -} - -``` - -**[📖 View Testing Examples](https://github.com/WebFiori/cli/tree/main/examples/10-multi-command-app)** - -## Examples - -Explore comprehensive examples to learn different aspects of WebFiori CLI: - -### Basic Examples -- **[📁 Basic Command](https://github.com/WebFiori/cli/tree/main/examples/01-basic-hello-world)** - Create your first CLI command -- **[📁 Command with Arguments](https://github.com/WebFiori/cli/tree/main/examples/02-arguments-and-options)** - Handle command-line arguments -- **[📁 User Input](https://github.com/WebFiori/cli/tree/main/examples/03-user-input)** - Read and validate user input -- **[📁 Output Formatting](https://github.com/WebFiori/cli/tree/main/examples/04-output-formatting)** - Colors and text formatting - -### Advanced Examples -- **[📁 Interactive Commands](https://github.com/WebFiori/cli/tree/main/examples/05-interactive-commands)** - Build interactive CLI applications -- **[📁 Table Display](https://github.com/WebFiori/cli/tree/main/examples/06-table-display)** - Format data in tables -- **[📁 Progress Bars](https://github.com/WebFiori/cli/tree/main/examples/07-progress-bars)** - Visual progress indicators -- **[📁 File Processing](https://github.com/WebFiori/cli/tree/main/examples/08-file-processing)** - File manipulation commands -- **[📁 Database Operations](https://github.com/WebFiori/cli/tree/main/examples/09-database-ops)** - Database CLI commands - -### Complete Applications -- **[📁 Multi-Command Application](https://github.com/WebFiori/cli/tree/main/examples/10-multi-command-app)** - Full-featured CLI application - -### Quick Links -- **[📖 All Examples](https://github.com/WebFiori/cli/tree/main/examples)** - Browse all available examples -- **[🚀 Sample App](https://github.com/WebFiori/cli/tree/main/examples/10-multi-command-app/main.php)** - Ready-to-run sample application - - ---- - -**Ready to build amazing CLI applications? Start with the [📁 Basic Command Example](https://github.com/WebFiori/cli/tree/main/examples/01-basic-hello-world) and work your way up!** +# WebFiori CLI +Class library that can help in writing command line based applications with minimum dependencies using PHP. + +

+ + + + + + + + + + + + + + + +

+ +## Content +* [Supported PHP Versions](#supported-php-versions) +* [Features](#features) +* [Quick Start](#quick-start) +* [Sample Application](#sample-application) +* [Installation](#installation) +* [Basic Usage](#basic-usage) + * [Simple Command Example](#simple-command-example) + * [Command with Arguments](#command-with-arguments) + * [Multi-Command Application](#multi-command-application) +* [Creating and Running Commands](#creating-and-running-commands) + * [Creating a Command](#creating-a-command) + * [Running a Command](#running-a-command) + * [Arguments](#arguments) + * [Adding Arguments to Commands](#adding-arguments-to-commands) + * [Accessing Argument Value](#accessing-argument-value) +* [Advanced Features](#advanced-features) + * [Interactive Mode](#interactive-mode) + * [Input and Output Streams](#input-and-output-streams) + * [ANSI Colors and Formatting](#ansi-colors-and-formatting) + * [Progress Bars](#progress-bars) + * [Table Display](#table-display) +* [The `help` Command](#the-help-command) + * [Setting Help Instructions](#setting-help-instructions) + * [Running `help` Command](#running-help-command) + * [General Help](#general-help) + * [Command-Specific Help](#command-specific-help) +* [Unit-Testing Commands](#unit-testing-commands) +* [Examples](#examples) + +## Supported PHP Versions +| Build Status | +|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| +| | +| | +| | +| | +| | + +## Features +* **Easy Command Creation**: Simple class-based approach to building CLI commands +* **Argument Handling**: Support for required and optional arguments with validation +* **Interactive Mode**: Keep your application running and execute multiple commands +* **ANSI Output**: Rich text formatting with colors and styles +* **Input/Output Streams**: Custom input and output stream implementations +* **Progress Bars**: Built-in progress indicators for long-running operations +* **Table Display**: Format and display data in clean, readable tables +* **Help System**: Automatic help generation for commands and arguments +* **Unit Testing**: Built-in testing utilities for command validation +* **Minimal Dependencies**: Lightweight library with minimal external requirements + +## Quick Start + +Get up and running in minutes: + +```bash +# Install via Composer +composer require webfiori/cli + +# Create your first command +php -r " +require 'vendor/autoload.php'; +use WebFiori\Cli\Command; +use WebFiori\Cli\Runner; + +class HelloCommand extends Command { + public function __construct() { + parent::__construct('hello', [], 'Say hello to the world'); + } + public function exec(): int { + \$this->println('Hello, World!'); + return 0; + } +} + +\$runner = new Runner(); +\$runner->register(new HelloCommand()); +exit(\$runner->start()); +" hello +``` + +## Sample Application + +A complete sample application with multiple examples can be found here: **[📁 View Sample Application](https://github.com/WebFiori/cli/tree/main/examples)** + +The sample application includes: +- **[Basic Commands](https://github.com/WebFiori/cli/tree/main/examples/01-basic-hello-world)** - Simple command creation +- **[Arguments Handling](https://github.com/WebFiori/cli/tree/main/examples/02-arguments-and-options)** - Working with command arguments +- **[User Input](https://github.com/WebFiori/cli/tree/main/examples/03-user-input)** - Building interactive applications +- **[Multi-Command Apps](https://github.com/WebFiori/cli/tree/main/examples/10-multi-command-app)** - Complex applications with multiple commands +- **[Progress Bars](https://github.com/WebFiori/cli/tree/main/examples/07-progress-bars)** - Visual progress indicators +- **[Table Display](https://github.com/WebFiori/cli/tree/main/examples/06-table-display)** - Formatting data in tables +- **[Database Operations](https://github.com/WebFiori/cli/tree/main/examples/09-database-ops)** - Database CLI commands + +## Installation + +Install WebFiori CLI using Composer: + +```bash +composer require webfiori/cli +``` + +Or add it to your `composer.json`: + +```json +{ + "require": { + "webfiori/cli": "*" + } +} +``` + +## Basic Usage + +### Simple Command Example + +Create a basic command that outputs a message: + +```php +println("Hello from WebFiori CLI!"); + return 0; + } +} + +$runner = new Runner(); +$runner->register(new GreetCommand()); +exit($runner->start()); +``` + +**Usage:** +```bash +php app.php greet +# Output: Hello from WebFiori CLI! +``` + +**[📖 View Complete Example](https://github.com/WebFiori/cli/tree/main/examples/01-basic-hello-world)** + +### Command with Arguments + +Create a command that accepts and processes arguments: + +```php + [ + Option::OPTIONAL => false, + Option::DESCRIPTION => 'Name of the person to greet' + ], + '--title' => [ + Option::OPTIONAL => true, + Option::DEFAULT => 'Friend', + Option::DESCRIPTION => 'Title to use (Mr, Ms, Dr, etc.)' + ] + ], 'Greet a specific person'); + } + + public function exec(): int { + $name = $this->getArgValue('--name'); + $title = $this->getArgValue('--title'); + + $this->println("Hello %s %s!", $title, $name); + return 0; + } +} +``` + +**Usage:** +```bash +php app.php greet-person --name=John --title=Mr +# Output: Hello Mr John! + +php app.php greet-person --name=Sarah +# Output: Hello Friend Sarah! +``` + +**[📖 View Complete Example](https://github.com/WebFiori/cli/tree/main/examples/02-arguments-and-options)** + +### Multi-Command Application + +Build applications with multiple commands: + +```php +register(new GreetCommand()); +$runner->register(new PersonalGreetCommand()); +$runner->register(new FileProcessCommand()); +$runner->register(new DatabaseCommand()); + +// Set application info +$runner->setAppName('My CLI App'); +$runner->setAppVersion('1.0.0'); + +exit($runner->start()); +``` + +**Usage:** +```bash +php app.php help # Show all available commands +php app.php greet # Run greet command +php app.php greet-person --name=Bob # Run greet-person command +php app.php -i # Start interactive mode +``` + +**[📖 View Complete Example](https://github.com/WebFiori/cli/tree/main/examples/10-multi-command-app)** + +## Creating and Running Commands + +### Creating a Command + +First step in creating new command is to create a new class that extends the class `WebFiori\Cli\Command`. The class `Command` is a utility class which has methods that can be used to read inputs, send outputs and use command line arguments. + +The class has one abstract method that must be implemented. The code that will exist in the body of the method will represent the logic of the command. + +``` php +println("Hi People!"); + return 0; + } + +} + +``` + +### Running a Command + +The class `WebFiori\Cli\Runner` is the class which is used to manage the logic of executing the commands. In order to run a command, an instance of this class must be created and used to register the command and start running the application. + +To register a command, the method `Runner::register()` is used. To start the application, the method `Runner::start()` is used. + +``` php +// File src/main.php +require_once '../vendor/autoload.php'; + +use WebFiori\Cli\Runner; +use SampleCommand; + + +$runner = new Runner(); +$runner->register(new SampleCommand()); +exit($runner->start()); +``` + +Now if terminal is opened and following command is executed: + +``` bash +php main.php say-hi +``` + +The output will be the string `Hi People!`. + +### Arguments + +Arguments is a way that can be used to pass values from the terminal to PHP process. They can be used to configure execution of the command. For example, a command might require some kind of file as input. + +#### Adding Arguments to Commands + +Arguments can be added in the constructor of the class as follows: + +``` php + [ + Option::OPTIONAL => true + ] + ]); + } + + public function exec(): int { + $this->println("Hi People!"); + return 0; + } + +} + +``` + +Arguments can be provided as an associative array or array of objects of type `WebFiori\Cli\Argument`. In case of associative array, Index is name of the argument and the value of the index is sub-associative array of options. Each argument can have the following options: +* `optional`: A boolean. if set to true, it means that the argument is optional. Default is false. +* `default`: An optional default value for the argument to use if it is not provided. +* `description`: A description of the argument which will be shown if the command `help` is executed. +* `values`: A set of values that the argument can have. If provided, only the values on the list will be allowed. + +The class `WebFiori\Cli\Option` can be used to access the options. + +#### Accessing Argument Value + +Accessing the value of an argument is performed using the method `Command::getArgValue(string $argName)`. If argument is provided, the method will return its value as `string`. If not provided, `null` is returned. + +``` php + [ + Option::OPTIONAL => true + ] + ]); + } + + public function exec(): int { + $personName = $this->getArgValue('--person-name'); + + if ($personName !== null) { + $this->println("Hi %s!", $personName); + } else { + $this->println("Hi People!"); + } + + return 0; + } + +} + +``` + +## Advanced Features + +### Interactive Mode + +Interactive mode is a way that can be used to keep your application running and execute more than one command using same PHP process. To start the application in interactive mode, add the argument `-i` when starting the application as follows: + +``` bash +php main.php -i +``` + +This will show following output in terminal: + +``` bash +>> Running in interactive mode. +>> Type command name or 'exit' to close. +>> +``` + +**[📖 View Interactive Mode Example](https://github.com/WebFiori/cli/tree/main/examples/05-interactive-commands)** + +### Input and Output Streams + +WebFiori CLI supports custom input and output streams for advanced use cases: + +```php +use WebFiori\Cli\Streams\FileInputStream; +use WebFiori\Cli\Streams\FileOutputStream; + +// Read from file instead of stdin +$command->setInputStream(new FileInputStream('input.txt')); + +// Write to file instead of stdout +$command->setOutputStream(new FileOutputStream('output.txt')); +``` + +**[📖 View Streams Example](https://github.com/WebFiori/cli/tree/main/examples/08-file-processing)** + +### ANSI Colors and Formatting + +Add colors and formatting to your CLI output: + +```php +public function exec(): int { + $this->println("This is %s text", 'normal'); + $this->println("This is {{bold}}bold{{/bold}} text"); + $this->println("This is {{red}}red{{/red}} text"); + $this->println("This is {{bg-blue}}{{white}}white on blue{{/white}}{{/bg-blue}} text"); + return 0; +} +``` + +**[📖 View Formatting Example](https://github.com/WebFiori/cli/tree/main/examples/04-output-formatting)** + +### Progress Bars + +Display progress for long-running operations: + +```php +use WebFiori\Cli\Progress\ProgressBar; + +public function exec(): int { + $items = range(1, 100); + + $this->withProgressBar($items, function($item, $bar) { + // Process each item + usleep(50000); // Simulate work + $bar->setMessage("Processing item {$item}"); + }); + + return 0; +} +``` + +**[📖 View Progress Bar Example](https://github.com/WebFiori/cli/tree/main/examples/07-progress-bars)** + +### Table Display + +Display data in formatted tables: + +```php +public function exec(): int { + $data = [ + ['Ahmed Hassan', 30, 'Cairo'], + ['Sarah Johnson', 25, 'Los Angeles'] + ]; + $headers = ['Name', 'Age', 'City']; + + $this->table($data, $headers); + + return 0; +} +``` + +**[📖 View Table Display Example](https://github.com/WebFiori/cli/tree/main/examples/06-table-display)** + +### Auto-Discovery + +Automatically discover and register commands from a directory: + +```php +$runner = new Runner(); +$runner->addDiscoveryPath(__DIR__ . '/commands'); +$runner->discoverCommands(); +``` + +#### Factory for Dependency Injection + +By default, auto-discovery instantiates commands using `new $className()`. If your commands require constructor dependencies, set a factory callable: + +```php +use WebFiori\Cli\Discovery\CommandDiscovery; +use WebFiori\Cli\Discovery\CommandCache; + +$discovery = new CommandDiscovery(new CommandCache(enabled: false)); +$discovery->addSearchPath(__DIR__ . '/commands'); +$discovery->setFactory(fn($class) => $container->get($class)); + +$runner->setCommandDiscovery($discovery); +$runner->discoverCommands(); +``` + +The factory receives the fully qualified class name and must return a `Command` instance. This works with any DI container or a simple closure. + +**[📖 View Auto-Discovery Example](https://github.com/WebFiori/cli/tree/main/examples/13-auto-discovery)** + +## The `help` Command +One of the commands which comes by default with the library is the `help` command. It can be used to display help instructions for all registered commands. + +> Note: In order to use this command, it must be registered using the method `Runner::register()`. + +### Setting Help Instructions + +Help instructions are provided by the developer who created the command during its implementation. Instructions can be set on the constructor of the class that extends the class `WebFiori\Cli\Command` as a description. The description can be set for the command and its arguments. + +``` php + [ + Option::DESCRIPTION => 'Name of someone to greet.', + Option::OPTIONAL => true + ] + ], 'A command to show greetings.'); + } + + public function exec(): int { + $name = $this->getArgValue('--person-name'); + + if ($name === null) { + $this->println("Hello World!"); + } else { + $this->println("Hello %s!", $name); + } + + return 0; + } +} + +``` + +### Running `help` Command + +Help command can be used in two ways, one way is to display a general help for the application and another one for specific command. + +#### General Help + +To show general help of the application, following command can be executed. + +``` bash +php main.php help +``` + +Output of this command will be as follows: + +``` +Usage: + command [arg1 arg2="val" arg3...] + +Global Arguments: + --ansi:[Optional] Force the use of ANSI output. +Available Commands: + help: Display CLI Help. To display help for specific command, use the argument "--command-name" with this command. + hello: A command to show greetings. + open-file: Reads a text file and display its content. + +``` + +> Note: Depending on registered commands, output may differ. + +#### Command-Specific Help + +To show help instructions for a specific command, the name of the command can be included using the argument `--command-name` as follows: + +``` bash +php main.php help --command-name=hello +``` + +Output of this command will be as follows: + +``` +hello: A command to show greetings. + Supported Arguments: + --person-name:[Optional] Name of someone to greet. +``` + +## Unit-Testing Commands + +The library provides the helper class `WebFiori\Cli\CommandTestCase` which can be used to write unit tests for different commands. The developer has to only extend the class and use utility methods to write tests. The class is based on PHPUnit. + +The class has two methods which can be used to execute tests: + +* `CommandTestCase::executeSingleCommand()`: Used to run one command at a time and return its output. +* `CommandTestCase::executeMultiCommand()`: Used to register multiple commands, set default command and/or run one of registered commands. + +First method is good to verify the output of one specific command. The second one is useful to simulate the execution of an application with multiple commands. + +Both methods support simulating arguments vector and user inputs. + + +``` php +namespace tests\cli; + +use WebFiori\Cli\CommandTestCase; + +class HelloCommandTest extends CommandTestCase { + /** + * @test + */ + public function test00() { + + //Verify test results + + $this->assertEquals([ + "Hello World!\n" + ], $this->executeSingleCommand(new HelloWorldCommand())); + $this->assertEquals(0, $this->getExitCode()); + } +} + +``` + +**[📖 View Testing Examples](https://github.com/WebFiori/cli/tree/main/examples/10-multi-command-app)** + +## Examples + +Explore comprehensive examples to learn different aspects of WebFiori CLI: + +### Basic Examples +- **[📁 Basic Command](https://github.com/WebFiori/cli/tree/main/examples/01-basic-hello-world)** - Create your first CLI command +- **[📁 Command with Arguments](https://github.com/WebFiori/cli/tree/main/examples/02-arguments-and-options)** - Handle command-line arguments +- **[📁 User Input](https://github.com/WebFiori/cli/tree/main/examples/03-user-input)** - Read and validate user input +- **[📁 Output Formatting](https://github.com/WebFiori/cli/tree/main/examples/04-output-formatting)** - Colors and text formatting + +### Advanced Examples +- **[📁 Interactive Commands](https://github.com/WebFiori/cli/tree/main/examples/05-interactive-commands)** - Build interactive CLI applications +- **[📁 Table Display](https://github.com/WebFiori/cli/tree/main/examples/06-table-display)** - Format data in tables +- **[📁 Progress Bars](https://github.com/WebFiori/cli/tree/main/examples/07-progress-bars)** - Visual progress indicators +- **[📁 File Processing](https://github.com/WebFiori/cli/tree/main/examples/08-file-processing)** - File manipulation commands +- **[📁 Database Operations](https://github.com/WebFiori/cli/tree/main/examples/09-database-ops)** - Database CLI commands + +### Complete Applications +- **[📁 Multi-Command Application](https://github.com/WebFiori/cli/tree/main/examples/10-multi-command-app)** - Full-featured CLI application + +### Quick Links +- **[📖 All Examples](https://github.com/WebFiori/cli/tree/main/examples)** - Browse all available examples +- **[🚀 Sample App](https://github.com/WebFiori/cli/tree/main/examples/10-multi-command-app/main.php)** - Ready-to-run sample application + + +--- + +**Ready to build amazing CLI applications? Start with the [📁 Basic Command Example](https://github.com/WebFiori/cli/tree/main/examples/01-basic-hello-world) and work your way up!** diff --git a/WebFiori/Cli/Discovery/CommandDiscovery.php b/WebFiori/Cli/Discovery/CommandDiscovery.php index 40be55f..6c1e1d1 100644 --- a/WebFiori/Cli/Discovery/CommandDiscovery.php +++ b/WebFiori/Cli/Discovery/CommandDiscovery.php @@ -1,315 +1,357 @@ -cache = $cache ?? new CommandCache(); - } - - /** - * Add a directory path to search for commands. - * - * @param string $path Directory path to search - * @return self - */ - public function addSearchPath(string $path): self { - $realPath = realpath($path); - - if ($realPath === false) { - throw new CommandDiscoveryException("Search path does not exist: {$path}"); - } - - if (!in_array($realPath, $this->searchPaths)) { - $this->searchPaths[] = $realPath; - } - - return $this; - } - - /** - * Add multiple search paths. - * - * @param array $paths Array of directory paths - * @return self - */ - public function addSearchPaths(array $paths): self { - foreach ($paths as $path) { - $this->addSearchPath($path); - } - - return $this; - } - - /** - * Discover commands from configured search paths. - * - * @return array Array of Command instances - * @throws CommandDiscoveryException If strict mode is enabled and errors occur - */ - public function discover(): array { - $this->errors = []; - - // Try to get from cache first - $cachedCommands = $this->cache->get(); - - if ($cachedCommands !== null) { - return $this->instantiateCommands($cachedCommands); - } - - // Discover commands - $commandMetadata = []; - $scannedFiles = []; - - foreach ($this->searchPaths as $path) { - $files = $this->scanDirectory($path); - $scannedFiles = array_merge($scannedFiles, $files); - - foreach ($files as $file) { - try { - $className = $this->extractClassName($file); - - if ($className && $this->isValidCommand($className)) { - $metadata = CommandMetadata::extract($className); - $commandMetadata[] = $metadata; - } - } catch (\Exception $e) { - $this->errors[] = "Failed to process {$file}: ".$e->getMessage(); - } - } - } - - // Handle errors - if (!empty($this->errors) && $this->strictMode) { - throw CommandDiscoveryException::fromErrors($this->errors); - } - - // Cache the results - $this->cache->store($commandMetadata, $scannedFiles); - - return $this->instantiateCommands($commandMetadata); - } - - /** - * Add a pattern to exclude files/directories. - * - * @param string $pattern Glob pattern to exclude - * @return self - */ - public function excludePattern(string $pattern): self { - if (!in_array($pattern, $this->excludePatterns)) { - $this->excludePatterns[] = $pattern; - } - - return $this; - } - - /** - * Add multiple exclude patterns. - * - * @param array $patterns Array of glob patterns - * @return self - */ - public function excludePatterns(array $patterns): self { - foreach ($patterns as $pattern) { - $this->excludePattern($pattern); - } - - return $this; - } - - /** - * Get the cache instance. - * - * @return CommandCache - */ - public function getCache(): CommandCache { - return $this->cache; - } - - /** - * Get discovery errors from last discovery attempt. - * - * @return array - */ - public function getErrors(): array { - return $this->errors; - } - - /** - * Enable or disable strict mode. - * In strict mode, any discovery error will throw an exception. - * - * @param bool $strict - * @return self - */ - public function setStrictMode(bool $strict): self { - $this->strictMode = $strict; - - return $this; - } - - /** - * Extract class name from PHP file. - * - * @param string $filePath - * @return string|null - */ - private function extractClassName(string $filePath): ?string { - $content = file_get_contents($filePath); - - if ($content === false) { - return null; - } - - // Extract namespace - $namespace = null; - - if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) { - $namespace = trim($matches[1]); - } - - // Extract class name - $className = null; - - if (preg_match('/class\s+(\w+)/', $content, $matches)) { - $className = $matches[1]; - } - - if (!$className) { - return null; - } - - return $namespace ? $namespace.'\\'.$className : $className; - } - - /** - * Instantiate commands from metadata. - * - * @param array $commandMetadata - * @return array Array of Command instances - */ - private function instantiateCommands(array $commandMetadata): array { - $commands = []; - - foreach ($commandMetadata as $metadata) { - try { - $className = $metadata['className']; - - if (class_exists($className)) { - // Check if class implements AutoDiscoverable before instantiating - if (is_subclass_of($className, AutoDiscoverable::class)) { - if (!$className::shouldAutoRegister()) { - continue; // Skip this command - } - } - - $commands[] = new $className(); - } - } catch (\Exception $e) { - $this->errors[] = "Failed to instantiate {$metadata['className']}: ".$e->getMessage(); - - if ($this->strictMode) { - throw new CommandDiscoveryException("Failed to instantiate {$metadata['className']}: ".$e->getMessage()); - } - } - } - - return $commands; - } - - /** - * Check if class is a valid command. - * - * @param string $className - * @return bool - */ - private function isValidCommand(string $className): bool { - try { - if (!class_exists($className)) { - return false; - } - - $reflection = new ReflectionClass($className); - - return $reflection->isSubclassOf(Command::class) - && !$reflection->isAbstract() - && !$reflection->isInterface() - && !$reflection->isTrait(); - } catch (\Exception $e) { - return false; - } - } - - /** - * Scan directory for PHP files. - * - * @param string $directory - * @return array Array of file paths - */ - private function scanDirectory(string $directory): array { - $files = []; - - if (!is_dir($directory)) { - return $files; - } - - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS) - ); - - foreach ($iterator as $file) { - if ($file->getExtension() !== 'php') { - continue; - } - - $filePath = $file->getRealPath(); - - if ($this->shouldExcludeFile($filePath)) { - continue; - } - - $files[] = $filePath; - } - - return $files; - } - - /** - * Check if file should be excluded based on patterns. - * - * @param string $filePath - * @return bool - */ - private function shouldExcludeFile(string $filePath): bool { - foreach ($this->excludePatterns as $pattern) { - if (fnmatch($pattern, $filePath) || fnmatch($pattern, basename($filePath))) { - return true; - } - } - - return false; - } -} +cache = $cache ?? new CommandCache(); + } + + /** + * Add a directory path to search for commands. + * + * @param string $path Directory path to search + * @return self + */ + public function addSearchPath(string $path): self { + $realPath = realpath($path); + + if ($realPath === false) { + throw new CommandDiscoveryException("Search path does not exist: {$path}"); + } + + if (!in_array($realPath, $this->searchPaths)) { + $this->searchPaths[] = $realPath; + } + + return $this; + } + + /** + * Add multiple search paths. + * + * @param array $paths Array of directory paths + * @return self + */ + public function addSearchPaths(array $paths): self { + foreach ($paths as $path) { + $this->addSearchPath($path); + } + + return $this; + } + + /** + * Discover commands from configured search paths. + * + * @return array Array of Command instances + * @throws CommandDiscoveryException If strict mode is enabled and errors occur + */ + public function discover(): array { + $this->errors = []; + + // Try to get from cache first + $cachedCommands = $this->cache->get(); + + if ($cachedCommands !== null) { + return $this->instantiateCommands($cachedCommands); + } + + // Discover commands + $commandMetadata = []; + $scannedFiles = []; + + foreach ($this->searchPaths as $path) { + $files = $this->scanDirectory($path); + $scannedFiles = array_merge($scannedFiles, $files); + + foreach ($files as $file) { + try { + $className = $this->extractClassName($file); + + if ($className && $this->isValidCommand($className)) { + $metadata = CommandMetadata::extract($className); + $commandMetadata[] = $metadata; + } + } catch (\Exception $e) { + $this->errors[] = "Failed to process {$file}: ".$e->getMessage(); + } + } + } + + // Handle errors + if (!empty($this->errors) && $this->strictMode) { + throw CommandDiscoveryException::fromErrors($this->errors); + } + + // Cache the results + $this->cache->store($commandMetadata, $scannedFiles); + + return $this->instantiateCommands($commandMetadata); + } + + /** + * Add a pattern to exclude files/directories. + * + * @param string $pattern Glob pattern to exclude + * @return self + */ + public function excludePattern(string $pattern): self { + if (!in_array($pattern, $this->excludePatterns)) { + $this->excludePatterns[] = $pattern; + } + + return $this; + } + + /** + * Add multiple exclude patterns. + * + * @param array $patterns Array of glob patterns + * @return self + */ + public function excludePatterns(array $patterns): self { + foreach ($patterns as $pattern) { + $this->excludePattern($pattern); + } + + return $this; + } + + /** + * Get the cache instance. + * + * @return CommandCache + */ + public function getCache(): CommandCache { + return $this->cache; + } + + /** + * Get discovery errors from last discovery attempt. + * + * @return array + */ + public function getErrors(): array { + return $this->errors; + } + + /** + * Enable or disable strict mode. + * In strict mode, any discovery error will throw an exception. + * + * @param bool $strict + * @return self + */ + public function setStrictMode(bool $strict): self { + $this->strictMode = $strict; + + return $this; + } + + /** + * Set a factory callable for instantiating discovered commands. + * + * When set, the factory is called with the fully qualified class name + * instead of using `new $className()`. This enables dependency injection + * for auto-discovered commands. + * + * Example with a DI container: + * ```php + * $discovery->setFactory(fn($class) => $container->get($class)); + * ``` + * + * Example with manual mapping: + * ```php + * $discovery->setFactory(function($class) use ($db) { + * return match($class) { + * MigrateCommand::class => new MigrateCommand($db), + * default => new $class(), + * }; + * }); + * ``` + * + * @param callable $factory A callable that accepts a class name string + * and returns a Command instance. + * @return self + */ + public function setFactory(callable $factory): self { + $this->factory = $factory; + + return $this; + } + + /** + * Extract class name from PHP file. + * + * @param string $filePath + * @return string|null + */ + private function extractClassName(string $filePath): ?string { + $content = file_get_contents($filePath); + + if ($content === false) { + return null; + } + + // Extract namespace + $namespace = null; + + if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) { + $namespace = trim($matches[1]); + } + + // Extract class name + $className = null; + + if (preg_match('/class\s+(\w+)/', $content, $matches)) { + $className = $matches[1]; + } + + if (!$className) { + return null; + } + + return $namespace ? $namespace.'\\'.$className : $className; + } + + /** + * Instantiate commands from metadata. + * + * @param array $commandMetadata + * @return array Array of Command instances + */ + private function instantiateCommands(array $commandMetadata): array { + $commands = []; + + foreach ($commandMetadata as $metadata) { + try { + $className = $metadata['className']; + + if (class_exists($className)) { + // Check if class implements AutoDiscoverable before instantiating + if (is_subclass_of($className, AutoDiscoverable::class)) { + if (!$className::shouldAutoRegister()) { + continue; // Skip this command + } + } + + $commands[] = $this->factory + ? ($this->factory)($className) + : new $className(); + } + } catch (\Throwable $e) { + $this->errors[] = "Failed to instantiate {$metadata['className']}: ".$e->getMessage(); + + if ($this->strictMode) { + throw new CommandDiscoveryException("Failed to instantiate {$metadata['className']}: ".$e->getMessage()); + } + } + } + + return $commands; + } + + /** + * Check if class is a valid command. + * + * @param string $className + * @return bool + */ + private function isValidCommand(string $className): bool { + try { + if (!class_exists($className)) { + return false; + } + + $reflection = new ReflectionClass($className); + + return $reflection->isSubclassOf(Command::class) + && !$reflection->isAbstract() + && !$reflection->isInterface() + && !$reflection->isTrait(); + } catch (\Exception $e) { + return false; + } + } + + /** + * Scan directory for PHP files. + * + * @param string $directory + * @return array Array of file paths + */ + private function scanDirectory(string $directory): array { + $files = []; + + if (!is_dir($directory)) { + return $files; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $filePath = $file->getRealPath(); + + if ($this->shouldExcludeFile($filePath)) { + continue; + } + + $files[] = $filePath; + } + + return $files; + } + + /** + * Check if file should be excluded based on patterns. + * + * @param string $filePath + * @return bool + */ + private function shouldExcludeFile(string $filePath): bool { + foreach ($this->excludePatterns as $pattern) { + if (fnmatch($pattern, $filePath) || fnmatch($pattern, basename($filePath))) { + return true; + } + } + + return false; + } +} diff --git a/examples/13-auto-discovery/README.md b/examples/13-auto-discovery/README.md new file mode 100644 index 0000000..b4976bc --- /dev/null +++ b/examples/13-auto-discovery/README.md @@ -0,0 +1,37 @@ +# Auto-Discovery with Factory + +This example demonstrates how to use `CommandDiscovery::setFactory()` to inject dependencies into auto-discovered commands. + +## Problem + +By default, auto-discovery instantiates commands using `new $className()`, which requires zero-argument constructors. Commands that need dependencies (database connections, services, configuration) cannot use auto-discovery without a factory. + +## Solution + +Set a factory callable that knows how to construct each command: + +```php +$discovery->setFactory(function (string $className) use ($container) { + return $container->get($className); +}); +``` + +## Running + +```bash +php main.php greet +# Output: Hello from the container! + +php main.php status +# Output: +# Application is running. +# Version: 2.1.0 +``` + +## Key Points + +- The factory receives the fully qualified class name as its only argument +- It must return a `Command` instance +- If no factory is set, the default `new $className()` behavior is used +- Factory exceptions are caught and handled like any other instantiation error +- Works with any DI container (PSR-11, webfiori/container, or a simple closure) diff --git a/examples/13-auto-discovery/commands/GreetCommand.php b/examples/13-auto-discovery/commands/GreetCommand.php new file mode 100644 index 0000000..f19189c --- /dev/null +++ b/examples/13-auto-discovery/commands/GreetCommand.php @@ -0,0 +1,21 @@ +greeting = $greeting; + } + + public function exec(): int { + $this->println($this->greeting); + + return 0; + } +} diff --git a/examples/13-auto-discovery/commands/StatusCommand.php b/examples/13-auto-discovery/commands/StatusCommand.php new file mode 100644 index 0000000..95e7f08 --- /dev/null +++ b/examples/13-auto-discovery/commands/StatusCommand.php @@ -0,0 +1,22 @@ +version = $version; + } + + public function exec(): int { + $this->println('Application is running.'); + $this->println('Version: ' . $this->version); + + return 0; + } +} diff --git a/examples/13-auto-discovery/main.php b/examples/13-auto-discovery/main.php new file mode 100644 index 0000000..2533962 --- /dev/null +++ b/examples/13-auto-discovery/main.php @@ -0,0 +1,44 @@ + 'Hello from the container!', + 'version' => '2.1.0', +]; + +// Create the runner +$runner = new Runner(); + +// Set up discovery with a factory that injects dependencies +$discovery = new CommandDiscovery(new CommandCache(enabled: false)); +$discovery->addSearchPath(__DIR__ . '/commands'); +$discovery->setFactory(function (string $className) use ($services) { + // Use a match or conditional to provide dependencies per command + return match ($className) { + GreetCommand::class => new GreetCommand($services['greeting']), + StatusCommand::class => new StatusCommand($services['version']), + default => new $className(), + }; +}); + +// Assign discovery to runner +$runner->setCommandDiscovery($discovery); +$runner->discoverCommands(); + +// Run the CLI +exit($runner->start()); diff --git a/tests/WebFiori/Tests/Cli/Discovery/CommandDiscoveryTest.php b/tests/WebFiori/Tests/Cli/Discovery/CommandDiscoveryTest.php index 10841a2..22c8ee5 100644 --- a/tests/WebFiori/Tests/Cli/Discovery/CommandDiscoveryTest.php +++ b/tests/WebFiori/Tests/Cli/Discovery/CommandDiscoveryTest.php @@ -1,206 +1,316 @@ -discovery = new CommandDiscovery(); - $this->testCommandsPath = __DIR__ . '/TestCommands'; - } - - /** - * @test - */ - public function testAddSearchPath() { - $this->discovery->addSearchPath($this->testCommandsPath); - - // Should not throw exception for valid path - $this->assertTrue(true); - } - - /** - * @test - */ - public function testAddInvalidSearchPath() { - $this->expectException(CommandDiscoveryException::class); - $this->expectExceptionMessage('Search path does not exist'); - - $this->discovery->addSearchPath('/non/existent/path'); - } - - /** - * @test - */ - public function testAddMultipleSearchPaths() { - $paths = [$this->testCommandsPath, __DIR__]; - - $this->discovery->addSearchPaths($paths); - - // Should not throw exception - $this->assertTrue(true); - } - - /** - * @test - */ - public function testExcludePattern() { - $this->discovery->excludePattern('*Test*'); - $this->discovery->excludePatterns(['*Abstract*', '*Hidden*']); - - // Should not throw exception - $this->assertTrue(true); - } - - /** - * @test - */ - public function testStrictMode() { - $this->discovery->setStrictMode(true); - $this->discovery->setStrictMode(false); - - // Should not throw exception - $this->assertTrue(true); - } - - /** - * @test - */ - public function testDiscoverCommands() { - $this->discovery->addSearchPath($this->testCommandsPath); - - $commands = $this->discovery->discover(); - - $this->assertIsArray($commands); - $this->assertNotEmpty($commands); - - // Should find TestCommand - $testCommandFound = false; - foreach ($commands as $command) { - if ($command instanceof TestCommand) { - $testCommandFound = true; - break; - } - } - $this->assertTrue($testCommandFound, 'TestCommand should be discovered'); - } - - /** - * @test - */ - public function testDiscoverWithExcludePatterns() { - $this->discovery->addSearchPath($this->testCommandsPath) - ->excludePattern('*Abstract*') - ->excludePattern('*NotACommand*'); - - $commands = $this->discovery->discover(); - - // Should not include abstract commands or non-commands - foreach ($commands as $command) { - $this->assertInstanceOf(\WebFiori\Cli\Command::class, $command); - } - } - - /** - * @test - */ - public function testDiscoverWithCache() { - $tempCacheFile = sys_get_temp_dir() . '/discovery_test_cache.json'; - $cache = new CommandCache($tempCacheFile, true); - $discovery = new CommandDiscovery($cache); - - $discovery->addSearchPath($this->testCommandsPath); - - // First discovery should populate cache - $commands1 = $discovery->discover(); - $this->assertTrue(file_exists($tempCacheFile)); - - // Second discovery should use cache - $commands2 = $discovery->discover(); - - $this->assertEquals(count($commands1), count($commands2)); - - // Cleanup - if (file_exists($tempCacheFile)) { - unlink($tempCacheFile); - } - } - - /** - * @test - */ - public function testGetErrors() { - $this->discovery->addSearchPath($this->testCommandsPath); - - // Discover commands (some may have errors) - $this->discovery->discover(); - - $errors = $this->discovery->getErrors(); - $this->assertIsArray($errors); - } - - /** - * @test - */ - public function testGetCache() { - $cache = $this->discovery->getCache(); - $this->assertInstanceOf(CommandCache::class, $cache); - } - - /** - * @test - */ - public function testDiscoverWithAutoDiscoverableCommand() { - // Set AutoDiscoverableCommand to not register - AutoDiscoverableCommand::setShouldRegister(false); - - $this->discovery->addSearchPath($this->testCommandsPath); - $commands = $this->discovery->discover(); - - // Should not include AutoDiscoverableCommand - $autoDiscoverableFound = false; - foreach ($commands as $command) { - if ($command instanceof AutoDiscoverableCommand) { - $autoDiscoverableFound = true; - break; - } - } - $this->assertFalse($autoDiscoverableFound); - - // Reset for other tests - AutoDiscoverableCommand::setShouldRegister(true); - } - - /** - * @test - */ - public function testStrictModeThrowsException() { - // Create a discovery that will encounter errors - $discovery = new CommandDiscovery(); - $discovery->setStrictMode(true); - - // Add a path that might have issues - $discovery->addSearchPath($this->testCommandsPath); - - // In strict mode, if there are any errors, it should throw - // Note: This test might not always throw depending on the test commands - // but it tests the mechanism - try { - $discovery->discover(); - $this->assertTrue(true); // No exception thrown - } catch (CommandDiscoveryException $e) { - $this->assertInstanceOf(CommandDiscoveryException::class, $e); - } - } -} +discovery = new CommandDiscovery(); + $this->testCommandsPath = __DIR__ . '/TestCommands'; + } + + /** + * @test + */ + public function testAddSearchPath() { + $this->discovery->addSearchPath($this->testCommandsPath); + + // Should not throw exception for valid path + $this->assertTrue(true); + } + + /** + * @test + */ + public function testAddInvalidSearchPath() { + $this->expectException(CommandDiscoveryException::class); + $this->expectExceptionMessage('Search path does not exist'); + + $this->discovery->addSearchPath('/non/existent/path'); + } + + /** + * @test + */ + public function testAddMultipleSearchPaths() { + $paths = [$this->testCommandsPath, __DIR__]; + + $this->discovery->addSearchPaths($paths); + + // Should not throw exception + $this->assertTrue(true); + } + + /** + * @test + */ + public function testExcludePattern() { + $this->discovery->excludePattern('*Test*'); + $this->discovery->excludePatterns(['*Abstract*', '*Hidden*']); + + // Should not throw exception + $this->assertTrue(true); + } + + /** + * @test + */ + public function testStrictMode() { + $this->discovery->setStrictMode(true); + $this->discovery->setStrictMode(false); + + // Should not throw exception + $this->assertTrue(true); + } + + /** + * @test + */ + public function testDiscoverCommands() { + $this->discovery->addSearchPath($this->testCommandsPath); + + $commands = $this->discovery->discover(); + + $this->assertIsArray($commands); + $this->assertNotEmpty($commands); + + // Should find TestCommand + $testCommandFound = false; + foreach ($commands as $command) { + if ($command instanceof TestCommand) { + $testCommandFound = true; + break; + } + } + $this->assertTrue($testCommandFound, 'TestCommand should be discovered'); + } + + /** + * @test + */ + public function testDiscoverWithExcludePatterns() { + $this->discovery->addSearchPath($this->testCommandsPath) + ->excludePattern('*Abstract*') + ->excludePattern('*NotACommand*'); + + $commands = $this->discovery->discover(); + + // Should not include abstract commands or non-commands + foreach ($commands as $command) { + $this->assertInstanceOf(\WebFiori\Cli\Command::class, $command); + } + } + + /** + * @test + */ + public function testDiscoverWithCache() { + $tempCacheFile = sys_get_temp_dir() . '/discovery_test_cache.json'; + $cache = new CommandCache($tempCacheFile, true); + $discovery = new CommandDiscovery($cache); + + $discovery->addSearchPath($this->testCommandsPath); + + // First discovery should populate cache + $commands1 = $discovery->discover(); + $this->assertTrue(file_exists($tempCacheFile)); + + // Second discovery should use cache + $commands2 = $discovery->discover(); + + $this->assertEquals(count($commands1), count($commands2)); + + // Cleanup + if (file_exists($tempCacheFile)) { + unlink($tempCacheFile); + } + } + + /** + * @test + */ + public function testGetErrors() { + $this->discovery->addSearchPath($this->testCommandsPath); + + // Discover commands (some may have errors) + $this->discovery->discover(); + + $errors = $this->discovery->getErrors(); + $this->assertIsArray($errors); + } + + /** + * @test + */ + public function testGetCache() { + $cache = $this->discovery->getCache(); + $this->assertInstanceOf(CommandCache::class, $cache); + } + + /** + * @test + */ + public function testDiscoverWithAutoDiscoverableCommand() { + // Set AutoDiscoverableCommand to not register + AutoDiscoverableCommand::setShouldRegister(false); + + $this->discovery->addSearchPath($this->testCommandsPath); + $commands = $this->discovery->discover(); + + // Should not include AutoDiscoverableCommand + $autoDiscoverableFound = false; + foreach ($commands as $command) { + if ($command instanceof AutoDiscoverableCommand) { + $autoDiscoverableFound = true; + break; + } + } + $this->assertFalse($autoDiscoverableFound); + + // Reset for other tests + AutoDiscoverableCommand::setShouldRegister(true); + } + + /** + * @test + */ + public function testStrictModeThrowsException() { + // Create a discovery that will encounter errors + $discovery = new CommandDiscovery(); + $discovery->setStrictMode(true); + + // Add a path that might have issues + $discovery->addSearchPath($this->testCommandsPath); + + // In strict mode, if there are any errors, it should throw + // Note: This test might not always throw depending on the test commands + // but it tests the mechanism + try { + $discovery->discover(); + $this->assertTrue(true); // No exception thrown + } catch (CommandDiscoveryException $e) { + $this->assertInstanceOf(CommandDiscoveryException::class, $e); + } + } + + /** + * @test + */ + public function testSetFactoryUsedForInstantiation() { + $factoryPath = __DIR__ . '/FactoryTestCommands'; + $cache = new CommandCache(enabled: false); + $discovery = new CommandDiscovery($cache); + $discovery->addSearchPath($factoryPath); + $discovery->setFactory(function (string $className) { + if ($className === CommandWithDependency::class) { + return new CommandWithDependency('injected-service'); + } + + return new $className(); + }); + + $commands = $discovery->discover(); + + $depCommand = null; + + foreach ($commands as $command) { + if ($command instanceof CommandWithDependency) { + $depCommand = $command; + + break; + } + } + + $this->assertNotNull($depCommand, 'CommandWithDependency should be discovered'); + $this->assertEquals('injected-service', $depCommand->getService()); + } + + /** + * @test + */ + public function testDefaultBehaviorWithoutFactory() { + $factoryPath = __DIR__ . '/FactoryTestCommands'; + $cache = new CommandCache(enabled: false); + $discovery = new CommandDiscovery($cache); + $discovery->addSearchPath($factoryPath); + + // Without factory, CommandWithDependency will fail but SimpleCommand should work + $commands = $discovery->discover(); + + $simpleFound = false; + + foreach ($commands as $command) { + if ($command instanceof SimpleCommand) { + $simpleFound = true; + + break; + } + } + + $this->assertTrue($simpleFound, 'SimpleCommand should be discovered without factory'); + } + + /** + * @test + */ + public function testFactoryExceptionHandledGracefully() { + $factoryPath = __DIR__ . '/FactoryTestCommands'; + $cache = new CommandCache(enabled: false); + $discovery = new CommandDiscovery($cache); + $discovery->addSearchPath($factoryPath); + $discovery->setFactory(function (string $className) { + if ($className === CommandWithDependency::class) { + throw new \RuntimeException('Missing dependency'); + } + + return new $className(); + }); + + $commands = $discovery->discover(); + + // Should not contain CommandWithDependency + foreach ($commands as $command) { + $this->assertNotInstanceOf(CommandWithDependency::class, $command); + } + + // Should have recorded the error + $errors = $discovery->getErrors(); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Missing dependency', $errors[0]); + } + + /** + * @test + */ + public function testFactoryExceptionInStrictModeThrows() { + $factoryPath = __DIR__ . '/FactoryTestCommands'; + $cache = new CommandCache(enabled: false); + $discovery = new CommandDiscovery($cache); + $discovery->addSearchPath($factoryPath); + $discovery->setStrictMode(true); + $discovery->setFactory(function (string $className) { + if ($className === CommandWithDependency::class) { + throw new \RuntimeException('Missing dependency'); + } + + return new $className(); + }); + + $this->expectException(CommandDiscoveryException::class); + $this->expectExceptionMessage('Missing dependency'); + $discovery->discover(); + } +} diff --git a/tests/WebFiori/Tests/Cli/Discovery/FactoryTestCommands/CommandWithDependency.php b/tests/WebFiori/Tests/Cli/Discovery/FactoryTestCommands/CommandWithDependency.php new file mode 100644 index 0000000..afcddd6 --- /dev/null +++ b/tests/WebFiori/Tests/Cli/Discovery/FactoryTestCommands/CommandWithDependency.php @@ -0,0 +1,26 @@ +service = $service; + } + + public function exec(): int { + $this->println('Service: ' . $this->service); + return 0; + } + + public function getService(): string { + return $this->service; + } +} diff --git a/tests/WebFiori/Tests/Cli/Discovery/FactoryTestCommands/SimpleCommand.php b/tests/WebFiori/Tests/Cli/Discovery/FactoryTestCommands/SimpleCommand.php new file mode 100644 index 0000000..6d1f543 --- /dev/null +++ b/tests/WebFiori/Tests/Cli/Discovery/FactoryTestCommands/SimpleCommand.php @@ -0,0 +1,19 @@ +println('Simple command executed'); + return 0; + } +}