docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Status;
use App\Framework\Router\Result\JsonResult;
/**
* Admin API Handler
*
* Handles auto-generated API endpoints for admin resources
*/
final readonly class AdminApiHandler
{
public function handleList(
HttpRequest $request,
mixed $repository
): JsonResult {
// Extract query parameters
$page = (int) ($request->query->get('page') ?? 1);
$perPage = (int) ($request->query->get('per_page') ?? 25);
$sortBy = $request->query->get('sort_by');
$sortDir = $request->query->get('sort_dir') ?? 'asc';
$search = $request->query->get('search');
// Build filters
$filters = [];
if ($search) {
$filters['search'] = $search;
}
// Fetch data from repository
$data = $repository->findAll($filters);
// Apply sorting if requested
if ($sortBy && method_exists($repository, 'sortBy')) {
$data = $repository->sortBy($data, $sortBy, $sortDir);
}
// Apply pagination
$total = count($data);
$offset = ($page - 1) * $perPage;
$paginatedData = array_slice($data, $offset, $perPage);
// Convert to arrays
$items = array_map(
fn($item) => method_exists($item, 'toArray') ? $item->toArray() : (array) $item,
$paginatedData
);
return new JsonResult([
'success' => true,
'data' => $items,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'pages' => (int) ceil($total / $perPage),
],
]);
}
public function handleGet(
string $id,
mixed $repository
): JsonResult {
$item = $repository->findById($id);
if ($item === null) {
return new JsonResult([
'success' => false,
'error' => 'Resource not found',
], Status::NOT_FOUND);
}
$data = method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
return new JsonResult([
'success' => true,
'data' => $data,
]);
}
public function handleCreate(
HttpRequest $request,
mixed $repository
): JsonResult {
$data = $request->parsedBody->toArray();
try {
$item = $repository->create($data);
$responseData = method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
return new JsonResult([
'success' => true,
'data' => $responseData,
'message' => 'Resource created successfully',
], Status::CREATED);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
public function handleUpdate(
string $id,
HttpRequest $request,
mixed $repository
): JsonResult {
$data = $request->parsedBody->toArray();
try {
$item = $repository->update($id, $data);
if ($item === null) {
return new JsonResult([
'success' => false,
'error' => 'Resource not found',
], Status::NOT_FOUND);
}
$responseData = method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
return new JsonResult([
'success' => true,
'data' => $responseData,
'message' => 'Resource updated successfully',
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
public function handleDelete(
string $id,
mixed $repository
): JsonResult {
try {
$deleted = $repository->delete($id);
if (!$deleted) {
return new JsonResult([
'success' => false,
'error' => 'Resource not found',
], Status::NOT_FOUND);
}
return new JsonResult([
'success' => true,
'message' => 'Resource deleted successfully',
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
use App\Framework\View\FormBuilder;
use App\Framework\View\Table\Table;
/**
* Admin Page Renderer
*
* Provides consistent rendering for admin pages
*/
final readonly class AdminPageRenderer
{
public function __construct(
private AdminLayoutProcessor $layoutProcessor
) {
}
public function renderIndex(
string $resource,
Table $table,
string $title,
array $actions = []
): ViewResult {
$data = [
'title' => $title,
'resource' => $resource,
'table' => $table->render(),
'actions' => $actions,
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult(
template: 'admin-index',
metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData
);
}
public function renderForm(
string $resource,
FormBuilder $form,
string $title,
?string $subtitle = null
): ViewResult {
$data = [
'title' => $title,
'subtitle' => $subtitle,
'resource' => $resource,
'form' => $form->build(),
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult(
template: 'admin-form',
metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData
);
}
public function renderShow(
string $resource,
string $title,
array $data
): ViewResult {
$pageData = [
'title' => $title,
'resource' => $resource,
...$data,
];
$finalData = $this->layoutProcessor->processLayoutFromArray($pageData);
return new ViewResult(
template: 'admin-show',
metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Attributes;
/**
* Admin Resource Attribute
*
* Marks a controller as an admin resource with configuration
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class AdminResource
{
public function __construct(
public string $name,
public string $singularName,
public string $pluralName,
public string $icon = 'file',
public bool $enableApi = true,
public bool $enableCrud = true,
public array $permissions = [],
) {
}
public function getApiEndpoint(): string
{
return "/admin/api/{$this->name}";
}
public function getIndexRoute(): string
{
return "/admin/{$this->name}";
}
public function getCreateRoute(): string
{
return "/admin/{$this->name}/create";
}
public function getEditRoute(string $id): string
{
return "/admin/{$this->name}/{$id}/edit";
}
public function getShowRoute(string $id): string
{
return "/admin/{$this->name}/{$id}";
}
}

View File

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Examples;
use App\Framework\Admin\Services\CrudService;
use App\Framework\Admin\ValueObjects\CrudConfig;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\Redirect;
use App\Framework\Http\Responses\ViewResult;
/**
* Campaign CRUD Controller - Example Implementation
*
* Demonstrates composition-based CRUD pattern using CrudService
* No inheritance - pure composition following framework principles
*/
final readonly class CampaignCrudController
{
private CrudConfig $config;
public function __construct(
private CrudService $crudService,
private CampaignRepository $campaignRepository
) {
// Configure CRUD settings for campaigns resource
$this->config = CrudConfig::forResource(
resource: 'campaigns',
resourceName: 'Campaign',
title: 'Campaigns'
)->withColumns([
['field' => 'artist_name', 'label' => 'Artist', 'sortable' => true],
['field' => 'campaign_type', 'label' => 'Type', 'sortable' => true],
['field' => 'status', 'label' => 'Status', 'sortable' => true],
['field' => 'created_at', 'label' => 'Created', 'sortable' => true],
])->withFilters([
['field' => 'status', 'type' => 'select', 'options' => ['active', 'draft', 'archived']],
['field' => 'campaign_type', 'type' => 'select', 'options' => ['pre-save', 'release', 'tour']],
])->withBulkActions([
['name' => 'activate', 'label' => 'Activate Selected'],
['name' => 'archive', 'label' => 'Archive Selected'],
]);
}
/**
* Show campaigns index
*/
#[Route(path: '/admin/campaigns', method: Method::GET)]
public function index(Request $request): ViewResult
{
// Fetch campaigns from repository
$campaigns = $this->campaignRepository->findAll();
// Delegate rendering to CrudService
return $this->crudService->renderIndex(
config: $this->config,
items: $campaigns,
request: $request,
pagination: null // Add pagination if needed
);
}
/**
* Show create campaign form
*/
#[Route(path: '/admin/campaigns/create', method: Method::GET)]
public function create(): ViewResult
{
return $this->crudService->renderCreate(
config: $this->config,
formFields: $this->getCampaignFormFields(),
defaultData: null,
helpText: 'Create a new campaign for an artist. Pre-save campaigns allow fans to save music before release.'
);
}
/**
* Store new campaign
*/
#[Route(path: '/admin/campaigns/store', method: Method::POST)]
public function store(Request $request): Redirect
{
try {
// Validate and create campaign
$data = $request->parsedBody->toArray();
$campaign = $this->campaignRepository->create($data);
// Delegate redirect to CrudService
return $this->crudService->redirectAfterCreate(
config: $this->config,
request: $request,
itemId: $campaign->id
);
} catch (\Exception $e) {
return $this->crudService->redirectWithError(
message: 'Failed to create campaign: ' . $e->getMessage(),
inputData: $request->parsedBody->toArray()
);
}
}
/**
* Show edit campaign form
*/
#[Route(path: '/admin/campaigns/edit/{id}', method: Method::GET)]
public function edit(Request $request): ViewResult
{
$id = $request->routeParams->get('id');
$campaign = $this->campaignRepository->findById($id);
return $this->crudService->renderEdit(
config: $this->config,
id: $id,
formFields: $this->getCampaignFormFields(),
itemData: $campaign->toArray(),
metadata: [
'id' => $campaign->id,
'createdAt' => $campaign->created_at->format('Y-m-d H:i:s'),
'updatedAt' => $campaign->updated_at?->format('Y-m-d H:i:s'),
'status' => ucfirst($campaign->status),
'statusColor' => $this->getStatusColor($campaign->status),
],
helpText: 'Edit campaign details. Changes will be reflected immediately.'
);
}
/**
* Update campaign
*/
#[Route(path: '/admin/campaigns/update/{id}', method: Method::PUT)]
public function update(Request $request): Redirect
{
try {
$id = $request->routeParams->get('id');
$data = $request->parsedBody->toArray();
$this->campaignRepository->update($id, $data);
return $this->crudService->redirectAfterUpdate(
config: $this->config,
request: $request,
itemId: $id
);
} catch (\Exception $e) {
return $this->crudService->redirectWithError(
message: 'Failed to update campaign: ' . $e->getMessage(),
inputData: $request->parsedBody->toArray()
);
}
}
/**
* Show campaign details
*/
#[Route(path: '/admin/campaigns/view/{id}', method: Method::GET)]
public function show(Request $request): ViewResult
{
$id = $request->routeParams->get('id');
$campaign = $this->campaignRepository->findById($id);
return $this->crudService->renderShow(
config: $this->config,
id: $id,
fields: [
['label' => 'Artist Name', 'value' => $campaign->artist_name, 'type' => 'text'],
['label' => 'Campaign Type', 'value' => ucfirst($campaign->campaign_type), 'type' => 'badge', 'color' => 'primary'],
['label' => 'Status', 'value' => ucfirst($campaign->status), 'type' => 'badge', 'color' => $this->getStatusColor($campaign->status)],
['label' => 'Spotify URI', 'value' => $campaign->spotify_uri, 'type' => 'text'],
['label' => 'Release Date', 'value' => $campaign->release_date?->format('Y-m-d'), 'type' => 'text'],
['label' => 'Total Saves', 'value' => $campaign->stats['total_saves'] ?? 0, 'type' => 'text'],
],
metadata: [
'id' => $campaign->id,
'createdAt' => $campaign->created_at->format('Y-m-d H:i:s'),
'updatedAt' => $campaign->updated_at?->format('Y-m-d H:i:s'),
'status' => ucfirst($campaign->status),
'statusColor' => $this->getStatusColor($campaign->status),
],
relatedItems: null, // Add related items if needed
actions: [
['type' => 'link', 'url' => "/admin/campaigns/{$id}/analytics", 'label' => 'View Analytics', 'icon' => 'graph-up', 'variant' => 'primary'],
['type' => 'button', 'name' => 'duplicate', 'label' => 'Duplicate Campaign', 'icon' => 'files', 'variant' => 'secondary'],
]
);
}
/**
* Delete campaign
*/
#[Route(path: '/admin/campaigns/delete/{id}', method: Method::DELETE)]
public function destroy(Request $request): Redirect
{
try {
$id = $request->routeParams->get('id');
$this->campaignRepository->delete($id);
return $this->crudService->redirectAfterDelete($this->config);
} catch (\Exception $e) {
return $this->crudService->redirectWithError(
message: 'Failed to delete campaign: ' . $e->getMessage()
);
}
}
/**
* Get campaign form fields configuration
*/
private function getCampaignFormFields(): array
{
return [
[
'type' => 'text',
'name' => 'artist_name',
'label' => 'Artist Name',
'required' => true,
'placeholder' => 'Enter artist name',
],
[
'type' => 'select',
'name' => 'campaign_type',
'label' => 'Campaign Type',
'required' => true,
'options' => [
['value' => 'pre-save', 'label' => 'Pre-Save Campaign'],
['value' => 'release', 'label' => 'Release Campaign'],
['value' => 'tour', 'label' => 'Tour Campaign'],
],
],
[
'type' => 'text',
'name' => 'spotify_uri',
'label' => 'Spotify URI',
'required' => true,
'placeholder' => 'spotify:album:...',
'help' => 'Full Spotify URI for the album or track',
],
[
'type' => 'date',
'name' => 'release_date',
'label' => 'Release Date',
'required' => false,
],
[
'type' => 'select',
'name' => 'status',
'label' => 'Status',
'required' => true,
'options' => [
['value' => 'draft', 'label' => 'Draft'],
['value' => 'active', 'label' => 'Active'],
['value' => 'archived', 'label' => 'Archived'],
],
],
[
'type' => 'textarea',
'name' => 'description',
'label' => 'Description',
'required' => false,
'placeholder' => 'Campaign description (optional)',
'rows' => 4,
],
];
}
/**
* Get Bootstrap color for campaign status
*/
private function getStatusColor(string $status): string
{
return match ($status) {
'active' => 'success',
'draft' => 'warning',
'archived' => 'secondary',
default => 'info',
};
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Examples;
/**
* Campaign Repository - Example Stub
*
* Stub implementation for the example CRUD controller
* In a real implementation, this would use the EntityManager
*/
final readonly class CampaignRepository
{
public function findAll(): array
{
// Stub: Return sample data
return [];
}
public function findById(string $id): object
{
// Stub: Return sample campaign object
return (object) [
'id' => $id,
'artist_name' => 'Example Artist',
'campaign_type' => 'pre-save',
'status' => 'active',
'spotify_uri' => 'spotify:album:example',
'release_date' => new \DateTime('2025-01-01'),
'created_at' => new \DateTime(),
'updated_at' => new \DateTime(),
'stats' => ['total_saves' => 0],
];
}
public function create(array $data): object
{
// Stub: Create campaign
return (object) ['id' => 'new-id'];
}
public function update(string $id, array $data): void
{
// Stub: Update campaign
}
public function delete(string $id): void
{
// Stub: Delete campaign
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Factories;
use App\Framework\Admin\ValueObjects\AdminFormConfig;
use App\Framework\Admin\FormFields\FormFieldFactory;
use App\Framework\Http\Session\FormIdGenerator;
use App\Framework\View\FormBuilder;
/**
* Admin Form Factory
*
* Creates admin forms using composition-based field system
*/
final readonly class AdminFormFactory
{
public function __construct(
private FormIdGenerator $formIdGenerator,
private FormFieldFactory $fieldFactory
) {}
public function create(AdminFormConfig $config): FormBuilder
{
// Use existing FormBuilder
$form = FormBuilder::create(
action: $config->action,
method: $config->method->value,
formIdGenerator: $this->formIdGenerator
)->withClass('admin-form');
// Create fields using composition-based field factory
foreach ($config->fields as $name => $fieldConfig) {
// Merge field config with data value
$fieldConfigWithValue = [
...$fieldConfig,
'name' => $name,
'value' => $config->data[$name] ?? null
];
// Create field using factory
$field = $this->fieldFactory->createFromConfig($fieldConfigWithValue);
// Render field to form
$form = $field->render($form);
}
return $form->addSubmitButton('Save');
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Factories;
use App\Framework\Admin\ValueObjects\AdminTableConfig;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\Table\Formatters;
/**
* Admin Table Factory
*
* Creates admin tables using the existing Table module
*/
final readonly class AdminTableFactory
{
public function create(AdminTableConfig $config, array $data): Table
{
// Build column definitions from config
$columnDefs = [];
foreach ($config->columns as $key => $columnConfig) {
$columnDefs[$key] = [
'header' => $columnConfig['label'],
'sortable' => $columnConfig['sortable'] ?? $config->sortable,
'formatter' => $this->getFormatter($columnConfig['formatter'] ?? null),
'class' => $columnConfig['class'] ?? null,
];
}
// Build table options with data attributes for JavaScript
$options = TableOptions::admin()
->withId($config->resource . 'Table')
->withDataAttributes([
'resource' => $config->resource,
'api-endpoint' => $config->getApiEndpoint(),
'sortable' => $config->sortable ? 'true' : 'false',
'searchable' => $config->searchable ? 'true' : 'false',
'paginated' => $config->paginated ? 'true' : 'false',
'per-page' => (string) $config->perPage,
]);
// Use existing Table::fromArray
return Table::fromArray(
data: $data,
columnDefs: $columnDefs,
options: $options
);
}
private function getFormatter(?string $type): mixed
{
return match ($type) {
'date' => new Formatters\DateFormatter(),
'status' => Formatters\StatusFormatter::withBadges(),
'boolean' => Formatters\BooleanFormatter::germanWithBadges(),
'currency' => new Formatters\CurrencyFormatter(),
'number' => new Formatters\NumberFormatter(),
'masked' => new Formatters\MaskedFormatter(),
default => null,
};
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Components;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\View\ValueObjects\FormElement;
/**
* Field Wrapper Component
*
* Wraps field content with label and help text
*/
final readonly class FieldWrapper
{
/**
* Wrap field content with label and optional help text
*/
public function wrap(string $content, FieldMetadata $metadata): string
{
$label = FormElement::create('label', ['for' => $metadata->name], $metadata->label);
$html = $label . $content;
if ($metadata->help !== null) {
$help = FormElement::create('small', ['class' => 'form-text text-muted'], $metadata->help);
$html .= $help;
}
return FormElement::create('div', ['class' => 'form-group'], $html);
}
/**
* Wrap without label (for checkbox, radio, etc.)
*/
public function wrapWithoutLabel(string $content, FieldMetadata $metadata): string
{
$html = $content;
if ($metadata->help !== null) {
$help = FormElement::create('small', ['class' => 'form-text text-muted'], $metadata->help);
$html .= $help;
}
return FormElement::create('div', ['class' => 'form-group'], $html);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Checkbox Field
*
* Single checkbox input field using composition
*/
final readonly class CheckboxField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null,
private string $checkedValue = '1'
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $help = null,
string $checkedValue = '1'
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
class: 'form-check-input',
required: $required
),
wrapper: new FieldWrapper(),
value: $value,
checkedValue: $checkedValue
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['type' => 'checkbox', 'value' => $this->checkedValue]);
$attrArray = $attrs->toArray();
if ($this->isChecked()) {
$attrArray['checked'] = 'checked';
}
$input = FormElement::create('input', $attrArray);
$label = FormElement::create(
'label',
['class' => 'form-check-label', 'for' => $this->metadata->name],
$this->metadata->label
);
$checkboxHtml = FormElement::create(
'div',
['class' => 'form-check'],
$input . $label
);
$wrapped = $this->wrapper->wrapWithoutLabel($checkboxHtml, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
private function isChecked(): bool
{
if ($this->value === null) {
return false;
}
if (is_bool($this->value)) {
return $this->value;
}
return (string) $this->value === $this->checkedValue;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
use DateTimeInterface;
/**
* DateTime Input Field
*
* DateTime input field with formatting using composition
*/
final readonly class DateTimeField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null,
private string $type = 'datetime-local'
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $help = null,
string $type = 'datetime-local'
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required
),
wrapper: new FieldWrapper(),
value: $value,
type: $type
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['type' => $this->type]);
$attrArray = $attrs->toArray();
if ($this->value !== null) {
$attrArray['value'] = $this->formatValue($this->value);
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
private function formatValue(mixed $value): string
{
if ($value instanceof DateTimeInterface) {
return $value->format($this->getFormatForType());
}
if (is_string($value) && strtotime($value) !== false) {
return date($this->getFormatForType(), strtotime($value));
}
return (string) $value;
}
private function getFormatForType(): string
{
return match ($this->type) {
'date' => 'Y-m-d',
'time' => 'H:i',
'datetime-local' => 'Y-m-d\TH:i',
'month' => 'Y-m',
'week' => 'Y-\WW',
default => 'Y-m-d\TH:i'
};
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Email Input Field
*
* Email input field with validation using composition
*/
final readonly class EmailField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $placeholder = null,
?string $help = null
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required,
placeholder: $placeholder
),
wrapper: new FieldWrapper(),
value: $value
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['type' => 'email']);
$attrArray = $attrs->toArray();
if ($this->value !== null && $this->value !== '') {
$attrArray['value'] = (string) $this->value;
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Hidden Input Field
*
* Hidden input field using composition (no wrapper needed)
*/
final readonly class HiddenField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private mixed $value = null
) {}
public static function create(
string $name,
mixed $value = null
): self {
return new self(
metadata: new FieldMetadata($name, ''),
attributes: new FieldAttributes(
name: $name,
id: $name,
class: ''
),
value: $value
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['type' => 'hidden']);
$attrArray = $attrs->toArray();
// Remove empty class attribute for hidden fields
if (empty($attrArray['class'])) {
unset($attrArray['class']);
}
if ($this->value !== null && $this->value !== '') {
$attrArray['value'] = (string) $this->value;
}
$input = FormElement::create('input', $attrArray);
return $form->addElement($input);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Number Input Field
*
* Number input field with min/max/step using composition
*/
final readonly class NumberField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null,
private ?int $min = null,
private ?int $max = null,
private ?int $step = null
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $placeholder = null,
?string $help = null,
?int $min = null,
?int $max = null,
?int $step = null
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required,
placeholder: $placeholder
),
wrapper: new FieldWrapper(),
value: $value,
min: $min,
max: $max,
step: $step
);
}
public function render(FormBuilder $form): FormBuilder
{
$additionalAttrs = ['type' => 'number'];
if ($this->min !== null) {
$additionalAttrs['min'] = (string) $this->min;
}
if ($this->max !== null) {
$additionalAttrs['max'] = (string) $this->max;
}
if ($this->step !== null) {
$additionalAttrs['step'] = (string) $this->step;
}
$attrs = $this->attributes->withAdditional($additionalAttrs);
$attrArray = $attrs->toArray();
if ($this->value !== null && $this->value !== '') {
$attrArray['value'] = (string) $this->value;
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\ValueObjects\FieldOptions;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Select Dropdown Field
*
* Select field with options using composition
*/
final readonly class SelectField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private FieldOptions $options,
private mixed $value = null
) {}
public static function create(
string $name,
string $label,
array $options,
mixed $value = null,
bool $required = false,
?string $help = null,
?string $placeholder = null
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required
),
wrapper: new FieldWrapper(),
options: new FieldOptions($options, $placeholder),
value: $value
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrArray = $this->attributes->toArray();
$optionsHtml = $this->options->toHtml((string) $this->value);
$select = FormElement::create('select', $attrArray, $optionsHtml);
$wrapped = $this->wrapper->wrap($select, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Text Input Field
*
* Standard text input field using composition
*/
final readonly class TextField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $placeholder = null,
?string $help = null
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required,
placeholder: $placeholder
),
wrapper: new FieldWrapper(),
value: $value
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['type' => 'text']);
$attrArray = $attrs->toArray();
if ($this->value !== null && $this->value !== '') {
$attrArray['value'] = (string) $this->value;
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Textarea Field
*
* Multi-line text input field using composition
*/
final readonly class TextareaField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null,
private int $rows = 5
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $placeholder = null,
?string $help = null,
int $rows = 5
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required,
placeholder: $placeholder
),
wrapper: new FieldWrapper(),
value: $value,
rows: $rows
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['rows' => (string) $this->rows]);
$attrArray = $attrs->toArray();
$content = $this->value !== null ? htmlspecialchars((string) $this->value) : '';
$textarea = FormElement::create('textarea', $attrArray, $content);
$wrapped = $this->wrapper->wrap($textarea, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields;
use App\Framework\View\FormBuilder;
/**
* Form Field Interface
*
* All form fields must implement this interface
*/
interface FormField
{
/**
* Render the field and add it to the form builder
*/
public function render(FormBuilder $form): FormBuilder;
/**
* Get the field name
*/
public function getName(): string;
/**
* Get the field value
*/
public function getValue(): mixed;
/**
* Get the field label
*/
public function getLabel(): string;
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields;
use App\Framework\Admin\FormFields\Fields\TextField;
use App\Framework\Admin\FormFields\Fields\EmailField;
use App\Framework\Admin\FormFields\Fields\NumberField;
use App\Framework\Admin\FormFields\Fields\TextareaField;
use App\Framework\Admin\FormFields\Fields\SelectField;
use App\Framework\Admin\FormFields\Fields\DateTimeField;
use App\Framework\Admin\FormFields\Fields\CheckboxField;
use App\Framework\Admin\FormFields\Fields\HiddenField;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* Form Field Factory
*
* Creates form fields from configuration using composition-based field classes
*/
final readonly class FormFieldFactory
{
/**
* Create a field from configuration array
*
* @param array{
* type: string,
* name: string,
* label?: string,
* value?: mixed,
* required?: bool,
* placeholder?: string,
* help?: string,
* options?: array<string, string>,
* rows?: int,
* min?: int,
* max?: int,
* step?: int,
* checked_value?: string,
* datetime_type?: string
* } $config
*/
public function createFromConfig(array $config): FormField
{
$type = $config['type'] ?? 'text';
$name = $config['name'] ?? throw FrameworkException::create(
ErrorCode::VAL_INVALID_INPUT,
'Field name is required'
);
return match ($type) {
'text' => TextField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
placeholder: $config['placeholder'] ?? null,
help: $config['help'] ?? null
),
'email' => EmailField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
placeholder: $config['placeholder'] ?? null,
help: $config['help'] ?? null
),
'number' => NumberField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
placeholder: $config['placeholder'] ?? null,
help: $config['help'] ?? null,
min: $config['min'] ?? null,
max: $config['max'] ?? null,
step: $config['step'] ?? null
),
'textarea' => TextareaField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
placeholder: $config['placeholder'] ?? null,
help: $config['help'] ?? null,
rows: $config['rows'] ?? 5
),
'select' => SelectField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
options: $config['options'] ?? [],
value: $config['value'] ?? null,
required: $config['required'] ?? false,
help: $config['help'] ?? null,
placeholder: $config['placeholder'] ?? null
),
'datetime', 'date', 'time' => DateTimeField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
help: $config['help'] ?? null,
type: $config['datetime_type'] ?? $type
),
'checkbox' => CheckboxField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
help: $config['help'] ?? null,
checkedValue: $config['checked_value'] ?? '1'
),
'hidden' => HiddenField::create(
name: $name,
value: $config['value'] ?? null
),
default => throw FrameworkException::create(
ErrorCode::VAL_INVALID_INPUT,
"Unsupported field type: {$type}"
)
};
}
/**
* Create multiple fields from configuration array
*
* @param array<array{type: string, name: string}> $fieldsConfig
* @return FormField[]
*/
public function createMultiple(array $fieldsConfig): array
{
return array_map(
fn(array $config) => $this->createFromConfig($config),
$fieldsConfig
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\ValueObjects;
/**
* Field Attributes Value Object
*
* Holds HTML attributes for form fields
*/
final readonly class FieldAttributes
{
/**
* @param array<string, string> $additional
*/
public function __construct(
public string $name,
public string $id,
public string $class = 'form-control',
public bool $required = false,
public ?string $placeholder = null,
public array $additional = []
) {}
/**
* Convert attributes to array for rendering
*
* @return array<string, string>
*/
public function toArray(): array
{
$attrs = [
'name' => $this->name,
'id' => $this->id,
'class' => $this->class,
...$this->additional
];
if ($this->required) {
$attrs['required'] = 'required';
}
if ($this->placeholder !== null) {
$attrs['placeholder'] = $this->placeholder;
}
return $attrs;
}
/**
* Create with additional attributes
*
* @param array<string, string> $attrs
*/
public function withAdditional(array $attrs): self
{
return new self(
name: $this->name,
id: $this->id,
class: $this->class,
required: $this->required,
placeholder: $this->placeholder,
additional: [...$this->additional, ...$attrs]
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\ValueObjects;
/**
* Field Metadata Value Object
*
* Holds metadata for form fields (name, label, help text)
*/
final readonly class FieldMetadata
{
public function __construct(
public string $name,
public string $label,
public ?string $help = null
) {}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\ValueObjects;
/**
* Field Options Value Object
*
* Holds options for select/radio/checkbox fields
*/
final readonly class FieldOptions
{
/**
* @param array<string, string> $options Key-value pairs for options
* @param string|null $placeholder Optional placeholder option
*/
public function __construct(
public array $options,
public ?string $placeholder = null
) {}
/**
* Convert options to HTML option elements
*/
public function toHtml(string $selectedValue = ''): string
{
$html = '';
if ($this->placeholder !== null) {
$html .= '<option value="">' . htmlspecialchars($this->placeholder) . '</option>';
}
foreach ($this->options as $value => $label) {
$selected = ((string) $value === $selectedValue) ? ' selected' : '';
$html .= sprintf(
'<option value="%s"%s>%s</option>',
htmlspecialchars((string) $value),
$selected,
htmlspecialchars($label)
);
}
return $html;
}
/**
* Get options as array
*
* @return array<string, string>
*/
public function toArray(): array
{
return $this->options;
}
}

View File

@@ -0,0 +1,691 @@
# Admin Framework
Streamlined, attribute-based admin panel framework for the Custom PHP Framework.
## Overview
The Admin Framework provides a unified, consistent approach to building admin interfaces with:
- **Attribute-Driven Configuration** - Use `#[AdminResource]` for controller-level metadata
- **Pure Composition** - No inheritance, all functionality via dependency injection
- **Factory Pattern** - AdminTableFactory, AdminFormFactory for building components
- **Interactive JavaScript** - Auto-initializing data tables with sorting, searching, pagination
- **Auto-Generated APIs** - CRUD endpoints with AdminApiHandler
- **Consistent Rendering** - AdminPageRenderer for unified page layouts
## Architecture
```
┌─────────────────────┐
│ AdminResource │ Controller Attribute
│ (Metadata) │
└──────────┬──────────┘
├──────────────────────┬──────────────────────┬─────────────────────┐
│ │ │ │
┌──────────▼──────────┐ ┌───────▼──────────┐ ┌───────▼──────────┐ ┌──────▼────────┐
│ AdminTableFactory │ │ AdminFormFactory │ │ AdminPageRenderer│ │ AdminApiHandler│
│ (Build Tables) │ │ (Build Forms) │ │ (Render Pages) │ │ (CRUD API) │
└──────────┬──────────┘ └───────┬──────────┘ └───────┬──────────┘ └──────┬────────┘
│ │ │ │
├──────────────────────┴──────────────────────┘ │
│ │
┌──────────▼──────────┐ ┌────────────────────┐ ┌─────────────────────────┐ │
│ Table Module │ │ FormBuilder Module │ │ Templates │ │
│ (View/Table) │ │ (View/FormBuilder) │ │ (admin-index/form) │ │
└─────────────────────┘ └────────────────────┘ └─────────────────────────┘ │
┌──────────────────────────────────────────────────────────────────────────┐ │
│ JavaScript AdminDataTable Module │ │
│ - Auto-initialization via data attributes │◄┘
│ - AJAX loading with sorting, searching, pagination │
└────────────────────────────────────────────────────────────────────────────┘
```
## Core Components
### 1. AdminResource Attribute
Controller-level attribute for admin resource configuration.
```php
#[AdminResource(
name: 'users', // Resource name (route prefix)
singularName: 'User', // Singular display name
pluralName: 'Users', // Plural display name
icon: 'user', // Icon identifier
enableApi: true, // Auto-generate CRUD API
enableCrud: true // Enable CRUD operations
)]
final readonly class UserAdminController
{
// ...
}
```
### 2. AdminTableConfig Value Object
Type-safe table configuration.
```php
$tableConfig = AdminTableConfig::create(
resource: 'users',
columns: [
'id' => [
'label' => 'ID',
'sortable' => true,
'class' => 'text-center',
],
'email' => [
'label' => 'Email',
'sortable' => true,
'searchable' => true,
],
'status' => [
'label' => 'Status',
'formatter' => 'status', // badge, date, boolean, etc.
],
],
sortable: true,
searchable: true
);
```
**Available Formatters:**
- `date` - DateFormatter for date/time values
- `status` - StatusFormatter with badges
- `boolean` - BooleanFormatter (German: Ja/Nein with badges)
- `currency` - CurrencyFormatter
- `number` - NumberFormatter
- `masked` - MaskedFormatter for sensitive data
### 3. AdminFormConfig Value Object
Type-safe form configuration.
```php
$formConfig = new AdminFormConfig(
resource: 'users',
action: '/admin/users',
method: Method::POST,
fields: [
'email' => [
'type' => 'email',
'label' => 'Email Address',
'required' => true,
'placeholder' => 'user@example.com',
],
'name' => [
'type' => 'text',
'label' => 'Full Name',
'required' => true,
],
'role' => [
'type' => 'select',
'label' => 'Role',
'options' => [
'user' => 'User',
'admin' => 'Administrator',
],
],
'active' => [
'type' => 'checkbox',
'label' => 'Active',
],
]
);
```
**Supported Field Types:**
- `text` - Text input
- `email` - Email input
- `password` - Password input
- `textarea` - Multi-line text
- `file` - File upload
- `hidden` - Hidden field
- `select` - Dropdown select
- `checkbox` - Checkbox input
### 4. AdminTableFactory
Creates interactive tables using the existing Table module.
```php
final readonly class ExampleController
{
public function __construct(
private AdminTableFactory $tableFactory,
) {}
public function index(): ViewResult
{
$tableConfig = AdminTableConfig::create(/* ... */);
$data = $this->repository->findAll();
$table = $this->tableFactory->create($tableConfig, $data);
// Table automatically includes data attributes for JavaScript:
// data-resource="users"
// data-api-endpoint="/admin/api/users"
// data-sortable="true"
// data-searchable="true"
}
}
```
### 5. AdminFormFactory
Creates forms using the existing FormBuilder module.
```php
final readonly class ExampleController
{
public function __construct(
private AdminFormFactory $formFactory,
) {}
public function create(): ViewResult
{
$formConfig = new AdminFormConfig(/* ... */);
$form = $this->formFactory->create($formConfig);
// For edit forms, provide existing data:
$formConfig = $formConfig->withData($user->toArray());
$form = $this->formFactory->create($formConfig);
}
}
```
### 6. AdminPageRenderer
Consistent rendering for admin pages.
```php
final readonly class ExampleController
{
public function __construct(
private AdminPageRenderer $pageRenderer,
) {}
// Index/list page
public function index(): ViewResult
{
return $this->pageRenderer->renderIndex(
resource: 'users',
table: $table,
title: 'Users',
actions: [
[
'url' => '/admin/users/create',
'label' => 'Create User',
'icon' => 'plus',
],
]
);
}
// Form page (create/edit)
public function create(): ViewResult
{
return $this->pageRenderer->renderForm(
resource: 'users',
form: $form,
title: 'Create User',
subtitle: 'Add a new user to the system'
);
}
// Detail page
public function show(): ViewResult
{
return $this->pageRenderer->renderShow(
resource: 'users',
title: 'User Details',
data: [
'user' => $user->toArray(),
'stats' => $userStats,
]
);
}
}
```
### 7. AdminApiHandler
Auto-generated CRUD API endpoints.
```php
final readonly class ExampleController
{
public function __construct(
private AdminApiHandler $apiHandler,
private UserRepository $repository,
) {}
#[Route('/admin/api/users', Method::GET)]
public function apiList(HttpRequest $request): JsonResult
{
// Handles: page, per_page, sort_by, sort_dir, search
return $this->apiHandler->handleList($request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::GET)]
public function apiGet(string $id): JsonResult
{
return $this->apiHandler->handleGet($id, $this->repository);
}
#[Route('/admin/api/users', Method::POST)]
public function apiCreate(HttpRequest $request): JsonResult
{
return $this->apiHandler->handleCreate($request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::PUT)]
public function apiUpdate(string $id, HttpRequest $request): JsonResult
{
return $this->apiHandler->handleUpdate($id, $request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::DELETE)]
public function apiDelete(string $id): JsonResult
{
return $this->apiHandler->handleDelete($id, $this->repository);
}
}
```
### 8. JavaScript AdminDataTable Module
Interactive data table with AJAX operations.
**Auto-Initialization:**
```javascript
// Automatically initializes on elements with both attributes:
// [data-resource][data-api-endpoint]
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-resource][data-api-endpoint]').forEach(table => {
new AdminDataTable(table).init();
});
});
```
**Features:**
- ✅ AJAX loading with loading states
- ✅ Column sorting (click headers)
- ✅ Search/filtering (debounced)
- ✅ Pagination
- ✅ Empty state handling
- ✅ Error handling
- ✅ HTML escaping for security
**Data Attributes:**
```html
<table
data-resource="users"
data-api-endpoint="/admin/api/users"
data-sortable="true"
data-searchable="true"
data-paginated="true"
data-per-page="25"
>
<!-- Table content -->
</table>
```
## Complete Example
See `src/Application/Admin/UserAdminController.php` for a complete working example.
### Step 1: Create Controller with AdminResource
```php
#[AdminResource(
name: 'users',
singularName: 'User',
pluralName: 'Users',
icon: 'user',
enableApi: true,
enableCrud: true
)]
final readonly class UserAdminController
{
public function __construct(
private AdminPageRenderer $pageRenderer,
private AdminTableFactory $tableFactory,
private AdminFormFactory $formFactory,
private AdminApiHandler $apiHandler,
private UserRepository $repository,
) {}
}
```
### Step 2: Implement Index Route
```php
#[Route('/admin/users', Method::GET)]
public function index(): ViewResult
{
$tableConfig = AdminTableConfig::create(
resource: 'users',
columns: [
'id' => ['label' => 'ID', 'sortable' => true],
'email' => ['label' => 'Email', 'sortable' => true, 'searchable' => true],
'name' => ['label' => 'Name', 'sortable' => true, 'searchable' => true],
'created_at' => ['label' => 'Created', 'formatter' => 'date'],
'status' => ['label' => 'Status', 'formatter' => 'status'],
]
);
$users = $this->repository->findAll();
$table = $this->tableFactory->create($tableConfig, array_map(
fn($user) => $user->toArray(),
$users
));
return $this->pageRenderer->renderIndex(
resource: 'users',
table: $table,
title: 'Users',
actions: [
['url' => '/admin/users/create', 'label' => 'Create User', 'icon' => 'plus'],
]
);
}
```
### Step 3: Implement Create/Store Routes
```php
#[Route('/admin/users/create', Method::GET)]
public function create(): ViewResult
{
$formConfig = new AdminFormConfig(
resource: 'users',
action: '/admin/users',
method: Method::POST,
fields: [
'email' => ['type' => 'email', 'label' => 'Email', 'required' => true],
'name' => ['type' => 'text', 'label' => 'Name', 'required' => true],
'password' => ['type' => 'password', 'label' => 'Password', 'required' => true],
]
);
$form = $this->formFactory->create($formConfig);
return $this->pageRenderer->renderForm(
resource: 'users',
form: $form,
title: 'Create User'
);
}
#[Route('/admin/users', Method::POST)]
public function store(HttpRequest $request): Redirect
{
$data = $request->parsedBody->toArray();
$this->repository->create([
'email' => $data['email'],
'name' => $data['name'],
'password' => password_hash($data['password'], PASSWORD_DEFAULT),
]);
return new Redirect('/admin/users', status: 303, flashMessage: 'User created');
}
```
### Step 4: Implement Edit/Update Routes
```php
#[Route('/admin/users/{id}/edit', Method::GET)]
public function edit(string $id): ViewResult
{
$user = $this->repository->findById($id);
$formConfig = (new AdminFormConfig(
resource: 'users',
action: "/admin/users/{$id}",
method: Method::PUT,
fields: [
'email' => ['type' => 'email', 'label' => 'Email', 'required' => true],
'name' => ['type' => 'text', 'label' => 'Name', 'required' => true],
]
))->withData($user->toArray());
$form = $this->formFactory->create($formConfig);
return $this->pageRenderer->renderForm(
resource: 'users',
form: $form,
title: 'Edit User'
);
}
#[Route('/admin/users/{id}', Method::PUT)]
public function update(string $id, HttpRequest $request): Redirect
{
$data = $request->parsedBody->toArray();
$this->repository->update($id, [
'email' => $data['email'],
'name' => $data['name'],
]);
return new Redirect('/admin/users', status: 303, flashMessage: 'User updated');
}
```
### Step 5: Implement Delete Route
```php
#[Route('/admin/users/{id}', Method::DELETE)]
public function destroy(string $id): Redirect
{
$this->repository->delete($id);
return new Redirect('/admin/users', status: 303, flashMessage: 'User deleted');
}
```
### Step 6: Implement API Routes
```php
#[Route('/admin/api/users', Method::GET)]
public function apiList(HttpRequest $request): JsonResult
{
return $this->apiHandler->handleList($request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::GET)]
public function apiGet(string $id): JsonResult
{
return $this->apiHandler->handleGet($id, $this->repository);
}
#[Route('/admin/api/users', Method::POST)]
public function apiCreate(HttpRequest $request): JsonResult
{
return $this->apiHandler->handleCreate($request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::PUT)]
public function apiUpdate(string $id, HttpRequest $request): JsonResult
{
return $this->apiHandler->handleUpdate($id, $request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::DELETE)]
public function apiDelete(string $id): JsonResult
{
return $this->apiHandler->handleDelete($id, $this->repository);
}
```
## Templates
### admin-index.view.php
Used by `AdminPageRenderer::renderIndex()`.
```php
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{ title }}</h1>
<div class="page-actions">
<if condition="{{ actions }}">
<for items="{{ actions }}" value="action">
<a href="{{ action.url }}" class="btn btn-primary">
<if condition="{{ action.icon }}">
<i class="icon-{{ action.icon }}"></i>
</if>
{{ action.label }}
</a>
</for>
</if>
</div>
</div>
<if condition="{{ searchable }}">
<div class="table-controls">
<input type="text"
class="form-control search-input"
data-table-search="{{ resource }}"
placeholder="Search...">
</div>
</if>
<div class="table-container">
{{ table }}
</div>
<div class="pagination-container" data-table-pagination="{{ resource }}"></div>
</div>
```
### admin-form.view.php
Used by `AdminPageRenderer::renderForm()`.
```php
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="subtitle">{{ subtitle }}</p>
</if>
</div>
<div class="form-container">
<div class="card">
<div class="card-body">
{{ form }}
</div>
</div>
</div>
<div class="form-actions">
<a href="/admin/{{ resource }}" class="btn btn-secondary">Cancel</a>
</div>
</div>
```
## Framework Compliance
The Admin Framework follows all framework principles:
**No Inheritance** - Pure composition, all controllers are `final readonly`
**Readonly Everywhere** - All framework classes are `readonly`
**Value Objects** - AdminTableConfig, AdminFormConfig instead of arrays
**Explicit DI** - All dependencies via constructor injection
**Attribute-Driven** - `#[AdminResource]` for metadata
**Existing Module Integration** - Reuses Table and FormBuilder modules
**Type Safety** - Strong typing throughout, no primitive obsession
**Immutability** - Value objects with transformation methods
## Best Practices
1. **Use Value Objects for Configuration** - Always use AdminTableConfig and AdminFormConfig instead of raw arrays
2. **Leverage Formatters** - Use built-in formatters for dates, status badges, booleans, etc.
3. **Provide Rich Context** - Include helpful labels, placeholders, and help text in forms
4. **Enable JavaScript Features** - Use sortable, searchable, paginated for better UX
5. **Implement All API Endpoints** - Required for JavaScript table functionality
6. **Use Consistent Naming** - Resource name should match routes and repository
7. **Inject Dependencies** - Always inject factories, renderers, handlers via constructor
8. **Type-Safe Data** - Convert entities to arrays with `toArray()` method
## Testing
```php
// Test table creation
it('creates admin table with correct configuration', function () {
$config = AdminTableConfig::create(
resource: 'test',
columns: ['id' => ['label' => 'ID']]
);
$factory = new AdminTableFactory();
$table = $factory->create($config, [['id' => 1]]);
expect($table)->toBeInstanceOf(Table::class);
expect($table->render())->toContain('data-resource="test"');
});
// Test form creation
it('creates admin form with correct fields', function () {
$config = new AdminFormConfig(
resource: 'test',
action: '/test',
method: Method::POST,
fields: [
'name' => ['type' => 'text', 'label' => 'Name']
]
);
$factory = new AdminFormFactory(new FormIdGenerator());
$form = $factory->create($config);
expect($form)->toBeInstanceOf(FormBuilder::class);
expect($form->build())->toContain('name="name"');
});
// Test API handler
it('handles list API requests', function () {
$handler = new AdminApiHandler();
$repository = new InMemoryUserRepository();
$request = HttpRequest::fromGlobals();
$result = $handler->handleList($request, $repository);
expect($result)->toBeInstanceOf(JsonResult::class);
expect($result->data['success'])->toBeTrue();
expect($result->data)->toHaveKey('pagination');
});
```
## Troubleshooting
**Tables not loading data:**
- Check API endpoint is correct: `/admin/api/{resource}`
- Verify API route is registered with correct HTTP method
- Ensure repository implements `findAll()` method
- Check browser console for JavaScript errors
**Forms not submitting:**
- Verify form action URL matches route
- Check HTTP method matches (POST for create, PUT for update)
- Ensure CSRF token is included (handled by FormBuilder automatically)
**Search not working:**
- Enable `searchable: true` in AdminTableConfig
- Mark specific columns as searchable: `'searchable' => true`
- Implement search logic in repository `findAll($filters)` method
**Sorting not working:**
- Enable `sortable: true` in AdminTableConfig
- Mark specific columns as sortable: `'sortable' => true`
- Implement sorting in repository or API handler

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Services;
use App\Framework\Admin\Factories\AdminFormFactory;
use App\Framework\Admin\ValueObjects\AdminFormConfig;
use App\Framework\Admin\ValueObjects\CrudConfig;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\Redirect;
use App\Framework\Http\Responses\ViewResult;
use App\Framework\View\TemplateRenderer;
/**
* CRUD Service
*
* Service for handling admin CRUD operations using composition
*/
final readonly class CrudService
{
public function __construct(
private TemplateRenderer $renderer,
private AdminFormFactory $formFactory
) {}
/**
* Render index view
*/
public function renderIndex(
CrudConfig $config,
array $items,
Request $request,
?array $pagination = null
): ViewResult {
$data = [
...$config->toArray(),
'items' => $items,
'pagination' => $pagination,
'currentUrl' => $request->uri(),
'createUrl' => "/admin/{$config->resource}/create",
'editUrl' => "/admin/{$config->resource}/edit",
'viewUrl' => "/admin/{$config->resource}/view",
'deleteUrl' => "/admin/{$config->resource}/delete",
];
return new ViewResult('crud-index', $data);
}
/**
* Render create form
*/
public function renderCreate(
CrudConfig $config,
array $formFields,
?array $defaultData = null,
?string $helpText = null
): ViewResult {
$formConfig = new AdminFormConfig(
resource: $config->resource,
action: "/admin/{$config->resource}/store",
method: Method::POST,
fields: $formFields,
data: $defaultData ?? []
);
$form = $this->formFactory->create($formConfig);
$data = [
'title' => "Create {$config->resourceName}",
'subtitle' => "Add a new {$config->resourceName} to the system",
'formTitle' => "{$config->resourceName} Details",
'resourceName' => $config->resourceName,
'form' => $form->build(),
'formId' => $form->getId(),
'backUrl' => "/admin/{$config->resource}",
'saveAndContinue' => true,
'helpText' => $helpText,
];
return new ViewResult('crud-create', $data);
}
/**
* Render edit form
*/
public function renderEdit(
CrudConfig $config,
string $id,
array $formFields,
array $itemData,
?array $metadata = null,
?string $helpText = null
): ViewResult {
$formConfig = new AdminFormConfig(
resource: $config->resource,
action: "/admin/{$config->resource}/update/{$id}",
method: Method::PUT,
fields: $formFields,
data: $itemData
);
$form = $this->formFactory->create($formConfig);
$data = [
'title' => "Edit {$config->resourceName}",
'subtitle' => "Update {$config->resourceName} information",
'formTitle' => "{$config->resourceName} Details",
'resourceName' => $config->resourceName,
'form' => $form->build(),
'formId' => $form->getId(),
'backUrl' => "/admin/{$config->resource}",
'viewUrl' => "/admin/{$config->resource}/view/{$id}",
'deleteUrl' => "/admin/{$config->resource}/delete/{$id}",
'canView' => $config->canView,
'canDelete' => $config->canDelete,
'saveAndView' => true,
'metadata' => $metadata,
'helpText' => $helpText,
];
return new ViewResult('crud-edit', $data);
}
/**
* Render show/detail view
*/
public function renderShow(
CrudConfig $config,
string $id,
array $fields,
?array $metadata = null,
?array $relatedItems = null,
?array $actions = null
): ViewResult {
$data = [
'title' => "{$config->resourceName} Details",
'subtitle' => "View {$config->resourceName} information",
'detailsTitle' => "{$config->resourceName} Information",
'resourceName' => $config->resourceName,
'fields' => $fields,
'backUrl' => "/admin/{$config->resource}",
'editUrl' => "/admin/{$config->resource}/edit/{$id}",
'deleteUrl' => "/admin/{$config->resource}/delete/{$id}",
'canEdit' => $config->canEdit,
'canDelete' => $config->canDelete,
'metadata' => $metadata,
'relatedItems' => $relatedItems,
'actions' => $actions,
];
return new ViewResult('crud-show', $data);
}
/**
* Create redirect for successful create
*/
public function redirectAfterCreate(
CrudConfig $config,
Request $request,
mixed $itemId
): Redirect {
// Check if "save and continue" was requested
if ($request->parsedBody->get('action') === 'save-and-continue') {
return Redirect::to("/admin/{$config->resource}/create")
->withFlash('success', "{$config->resourceName} created successfully!");
}
return Redirect::to("/admin/{$config->resource}")
->withFlash('success', "{$config->resourceName} created successfully!");
}
/**
* Create redirect for successful update
*/
public function redirectAfterUpdate(
CrudConfig $config,
Request $request,
string $itemId
): Redirect {
// Check if "save and view" was requested
if ($request->parsedBody->get('action') === 'save-and-view') {
return Redirect::to("/admin/{$config->resource}/view/{$itemId}")
->withFlash('success', "{$config->resourceName} updated successfully!");
}
return Redirect::to("/admin/{$config->resource}")
->withFlash('success', "{$config->resourceName} updated successfully!");
}
/**
* Create redirect for successful delete
*/
public function redirectAfterDelete(CrudConfig $config): Redirect
{
return Redirect::to("/admin/{$config->resource}")
->withFlash('success', "{$config->resourceName} deleted successfully!");
}
/**
* Create redirect for errors
*/
public function redirectWithError(string $message, ?array $inputData = null): Redirect
{
$redirect = Redirect::back()->withFlash('error', $message);
if ($inputData !== null) {
$redirect = $redirect->withInput($inputData);
}
return $redirect;
}
}

View File

@@ -0,0 +1,64 @@
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="text-muted">{{ subtitle }}</p>
</if>
</div>
<div>
<a href="{{ backUrl }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
</div>
</div>
</div>
<div class="form-container">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ formTitle }}</h5>
</div>
<div class="card-body">
<if condition="{{ errors }}">
<div class="alert alert-danger">
<h6>Please fix the following errors:</h6>
<ul class="mb-0">
<for var="error" in="errors">
<li>{{ error }}</li>
</for>
</ul>
</div>
</if>
{{ form }}
<div class="form-actions mt-4 d-flex gap-2">
<button type="submit" form="{{ formId }}" class="btn btn-primary">
<i class="bi bi-save"></i> Create {{ resourceName }}
</button>
<if condition="{{ saveAndContinue }}">
<button type="submit" form="{{ formId }}" name="action" value="save-and-continue" class="btn btn-secondary">
<i class="bi bi-plus-circle"></i> Save & Create Another
</button>
</if>
<a href="{{ backUrl }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
<if condition="{{ helpText }}">
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-info-circle"></i> Help</h6>
</div>
<div class="card-body">
<p class="mb-0">{{ helpText }}</p>
</div>
</div>
</if>
</div>
</div>

View File

@@ -0,0 +1,150 @@
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="text-muted">{{ subtitle }}</p>
</if>
</div>
<div class="btn-group">
<if condition="{{ canView }}">
<a href="{{ viewUrl }}" class="btn btn-info">
<i class="bi bi-eye"></i> View
</a>
</if>
<a href="{{ backUrl }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ formTitle }}</h5>
<if condition="{{ lastModified }}">
<small class="text-muted">Last modified: {{ lastModified }}</small>
</if>
</div>
<div class="card-body">
<if condition="{{ errors }}">
<div class="alert alert-danger">
<h6>Please fix the following errors:</h6>
<ul class="mb-0">
<for var="error" in="errors">
<li>{{ error }}</li>
</for>
</ul>
</div>
</if>
<if condition="{{ successMessage }}">
<div class="alert alert-success">
{{ successMessage }}
</div>
</if>
{{ form }}
<div class="form-actions mt-4 d-flex gap-2">
<button type="submit" form="{{ formId }}" class="btn btn-primary">
<i class="bi bi-save"></i> Save Changes
</button>
<if condition="{{ saveAndView }}">
<button type="submit" form="{{ formId }}" name="action" value="save-and-view" class="btn btn-secondary">
Save & View
</button>
</if>
<a href="{{ backUrl }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<if condition="{{ metadata }}">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-info-circle"></i> Information</h6>
</div>
<div class="card-body">
<dl class="row mb-0">
<if condition="{{ metadata.createdAt }}">
<dt class="col-sm-5">Created:</dt>
<dd class="col-sm-7">{{ metadata.createdAt }}</dd>
</if>
<if condition="{{ metadata.updatedAt }}">
<dt class="col-sm-5">Updated:</dt>
<dd class="col-sm-7">{{ metadata.updatedAt }}</dd>
</if>
<if condition="{{ metadata.createdBy }}">
<dt class="col-sm-5">Created by:</dt>
<dd class="col-sm-7">{{ metadata.createdBy }}</dd>
</if>
<if condition="{{ metadata.status }}">
<dt class="col-sm-5">Status:</dt>
<dd class="col-sm-7">
<span class="badge bg-{{ metadata.statusColor }}">{{ metadata.status }}</span>
</dd>
</if>
</dl>
</div>
</div>
</if>
<if condition="{{ canDelete }}">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Danger Zone</h6>
</div>
<div class="card-body">
<p class="mb-2">Permanently delete this {{ resourceName }}.</p>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
<i class="bi bi-trash"></i> Delete {{ resourceName }}
</button>
</div>
</div>
</if>
<if condition="{{ helpText }}">
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-question-circle"></i> Help</h6>
</div>
<div class="card-body">
<p class="mb-0">{{ helpText }}</p>
</div>
</div>
</if>
</div>
</div>
</div>
<if condition="{{ canDelete }}">
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this {{ resourceName }}?</p>
<p class="text-danger mb-0"><strong>This action cannot be undone.</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ deleteUrl }}" class="d-inline">
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
</div>
</div>
</if>

View File

@@ -0,0 +1,165 @@
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="text-muted">{{ subtitle }}</p>
</if>
<div class="page-actions">
<if condition="{{ canCreate }}">
<a href="{{ createUrl }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create New
</a>
</if>
</div>
</div>
<if condition="{{ filters }}">
<div class="filters-section card mb-3">
<div class="card-body">
<form method="GET" class="row g-3">
<for var="filter" in="filters">
<div class="col-md-3">
<label class="form-label">{{ filter.label }}</label>
<if condition="{{ filter.type == 'select' }}">
<select name="{{ filter.name }}" class="form-select">
<option value="">All</option>
<for var="option" in="filter.options">
<option value="{{ option.value }}">{{ option.label }}</option>
</for>
</select>
</if>
<if condition="{{ filter.type == 'text' }}">
<input type="text" name="{{ filter.name }}" class="form-control" placeholder="{{ filter.placeholder }}">
</if>
</div>
</for>
<div class="col-md-12">
<button type="submit" class="btn btn-secondary">Filter</button>
<a href="{{ currentUrl }}" class="btn btn-link">Clear</a>
</div>
</form>
</div>
</div>
</if>
<div class="table-container card">
<div class="card-body">
<if condition="{{ items }}">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<if condition="{{ bulkActions }}">
<th width="50">
<input type="checkbox" class="form-check-input" id="select-all">
</th>
</if>
<for var="column" in="columns">
<th>{{ column.label }}</th>
</for>
<if condition="{{ hasActions }}">
<th width="150">Actions</th>
</if>
</tr>
</thead>
<tbody>
<for var="item" in="items">
<tr>
<if condition="{{ bulkActions }}">
<td>
<input type="checkbox" class="form-check-input item-checkbox" value="{{ item.id }}">
</td>
</if>
<for var="column" in="columns">
<td>{{ item[column.field] }}</td>
</for>
<if condition="{{ hasActions }}">
<td>
<div class="btn-group btn-group-sm">
<if condition="{{ canView }}">
<a href="{{ viewUrl }}/{{ item.id }}" class="btn btn-info" title="View">
<i class="bi bi-eye"></i>
</a>
</if>
<if condition="{{ canEdit }}">
<a href="{{ editUrl }}/{{ item.id }}" class="btn btn-warning" title="Edit">
<i class="bi bi-pencil"></i>
</a>
</if>
<if condition="{{ canDelete }}">
<button type="button" class="btn btn-danger"
data-delete-item="{{ item.id }}"
title="Delete">
<i class="bi bi-trash"></i>
</button>
</if>
</div>
</td>
</if>
</tr>
</for>
</tbody>
</table>
</div>
<if condition="{{ pagination }}">
<div class="pagination-wrapper">
<nav>
<ul class="pagination">
<if condition="{{ pagination.hasPrevious }}">
<li class="page-item">
<a class="page-link" href="{{ pagination.previousUrl }}">Previous</a>
</li>
</if>
<for var="page" in="pagination.pages">
<li class="page-item {{ page.active ? 'active' : '' }}">
<a class="page-link" href="{{ page.url }}">{{ page.number }}</a>
</li>
</for>
<if condition="{{ pagination.hasNext }}">
<li class="page-item">
<a class="page-link" href="{{ pagination.nextUrl }}">Next</a>
</li>
</if>
</ul>
</nav>
<div class="pagination-info text-muted">
Showing {{ pagination.from }} to {{ pagination.to }} of {{ pagination.total }} entries
</div>
</div>
</if>
</if>
<if condition="{{ !items }}">
<div class="empty-state text-center py-5">
<i class="bi bi-inbox" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">No items found.</p>
<if condition="{{ canCreate }}">
<a href="{{ createUrl }}" class="btn btn-primary mt-2">Create First Item</a>
</if>
</div>
</if>
</div>
</div>
<if condition="{{ bulkActions }}">
<div class="bulk-actions-bar" style="display: none;">
<div class="selected-count">
<span id="selected-count">0</span> items selected
</div>
<div class="bulk-action-buttons">
<for var="action" in="bulkActions">
<button type="button" class="btn btn-{{ action.variant }}" data-bulk-action="{{ action.name }}">
{{ action.label }}
</button>
</for>
</div>
</div>
</if>
</div>

View File

@@ -0,0 +1,231 @@
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="text-muted">{{ subtitle }}</p>
</if>
</div>
<div class="btn-group">
<if condition="{{ canEdit }}">
<a href="{{ editUrl }}" class="btn btn-warning">
<i class="bi bi-pencil"></i> Edit
</a>
</if>
<a href="{{ backUrl }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ detailsTitle }}</h5>
</div>
<div class="card-body">
<if condition="{{ fields }}">
<for var="field" in="fields">
<div class="row mb-3">
<div class="col-md-4">
<strong>{{ field.label }}:</strong>
</div>
<div class="col-md-8">
<if condition="{{ field.type == 'html' }}">
<div>{{{ field.value }}}</div>
</if>
<if condition="{{ field.type == 'badge' }}">
<span class="badge bg-{{ field.color }}">{{ field.value }}</span>
</if>
<if condition="{{ field.type == 'link' }}">
<a href="{{ field.url }}">{{ field.value }}</a>
</if>
<if condition="{{ field.type == 'image' }}">
<img src="{{ field.value }}" alt="{{ field.label }}" class="img-thumbnail" style="max-width: 200px;">
</if>
<if condition="{{ field.type == 'boolean' }}">
<i class="bi bi-{{ field.value ? 'check-circle text-success' : 'x-circle text-danger' }}"></i>
{{ field.value ? 'Yes' : 'No' }}
</if>
<if condition="{{ field.type == 'text' || !field.type }}">
{{ field.value || '-' }}
</if>
</div>
</div>
</for>
</if>
</div>
</div>
<if condition="{{ relatedItems }}">
<for var="relation" in="relatedItems">
<div class="card mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">{{ relation.title }}</h6>
<if condition="{{ relation.createUrl }}">
<a href="{{ relation.createUrl }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus"></i> Add
</a>
</if>
</div>
<div class="card-body">
<if condition="{{ relation.items }}">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<for var="column" in="relation.columns">
<th>{{ column.label }}</th>
</for>
<if condition="{{ relation.hasActions }}">
<th width="100">Actions</th>
</if>
</tr>
</thead>
<tbody>
<for var="item" in="relation.items">
<tr>
<for var="column" in="relation.columns">
<td>{{ item[column.field] }}</td>
</for>
<if condition="{{ relation.hasActions }}">
<td>
<div class="btn-group btn-group-sm">
<if condition="{{ relation.canEdit }}">
<a href="{{ relation.editUrl }}/{{ item.id }}" class="btn btn-outline-warning btn-sm">
<i class="bi bi-pencil"></i>
</a>
</if>
<if condition="{{ relation.canDelete }}">
<button type="button" class="btn btn-outline-danger btn-sm" data-delete-related="{{ item.id }}">
<i class="bi bi-trash"></i>
</button>
</if>
</div>
</td>
</if>
</tr>
</for>
</tbody>
</table>
</div>
</if>
<if condition="{{ !relation.items }}">
<p class="text-muted text-center mb-0">No {{ relation.title }} found.</p>
</if>
</div>
</div>
</for>
</if>
</div>
<div class="col-lg-4">
<if condition="{{ metadata }}">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-info-circle"></i> Metadata</h6>
</div>
<div class="card-body">
<dl class="row mb-0">
<if condition="{{ metadata.id }}">
<dt class="col-sm-5">ID:</dt>
<dd class="col-sm-7"><code>{{ metadata.id }}</code></dd>
</if>
<if condition="{{ metadata.createdAt }}">
<dt class="col-sm-5">Created:</dt>
<dd class="col-sm-7">{{ metadata.createdAt }}</dd>
</if>
<if condition="{{ metadata.updatedAt }}">
<dt class="col-sm-5">Updated:</dt>
<dd class="col-sm-7">{{ metadata.updatedAt }}</dd>
</if>
<if condition="{{ metadata.createdBy }}">
<dt class="col-sm-5">Created by:</dt>
<dd class="col-sm-7">{{ metadata.createdBy }}</dd>
</if>
<if condition="{{ metadata.status }}">
<dt class="col-sm-5">Status:</dt>
<dd class="col-sm-7">
<span class="badge bg-{{ metadata.statusColor }}">{{ metadata.status }}</span>
</dd>
</if>
</dl>
</div>
</div>
</if>
<if condition="{{ actions }}">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-lightning"></i> Quick Actions</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<for var="action" in="actions">
<if condition="{{ action.type == 'link' }}">
<a href="{{ action.url }}" class="btn btn-{{ action.variant || 'secondary' }}">
<if condition="{{ action.icon }}">
<i class="bi bi-{{ action.icon }}"></i>
</if>
{{ action.label }}
</a>
</if>
<if condition="{{ action.type == 'button' }}">
<button type="button" class="btn btn-{{ action.variant || 'secondary' }}" data-action="{{ action.name }}">
<if condition="{{ action.icon }}">
<i class="bi bi-{{ action.icon }}"></i>
</if>
{{ action.label }}
</button>
</if>
</for>
</div>
</div>
</div>
</if>
<if condition="{{ canDelete }}">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Danger Zone</h6>
</div>
<div class="card-body">
<p class="mb-2">Permanently delete this {{ resourceName }}.</p>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</if>
</div>
</div>
</div>
<if condition="{{ canDelete }}">
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this {{ resourceName }}?</p>
<p class="text-danger mb-0"><strong>This action cannot be undone.</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ deleteUrl }}" class="d-inline">
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
</div>
</div>
</if>

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\ValueObjects;
use App\Framework\Http\Method;
/**
* Admin Form Configuration
*
* Configuration for creating admin forms
*/
final readonly class AdminFormConfig
{
/**
* @param array<string, array{type: string, label: string, required?: bool, options?: array, placeholder?: string, help?: string}> $fields
* @param array<string, mixed> $data
*/
public function __construct(
public string $resource,
public string $action,
public Method $method,
public array $fields,
public array $data = [],
) {
}
public static function create(
string $resource,
string $action,
Method $method,
array $fields
): self {
return new self(
resource: $resource,
action: $action,
method: $method,
fields: $fields
);
}
public function withData(array $data): self
{
return new self(
resource: $this->resource,
action: $this->action,
method: $this->method,
fields: $this->fields,
data: [...$this->data, ...$data]
);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\ValueObjects;
/**
* Admin Table Configuration
*
* Configuration for creating admin data tables
*/
final readonly class AdminTableConfig
{
/**
* @param array<string, array{label: string, sortable?: bool, searchable?: bool, formatter?: string, class?: string}> $columns
*/
public function __construct(
public array $columns,
public string $resource,
public bool $sortable = true,
public bool $searchable = true,
public bool $paginated = true,
public int $perPage = 25,
public ?string $apiEndpoint = null,
) {
}
public static function create(
string $resource,
array $columns,
bool $sortable = true,
bool $searchable = true
): self {
return new self(
columns: $columns,
resource: $resource,
sortable: $sortable,
searchable: $searchable,
apiEndpoint: "/admin/api/{$resource}"
);
}
public function getApiEndpoint(): string
{
return $this->apiEndpoint ?? "/admin/api/{$this->resource}";
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\ValueObjects;
/**
* CRUD Configuration Value Object
*
* Configuration for admin CRUD operations
*/
final readonly class CrudConfig
{
/**
* @param string $resource Resource name (e.g., 'campaigns', 'users')
* @param string $resourceName Singular display name (e.g., 'Campaign', 'User')
* @param string $title Page title
* @param array<string, mixed> $columns Column definitions for index view
* @param bool $canCreate Can create new items
* @param bool $canEdit Can edit items
* @param bool $canView Can view items
* @param bool $canDelete Can delete items
* @param array<string, mixed>|null $filters Filter definitions
* @param array<string, mixed>|null $bulkActions Bulk action definitions
* @param bool $searchable Enable search
*/
public function __construct(
public string $resource,
public string $resourceName,
public string $title,
public array $columns = [],
public bool $canCreate = true,
public bool $canEdit = true,
public bool $canView = true,
public bool $canDelete = true,
public ?array $filters = null,
public ?array $bulkActions = null,
public bool $searchable = true,
) {}
public static function forResource(
string $resource,
string $resourceName,
string $title
): self {
return new self(
resource: $resource,
resourceName: $resourceName,
title: $title
);
}
public function withColumns(array $columns): self
{
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $columns,
canCreate: $this->canCreate,
canEdit: $this->canEdit,
canView: $this->canView,
canDelete: $this->canDelete,
filters: $this->filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable
);
}
public function withPermissions(
bool $canCreate = true,
bool $canEdit = true,
bool $canView = true,
bool $canDelete = true
): self {
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $this->columns,
canCreate: $canCreate,
canEdit: $canEdit,
canView: $canView,
canDelete: $canDelete,
filters: $this->filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable
);
}
public function withFilters(array $filters): self
{
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $this->columns,
canCreate: $this->canCreate,
canEdit: $this->canEdit,
canView: $this->canView,
canDelete: $this->canDelete,
filters: $filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable
);
}
public function withBulkActions(array $bulkActions): self
{
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $this->columns,
canCreate: $this->canCreate,
canEdit: $this->canEdit,
canView: $this->canView,
canDelete: $this->canDelete,
filters: $this->filters,
bulkActions: $bulkActions,
searchable: $this->searchable
);
}
public function toArray(): array
{
return [
'resource' => $this->resource,
'resourceName' => $this->resourceName,
'title' => $this->title,
'columns' => $this->columns,
'canCreate' => $this->canCreate,
'canEdit' => $this->canEdit,
'canView' => $this->canView,
'canDelete' => $this->canDelete,
'hasActions' => $this->canEdit || $this->canView || $this->canDelete,
'filters' => $this->filters,
'bulkActions' => $this->bulkActions,
'searchable' => $this->searchable,
];
}
}

View File

@@ -4,34 +4,62 @@ declare(strict_types=1);
namespace App\Framework\Attributes;
use App\Framework\Core\StaticRoute;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\Http\Method;
use App\Framework\Router\RouteNameInterface;
use App\Framework\Router\ValueObjects\ParameterCollection;
use App\Framework\Router\ValueObjects\RoutePath;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final readonly class Route
{
public function __construct(
public string $path,
public string|RoutePath $path,
public Method $method = Method::GET,
public ?string $name = null,
public RouteNameInterface|string|null $name = null,
/** @var array<int, string>|string */
public array|string $subdomain = [],
) {
}
/**
* Get the path as string for router compilation
*/
public function getPathAsString(): string
{
return $this->path instanceof RoutePath
? $this->path->toString()
: $this->path;
}
/**
* Get the path as RoutePath object
*/
public function getRoutePath(): RoutePath
{
return $this->path instanceof RoutePath
? $this->path
: RoutePath::fromString($this->path);
}
/**
* Transform this attribute into a CompiledRoute for the router
*/
public function toCompiledRoute(ClassName $controller, MethodName $action): CompiledRoute
{
$routeName = $this->name instanceof RouteNameInterface
? $this->name->value
: $this->name;
return new CompiledRoute(
path: $this->path,
path: $this->getPathAsString(),
method: $this->method,
controller: $controller->getFullyQualified(),
action: $action->toString(),
name: $this->name
name: $routeName
);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Commands;
use App\Framework\BuildTime\Discovery\Scanners\AttributeScanner;
use App\Framework\BuildTime\Discovery\Scanners\InterfaceScanner;
use App\Framework\BuildTime\Discovery\Scanners\TemplateScanner;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Core\PathProvider;
use App\Framework\Discovery\Storage\DiscoveryStorageService;
/**
* Bootstrap command that runs all discovery scanners
*
* This command doesn't rely on discovery itself - it's manually registered
*/
#[ConsoleCommand(
name: 'discovery:bootstrap',
description: 'Run all discovery scanners and build cache'
)]
final readonly class BootstrapDiscoveryCommand
{
public function __construct(
private AttributeScanner $attributeScanner,
private TemplateScanner $templateScanner,
private InterfaceScanner $interfaceScanner,
private DiscoveryStorageService $storage,
private PathProvider $pathProvider,
private ConsoleOutput $output
) {
}
public function execute(ConsoleInput $input): int
{
$this->output->info('Bootstrapping discovery system...');
$totalStart = microtime(true);
// 1. Discover Attributes
$this->output->writeln("\n📦 Discovering attributes...");
$attrStart = microtime(true);
$paths = [$this->pathProvider->getSourcePath()];
$attributeRegistry = $this->attributeScanner->scan($paths);
$this->storage->storeAttributes($attributeRegistry);
$attrDuration = round((microtime(true) - $attrStart) * 1000, 2);
$this->output->success("{$attributeRegistry->count()} attributes in {$attrDuration}ms");
// 2. Discover Templates
$this->output->writeln("\n📄 Discovering templates...");
$tplStart = microtime(true);
$templatePaths = [
$this->pathProvider->getSourcePath(),
$this->pathProvider->getBasePath() . '/resources'
];
$templateRegistry = $this->templateScanner->scan($templatePaths);
$this->storage->storeTemplates($templateRegistry);
$tplDuration = round((microtime(true) - $tplStart) * 1000, 2);
$this->output->success("" . count($templateRegistry->getAll()) . " templates in {$tplDuration}ms");
// 3. Discover Interfaces
$this->output->writeln("\n🔌 Discovering interface implementations...");
$intStart = microtime(true);
$interfaceRegistry = $this->interfaceScanner->scan($paths);
$this->storage->storeInterfaces($interfaceRegistry);
$intDuration = round((microtime(true) - $intStart) * 1000, 2);
$this->output->success("{$interfaceRegistry->count()} implementations in {$intDuration}ms");
// Summary
$totalDuration = round((microtime(true) - $totalStart) * 1000, 2);
$this->output->writeln("\n" . str_repeat("=", 60));
$this->output->success("🎉 Discovery bootstrap complete in {$totalDuration}ms");
$this->output->writeln(" 📁 Stored in: storage/discovery/");
$this->output->writeln(str_repeat("=", 60));
return 0;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Commands;
use App\Framework\BuildTime\Discovery\Scanners\AttributeScanner;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Core\PathProvider;
use App\Framework\Discovery\Storage\DiscoveryStorageService;
/**
* Discover all attributes and store them for runtime use
*/
#[ConsoleCommand(
name: 'discovery:attributes',
description: 'Discover and cache all PHP attributes (Routes, Initializers, etc.)'
)]
final readonly class DiscoverAttributesCommand
{
public function __construct(
private AttributeScanner $scanner,
private DiscoveryStorageService $storage,
private PathProvider $pathProvider,
private ConsoleOutput $output
) {
}
public function execute(ConsoleInput $input): int
{
$this->output->info('Discovering attributes...');
$startTime = microtime(true);
// Scan src directory for attributes
$paths = [$this->pathProvider->getSourcePath()];
$registry = $this->scanner->scan($paths);
// Store for runtime
$this->storage->storeAttributes($registry);
$duration = round((microtime(true) - $startTime) * 1000, 2);
$count = $registry->count();
$this->output->success("Discovered {$count} attributes in {$duration}ms");
$this->output->writeln(' Stored in: storage/discovery/attributes.php');
return 0;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Commands;
use App\Framework\BuildTime\Discovery\Scanners\InterfaceScanner;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Core\PathProvider;
use App\Framework\Discovery\Storage\DiscoveryStorageService;
/**
* Discover all interface implementations and store them for runtime use
*/
#[ConsoleCommand(
name: 'discovery:interfaces',
description: 'Discover and cache all interface implementations'
)]
final readonly class DiscoverInterfacesCommand
{
public function __construct(
private InterfaceScanner $scanner,
private DiscoveryStorageService $storage,
private PathProvider $pathProvider,
private ConsoleOutput $output
) {
}
public function execute(ConsoleInput $input): int
{
$this->output->info('Discovering interface implementations...');
$startTime = microtime(true);
// Scan src directory for interface implementations
$paths = [$this->pathProvider->getSourcePath()];
$registry = $this->scanner->scan($paths);
// Store for runtime
$this->storage->storeInterfaces($registry);
$duration = round((microtime(true) - $startTime) * 1000, 2);
$count = $registry->getTotalMappings();
$this->output->success("Discovered {$count} interface implementations in {$duration}ms");
$this->output->writeln(' Stored in: storage/discovery/interfaces.php');
// Show sample interface mappings
$this->output->writeln("\nSample interface implementations:");
$all = $registry->getAll();
$sample = array_slice($all, 0, 3);
foreach ($sample as $interfaceName => $implementations) {
$this->output->writeln(" {$interfaceName}:");
foreach (array_slice($implementations, 0, 2) as $impl) {
$this->output->writeln(" - {$impl}");
}
}
return 0;
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Commands;
use App\Framework\BuildTime\Discovery\Scanners\TemplateScanner;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Core\PathProvider;
use App\Framework\Discovery\Storage\DiscoveryStorageService;
/**
* Discover all templates and store them for runtime use
*/
#[ConsoleCommand(
name: 'discovery:templates',
description: 'Discover and cache all .view.php templates'
)]
final readonly class DiscoverTemplatesCommand
{
public function __construct(
private TemplateScanner $scanner,
private DiscoveryStorageService $storage,
private PathProvider $pathProvider,
private ConsoleOutput $output
) {
}
public function execute(ConsoleInput $input): int
{
$this->output->info('Discovering templates...');
$startTime = microtime(true);
// Scan src and resources directories for templates
$paths = [
$this->pathProvider->getSourcePath(),
$this->pathProvider->getBasePath() . '/resources'
];
$registry = $this->scanner->scan($paths);
// Store for runtime
$this->storage->storeTemplates($registry);
$duration = round((microtime(true) - $startTime) * 1000, 2);
$count = count($registry->getAll());
$this->output->success("Discovered {$count} templates in {$duration}ms");
$this->output->writeln(' Stored in: storage/discovery/templates.php');
// Show sample templates
$this->output->writeln("\nSample templates:");
$templates = array_slice($registry->getAll(), 0, 5);
foreach ($templates as $template) {
$this->output->writeln(" - {$template->name} ({$template->path})");
}
return 0;
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Scanners;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\Discovery\Results\AttributeRegistry;
use App\Framework\Discovery\ValueObjects\AttributeTarget;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\FileScanner;
use App\Framework\Filesystem\ValueObjects\FilePattern;
use App\Framework\Reflection\ReflectionProvider;
use App\Framework\Reflection\WrappedReflectionAttribute;
/**
* Specialized scanner for PHP attributes
*
* Scans PHP files for classes with attributes like:
* - #[Route] - HTTP routes
* - #[Initializer] - DI initializers
* - #[ConsoleCommand] - CLI commands
* - #[McpTool] / #[McpResource] - MCP integration
*/
final readonly class AttributeScanner
{
public function __construct(
private FileScanner $fileScanner,
private ReflectionProvider $reflectionProvider
) {
}
/**
* Scan directories for PHP attributes
*
* @param array<string> $paths
*/
public function scan(array $paths): AttributeRegistry
{
$registry = new AttributeRegistry();
foreach ($paths as $path) {
$this->scanDirectory($path, $registry);
}
return $registry;
}
/**
* Scan single directory for attributes
*/
private function scanDirectory(string $path, AttributeRegistry $registry): void
{
$filePath = FilePath::create($path);
// Scan all PHP files
$pattern = FilePattern::php();
$files = $this->fileScanner->findFiles($filePath, $pattern);
foreach ($files as $file) {
$this->scanFile($file->getPath()->toString(), $registry);
}
}
/**
* Scan single PHP file for attributes
*/
private function scanFile(string $filePath, AttributeRegistry $registry): void
{
// Extract class names from file
$classNames = $this->extractClassNames($filePath);
foreach ($classNames as $className) {
$this->scanClass($className, $filePath, $registry);
}
}
/**
* Scan single class for attributes
*/
private function scanClass(string $className, string $filePath, AttributeRegistry $registry): void
{
try {
$reflection = $this->reflectionProvider->getClass(ClassName::create($className));
// Get all attributes from class
$attributes = $reflection->getAttributes();
foreach ($attributes->toArray() as $attribute) {
$discovered = $this->createDiscoveredAttribute(
$attribute,
$className,
$filePath,
AttributeTarget::TARGET_CLASS
);
$registry->add($attribute->getName(), $discovered);
}
// Also scan methods for attributes (like #[Route] on methods)
foreach ($reflection->getMethods()->toArray() as $method) {
$methodAttributes = $method->getAttributes();
foreach ($methodAttributes->toArray() as $attribute) {
$discovered = $this->createDiscoveredAttribute(
$attribute,
$className,
$filePath,
AttributeTarget::METHOD,
$method->getName()
);
$registry->add($attribute->getName(), $discovered);
}
}
} catch (\Throwable $e) {
// Skip classes that can't be reflected (e.g., missing dependencies)
return;
}
}
/**
* Create DiscoveredAttribute from WrappedReflectionAttribute
*/
private function createDiscoveredAttribute(
WrappedReflectionAttribute $attribute,
string $className,
string $filePath,
AttributeTarget $target,
?string $methodName = null
): DiscoveredAttribute {
// Get the actual attribute instance to extract arguments
$instance = $attribute->newInstance();
// Extract arguments from the instance if available
$arguments = [];
if ($instance) {
// Get reflection of the attribute instance to extract constructor arguments
$reflection = new \ReflectionObject($instance);
$constructor = $reflection->getConstructor();
if ($constructor) {
foreach ($constructor->getParameters() as $param) {
$propName = $param->getName();
if ($reflection->hasProperty($propName)) {
$property = $reflection->getProperty($propName);
// PHP 8.4+ properties are always accessible, no need for setAccessible()
$arguments[$propName] = $property->getValue($instance);
}
}
}
}
return new DiscoveredAttribute(
className: ClassName::create($className),
attributeClass: $attribute->getName(),
target: $target,
methodName: $methodName ? MethodName::create($methodName) : null,
propertyName: null,
arguments: $arguments,
filePath: FilePath::create($filePath),
additionalData: []
);
}
/**
* Extract class names from PHP file
*
* Simple regex-based extraction - faster than full parsing
*
* @return array<string>
*/
private function extractClassNames(string $filePath): array
{
$content = file_get_contents($filePath);
if ($content === false) {
return [];
}
$classNames = [];
// Extract namespace
$namespace = '';
if (preg_match('/^namespace\s+([a-zA-Z0-9\\\\]+);/m', $content, $matches)) {
$namespace = $matches[1];
}
// Extract class/interface/trait/enum names
$pattern = '/^(?:final\s+)?(?:readonly\s+)?(?:abstract\s+)?(class|interface|trait|enum)\s+([a-zA-Z0-9_]+)/m';
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$className = $match[2];
$classNames[] = $namespace ? $namespace . '\\' . $className : $className;
}
}
return $classNames;
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Scanners;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Discovery\Results\InterfaceRegistry;
use App\Framework\Discovery\ValueObjects\InterfaceMapping;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\FileScanner;
use App\Framework\Filesystem\ValueObjects\FilePattern;
use App\Framework\Reflection\ReflectionProvider;
/**
* Specialized scanner for interface implementations
*
* Finds all classes that implement specific interfaces.
* Useful for DI container auto-wiring and service discovery.
*/
final readonly class InterfaceScanner
{
public function __construct(
private FileScanner $fileScanner,
private ReflectionProvider $reflectionProvider,
/** @var array<int, class-string> */
private array $targetInterfaces = []
) {
}
/**
* Scan directories for interface implementations
*
* @param array<string> $paths
*/
public function scan(array $paths): InterfaceRegistry
{
$registry = new InterfaceRegistry();
foreach ($paths as $path) {
$this->scanDirectory($path, $registry);
}
return $registry;
}
/**
* Scan single directory for interface implementations
*/
private function scanDirectory(string $path, InterfaceRegistry $registry): void
{
$filePath = FilePath::create($path);
// Scan all PHP files
$pattern = FilePattern::php();
$files = $this->fileScanner->findFiles($filePath, $pattern);
foreach ($files as $file) {
$this->scanFile($file->getPath()->toString(), $registry);
}
}
/**
* Scan single PHP file for interface implementations
*/
private function scanFile(string $filePath, InterfaceRegistry $registry): void
{
// Extract class names from file
$classNames = $this->extractClassNames($filePath);
foreach ($classNames as $className) {
$this->scanClass($className, $filePath, $registry);
}
}
/**
* Scan single class for interface implementations
*/
private function scanClass(string $className, string $filePath, InterfaceRegistry $registry): void
{
try {
$reflection = $this->reflectionProvider->getClass(ClassName::create($className));
// Skip interfaces and traits
if ($reflection->isInterface() || $reflection->isTrait()) {
return;
}
// Get all interfaces this class implements
$interfaces = $reflection->getInterfaceNames();
foreach ($interfaces as $interfaceName) {
// If we have target interfaces, only add those
if (!empty($this->targetInterfaces) && !in_array($interfaceName, $this->targetInterfaces, true)) {
continue;
}
// Create mapping with file path
$mapping = InterfaceMapping::create($interfaceName, $className, $filePath);
$registry->add($mapping);
}
} catch (\Throwable $e) {
// Skip classes that can't be reflected
return;
}
}
/**
* Extract class names from PHP file
*
* @return array<string>
*/
private function extractClassNames(string $filePath): array
{
$content = file_get_contents($filePath);
if ($content === false) {
return [];
}
$classNames = [];
// Extract namespace
$namespace = '';
if (preg_match('/^namespace\s+([a-zA-Z0-9\\\\]+);/m', $content, $matches)) {
$namespace = $matches[1];
}
// Extract class/enum names (not interfaces or traits)
$pattern = '/^(?:final\s+)?(?:readonly\s+)?(?:abstract\s+)?(class|enum)\s+([a-zA-Z0-9_]+)/m';
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$className = $match[2];
$classNames[] = $namespace ? $namespace . '\\' . $className : $className;
}
}
return $classNames;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Scanners;
use App\Framework\Discovery\Results\TemplateRegistry;
use App\Framework\Discovery\ValueObjects\TemplateMapping;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\FileScanner;
use App\Framework\Filesystem\ValueObjects\FilePattern;
/**
* Specialized scanner for template files (.view.php)
*
* Much faster than UnifiedDiscoveryService because it:
* - Only looks for .view.php files
* - Doesn't parse PHP code or extract classes
* - Simple file-to-template mapping
*/
final readonly class TemplateScanner
{
public function __construct(
private FileScanner $fileScanner
) {
}
/**
* Scan directories for .view.php templates
*
* @param array<string> $paths
*/
public function scan(array $paths): TemplateRegistry
{
$registry = new TemplateRegistry();
foreach ($paths as $path) {
$this->scanDirectory($path, $registry);
}
return $registry;
}
/**
* Scan single directory for templates
*/
private function scanDirectory(string $path, TemplateRegistry $registry): void
{
$filePath = FilePath::create($path);
// Only scan for .view.php files
$pattern = FilePattern::create('*.view.php');
$files = $this->fileScanner->findFiles($filePath, $pattern);
foreach ($files as $file) {
$fullPath = $file->getPath()->toString();
// Extract template name from filename
$templateName = $this->extractTemplateName($fullPath);
// Create mapping
$mapping = TemplateMapping::create($templateName, $fullPath);
$registry->add($mapping);
}
}
/**
* Extract template name from file path
*
* Examples:
* - /path/to/dashboard.view.php → dashboard
* - /path/to/admin/users.view.php → admin/users
*/
private function extractTemplateName(string $filePath): string
{
// Remove .view.php extension
$withoutExtension = str_replace('.view.php', '', $filePath);
// Get relative path from src/ or resources/
if (str_contains($withoutExtension, '/src/')) {
$parts = explode('/src/', $withoutExtension);
$relativePath = end($parts);
} elseif (str_contains($withoutExtension, '/resources/')) {
$parts = explode('/resources/', $withoutExtension);
$relativePath = end($parts);
} else {
// Fallback: just use basename
$relativePath = basename($withoutExtension);
}
// Remove leading slashes
return ltrim($relativePath, '/');
}
}

View File

@@ -56,6 +56,11 @@ final readonly class CacheInitializer
// L2 Cache: Persistent cache with compression
try {
// Check if Redis extension is available
if (! extension_loaded('redis')) {
throw new \RuntimeException('Redis extension is not loaded. Please install php-redis extension or use alternative cache drivers.');
}
$redisConfig = new RedisConfig(
host: $this->redisHost,
port: $this->redisPort,

View File

@@ -8,6 +8,7 @@ use App\Framework\Cache\Cache;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ExitCode;
use App\Framework\Core\PathProvider;
use App\Framework\DI\Container;
use App\Framework\Redis\RedisConnectionPool;
@@ -22,7 +23,7 @@ final readonly class ClearCache
}
#[ConsoleCommand("cache:clear", "Clears all caches (application, discovery, routes, opcache, redis)")]
public function __invoke(ConsoleInput $input, ConsoleOutput $output): void
public function __invoke(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$cleared = [];
@@ -58,17 +59,19 @@ final readonly class ClearCache
$cleared[] = 'All cache files';
$output->writeSuccess('Cleared: ' . implode(', ', $cleared));
return ExitCode::SUCCESS;
}
#[ConsoleCommand("redis:flush", "Advanced Redis cache clearing with options")]
public function flushRedis(ConsoleInput $input, ConsoleOutput $output): void
public function flushRedis(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$cleared = [];
if (! $this->container->has(RedisConnectionPool::class)) {
$output->writeError('Redis connection pool not available');
return;
return ExitCode::FAILURE;
}
try {
@@ -86,7 +89,7 @@ final readonly class ClearCache
} else {
$output->writeError("Failed to flush Redis database $database");
return;
return ExitCode::FAILURE;
}
} else {
// Default: FLUSHALL
@@ -96,13 +99,17 @@ final readonly class ClearCache
} else {
$output->writeError('Failed to flush Redis');
return;
return ExitCode::FAILURE;
}
}
$output->writeSuccess('Cleared: ' . implode(', ', $cleared));
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeError('Redis flush failed: ' . $e->getMessage());
return ExitCode::FAILURE;
}
}

View File

@@ -7,15 +7,19 @@ namespace App\Framework\Cache;
use App\Framework\Async\AsyncService;
use App\Framework\Cache\Contracts\DriverAccessible;
use App\Framework\Cache\Contracts\Scannable;
use App\Framework\Cache\Strategies\CacheStrategyManager;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Smart cache implementation that replaces AsyncAwareCache
* Smart cache implementation with composable strategy support
*
* Features:
* - Integrated async functionality with intelligent batching
* - Pattern and prefix support for bulk operations
* - Automatic async/sync decision making
* - Composable cache strategies (Adaptive TTL, Heat Mapping, Predictive Warming)
* - Strategy-based cache optimization and intelligence
* - Backward compatibility with existing Cache interface
*/
final class SmartCache implements Cache, DriverAccessible
@@ -24,12 +28,22 @@ final class SmartCache implements Cache, DriverAccessible
private const int LARGE_BATCH_THRESHOLD = 20; // Extra optimization for large batches
private readonly ?TagIndex $tagIndex;
private readonly ?CacheStrategyManager $strategyManager;
public function __construct(
private readonly Cache $innerCache,
private readonly ?AsyncService $asyncService = null,
private readonly bool $asyncEnabled = true
private readonly bool $asyncEnabled = true,
?CacheStrategyManager $strategyManager = null,
private readonly bool $enableDefaultStrategies = true
) {
// Initialize strategy manager with defaults if enabled and none provided
if ($strategyManager === null && $this->enableDefaultStrategies) {
$this->strategyManager = CacheStrategyManager::createDefault();
} else {
$this->strategyManager = $strategyManager;
}
// Initialize tag index if we have access to a scannable driver
$driver = $this->getDriver();
$this->tagIndex = ($driver instanceof Scannable) ? new TagIndex($driver) : null;
@@ -60,11 +74,24 @@ final class SmartCache implements Cache, DriverAccessible
// Handle exact keys with potential async optimization
if (! empty($exactKeys)) {
$startTime = Timestamp::now();
if ($this->shouldUseAsync(count($exactKeys))) {
$results[] = $this->getAsync($exactKeys);
} else {
$results[] = $this->innerCache->get(...$exactKeys);
}
// Notify strategies of cache access
if ($this->strategyManager) {
$endTime = Timestamp::now();
$retrievalTime = $startTime->diff($endTime);
foreach ($exactKeys as $key) {
$isHit = $results[count($results) - 1]->getItem($key)->isHit;
$this->strategyManager->notifyAccess($key, $isHit, $retrievalTime);
}
}
}
// Handle patterns - these require special processing
@@ -97,12 +124,23 @@ final class SmartCache implements Cache, DriverAccessible
$this->monitorCacheItemSize($item);
}
// Use async for large batches
if ($this->shouldUseAsync(count($items))) {
return $this->setAsync($items);
// Apply strategies to modify TTL if needed
$modifiedItems = [];
foreach ($items as $item) {
if ($this->strategyManager) {
$adaptedTtl = $this->strategyManager->notifySet($item->key, $item->value, $item->ttl);
$modifiedItems[] = CacheItem::forSet($item->key, $item->value, $adaptedTtl);
} else {
$modifiedItems[] = $item;
}
}
return $this->innerCache->set(...$items);
// Use async for large batches
if ($this->shouldUseAsync(count($modifiedItems))) {
return $this->setAsync($modifiedItems);
}
return $this->innerCache->set(...$modifiedItems);
}
public function has(CacheIdentifier ...$identifiers): array
@@ -184,6 +222,13 @@ final class SmartCache implements Cache, DriverAccessible
// Handle exact keys
if (! empty($exactKeys)) {
// Notify strategies before forgetting
if ($this->strategyManager) {
foreach ($exactKeys as $key) {
$this->strategyManager->notifyForget($key);
}
}
if ($this->shouldUseAsync(count($exactKeys))) {
$success &= $this->forgetAsync($exactKeys);
} else {
@@ -219,7 +264,23 @@ final class SmartCache implements Cache, DriverAccessible
public function remember(CacheKey $key, callable $callback, ?Duration $ttl = null): CacheItem
{
return $this->innerCache->remember($key, $callback, $ttl);
$startTime = Timestamp::now();
// Apply strategies to TTL if available
if ($this->strategyManager) {
$ttl = $this->strategyManager->notifySet($key, null, $ttl);
}
$result = $this->innerCache->remember($key, $callback, $ttl);
// Notify strategies of access
if ($this->strategyManager) {
$endTime = Timestamp::now();
$retrievalTime = $startTime->diff($endTime);
$this->strategyManager->notifyAccess($key, $result->isHit, $retrievalTime);
}
return $result;
}
/**
@@ -412,7 +473,7 @@ final class SmartCache implements Cache, DriverAccessible
}
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::from($key), $matchingKeys);
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch load all matching keys
return $cacheDriver->get(...$cacheKeys);
@@ -445,7 +506,7 @@ final class SmartCache implements Cache, DriverAccessible
}
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::from($key), $matchingKeys);
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch load all matching keys
return $cacheDriver->get(...$cacheKeys);
@@ -474,7 +535,7 @@ final class SmartCache implements Cache, DriverAccessible
}
// Convert to CacheKey objects and batch load
$cacheKeys = array_map(fn (string $key) => CacheKey::from($key), $keyStrings);
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $keyStrings);
return $this->innerCache->get(...$cacheKeys);
@@ -557,7 +618,7 @@ final class SmartCache implements Cache, DriverAccessible
}
// Convert to CacheKey objects and check existence
$cacheKeys = array_map(fn (string $key) => CacheKey::from($key), $keyStrings);
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $keyStrings);
return $this->innerCache->has(...$cacheKeys);
@@ -588,7 +649,7 @@ final class SmartCache implements Cache, DriverAccessible
}
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::from($key), $matchingKeys);
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch delete all matching keys
$cacheDriver->forget(...$cacheKeys);
@@ -622,7 +683,7 @@ final class SmartCache implements Cache, DriverAccessible
}
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::from($key), $matchingKeys);
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch delete all matching keys
$cacheDriver->forget(...$cacheKeys);
@@ -652,7 +713,7 @@ final class SmartCache implements Cache, DriverAccessible
}
// Convert to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::from($key), $keyStrings);
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $keyStrings);
// Delete from cache and remove from tag index
$this->innerCache->forget(...$cacheKeys);
@@ -740,7 +801,7 @@ final class SmartCache implements Cache, DriverAccessible
}
/**
* Get comprehensive stats including smart cache metrics
* Get comprehensive stats including smart cache metrics and strategy stats
*/
public function getStats(): array
{
@@ -756,6 +817,7 @@ final class SmartCache implements Cache, DriverAccessible
'prefix_support' => $this->driverSupports(Scannable::class),
'tag_support' => $this->tagIndex !== null,
'intelligent_batching' => true,
'strategy_support' => $this->strategyManager !== null && $this->strategyManager->isEnabled(),
];
// Add tag index statistics if available
@@ -763,6 +825,13 @@ final class SmartCache implements Cache, DriverAccessible
$smartStats['tag_stats'] = $this->tagIndex->getStats();
}
// Add strategy statistics if available
if ($this->strategyManager) {
$smartStats['strategy_stats'] = $this->strategyManager->getStats();
} else {
$smartStats['strategy_support'] = false;
}
return array_merge($baseStats, $smartStats);
}
@@ -963,4 +1032,100 @@ final class SmartCache implements Cache, DriverAccessible
return implode(', ', $patterns);
}
/**
* Get the strategy manager (if available)
*/
public function getStrategyManager(): ?CacheStrategyManager
{
return $this->strategyManager;
}
/**
* Clear all strategy data
*/
public function clearStrategyData(): void
{
if ($this->strategyManager) {
$this->strategyManager->clearAll();
}
}
/**
* Get strategy-specific statistics
*/
public function getStrategyStats(string $strategyName): ?array
{
if (!$this->strategyManager) {
return null;
}
$strategy = $this->strategyManager->getStrategy($strategyName);
return $strategy?->getStats();
}
/**
* Check if a strategy is enabled
*/
public function isStrategyEnabled(string $strategyName): bool
{
return $this->strategyManager?->isStrategyEnabled($strategyName) ?? false;
}
/**
* Check if strategies are enabled at all
*/
public function hasStrategies(): bool
{
return $this->strategyManager !== null;
}
/**
* Create SmartCache with default strategies (same as constructor default)
*/
public static function withDefaultStrategies(Cache $innerCache, ?AsyncService $asyncService = null): self
{
return new self(
innerCache: $innerCache,
asyncService: $asyncService
// strategyManager will default to CacheStrategyManager::createDefault()
);
}
/**
* Create SmartCache without any strategies
*/
public static function withoutStrategies(Cache $innerCache, ?AsyncService $asyncService = null): self
{
return new self(
innerCache: $innerCache,
asyncService: $asyncService,
strategyManager: null,
enableDefaultStrategies: false
);
}
/**
* Create SmartCache with performance-focused strategies
*/
public static function withPerformanceStrategies(Cache $innerCache, ?AsyncService $asyncService = null): self
{
return new self(
innerCache: $innerCache,
asyncService: $asyncService,
strategyManager: CacheStrategyManager::createPerformanceFocused()
);
}
/**
* Create SmartCache with development-focused strategies
*/
public static function withDevelopmentStrategies(Cache $innerCache, ?AsyncService $asyncService = null): self
{
return new self(
innerCache: $innerCache,
asyncService: $asyncService,
strategyManager: CacheStrategyManager::createDevelopmentFocused()
);
}
}

View File

@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Adaptive TTL Cache Strategy
*
* Dynamically adjusts TTL based on access patterns to optimize cache efficiency.
* Frequently accessed items get longer TTL, rarely accessed items get shorter TTL.
*/
final class AdaptiveTtlCacheStrategy implements CacheStrategy
{
private const int DEFAULT_LEARNING_WINDOW = 100;
private const float DEFAULT_EXTENSION_FACTOR = 1.5;
private const float DEFAULT_REDUCTION_FACTOR = 0.8;
private const int ACCESS_THRESHOLD_HIGH = 10;
private const int ACCESS_THRESHOLD_LOW = 2;
/** @var array<string, AccessPattern> */
private array $accessPatterns = [];
/** @var array<string, AdaptiveTtlStats> */
private array $itemStats = [];
private readonly Duration $minTtl;
private readonly Duration $maxTtl;
public function __construct(
private readonly bool $enabled = true,
private readonly int $learningWindow = self::DEFAULT_LEARNING_WINDOW,
private readonly float $extensionFactor = self::DEFAULT_EXTENSION_FACTOR,
private readonly float $reductionFactor = self::DEFAULT_REDUCTION_FACTOR,
?Duration $minTtl = null,
?Duration $maxTtl = null
) {
$this->minTtl = $minTtl ?? Duration::fromSeconds(60);
$this->maxTtl = $maxTtl ?? Duration::fromSeconds(86400);
}
public function onCacheAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
return;
}
$keyString = $key->toString();
// Record access pattern
if (!isset($this->accessPatterns[$keyString])) {
$this->accessPatterns[$keyString] = new AccessPattern($this->learningWindow);
}
$this->accessPatterns[$keyString]->recordAccess();
// Record hit/miss statistics
if (!isset($this->itemStats[$keyString])) {
$this->itemStats[$keyString] = new AdaptiveTtlStats();
}
$this->itemStats[$keyString]->recordHitMiss($isHit);
}
public function onCacheSet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration
{
if (!$this->enabled) {
return $originalTtl ?? Duration::fromHours(1);
}
return $this->calculateAdaptiveTtl($key, $originalTtl);
}
public function onCacheForget(CacheKey $key): void
{
if (!$this->enabled) {
return;
}
$keyString = $key->toString();
unset($this->accessPatterns[$keyString]);
unset($this->itemStats[$keyString]);
}
public function getStats(): array
{
$stats = [
'strategy' => 'AdaptiveTtlCacheStrategy',
'enabled' => $this->enabled,
'total_tracked_keys' => count($this->accessPatterns),
'learning_window' => $this->learningWindow,
'ttl_bounds' => [
'min_seconds' => $this->minTtl->toSeconds(),
'max_seconds' => $this->maxTtl->toSeconds(),
],
'adaptation_factors' => [
'extension_factor' => $this->extensionFactor,
'reduction_factor' => $this->reductionFactor,
],
'key_patterns' => []
];
// Include top accessed keys and their patterns
$keysByAccess = [];
foreach ($this->accessPatterns as $keyString => $pattern) {
$keysByAccess[$keyString] = $pattern->getTotalAccesses();
}
arsort($keysByAccess);
$topKeys = array_slice($keysByAccess, 0, 10, true);
foreach ($topKeys as $keyString => $totalAccesses) {
$pattern = $this->accessPatterns[$keyString];
$itemStats = $this->itemStats[$keyString] ?? null;
$stats['key_patterns'][$keyString] = [
'total_accesses' => $totalAccesses,
'recent_accesses' => $pattern->getRecentAccessCount(),
'access_frequency' => round($pattern->getAccessFrequency(), 3),
'hit_rate' => $itemStats ? round($itemStats->getHitRate(), 3) : null,
'total_requests' => $itemStats?->getTotalRequests() ?? 0,
];
}
return $stats;
}
public function getName(): string
{
return 'adaptive_ttl';
}
public function clear(): void
{
$this->accessPatterns = [];
$this->itemStats = [];
}
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Calculate adaptive TTL based on access patterns and original TTL
*/
private function calculateAdaptiveTtl(CacheKey $key, ?Duration $originalTtl): Duration
{
$keyString = $key->toString();
$pattern = $this->accessPatterns[$keyString] ?? null;
// Use original TTL as base, or default if not provided
$baseTtl = $originalTtl ?? Duration::fromHours(1);
$baseSeconds = $baseTtl->toSeconds();
if (!$pattern) {
// No access pattern yet, use original TTL
return $this->enforceTtlBounds($baseTtl);
}
$recentAccesses = $pattern->getRecentAccessCount();
$accessFrequency = $pattern->getAccessFrequency();
$hitRate = $this->itemStats[$keyString]?->getHitRate() ?? 1.0;
// Calculate adaptation factor based on access patterns
$adaptationFactor = 1.0;
// High access count and good hit rate = extend TTL
if ($recentAccesses >= self::ACCESS_THRESHOLD_HIGH && $hitRate > 0.8) {
$adaptationFactor = $this->extensionFactor;
}
// Low access count or poor hit rate = reduce TTL
elseif ($recentAccesses <= self::ACCESS_THRESHOLD_LOW || $hitRate < 0.3) {
$adaptationFactor = $this->reductionFactor;
}
// Moderate access = adjust based on frequency trend
else {
// If access frequency is increasing, slightly extend TTL
// If decreasing, slightly reduce TTL
$trendFactor = max(0.5, min(2.0, $accessFrequency));
$adaptationFactor = 0.8 + ($trendFactor * 0.4); // Range: 0.8 to 1.2
}
$adaptedSeconds = (int) round($baseSeconds * $adaptationFactor);
$adaptedTtl = Duration::fromSeconds($adaptedSeconds);
return $this->enforceTtlBounds($adaptedTtl);
}
/**
* Ensure TTL stays within configured bounds
*/
private function enforceTtlBounds(Duration $ttl): Duration
{
$seconds = $ttl->toSeconds();
$minSeconds = $this->minTtl->toSeconds();
$maxSeconds = $this->maxTtl->toSeconds();
$boundedSeconds = max($minSeconds, min($maxSeconds, $seconds));
return Duration::fromSeconds($boundedSeconds);
}
}
/**
* Access pattern tracking for adaptive TTL calculation
*/
final class AccessPattern
{
/** @var array<int, Timestamp> */
private array $accessTimes = [];
public function __construct(
private readonly int $windowSize
) {}
public function recordAccess(): void
{
$this->accessTimes[] = Timestamp::now();
// Keep only recent accesses within the window
if (count($this->accessTimes) > $this->windowSize) {
array_shift($this->accessTimes);
}
}
public function getRecentAccessCount(): int
{
return count($this->accessTimes);
}
public function getTotalAccesses(): int
{
return count($this->accessTimes);
}
/**
* Calculate access frequency (accesses per hour)
*/
public function getAccessFrequency(): float
{
if (count($this->accessTimes) < 2) {
return 0.0;
}
$oldest = $this->accessTimes[0];
$newest = end($this->accessTimes);
$timeSpan = $newest->diff($oldest);
if ($timeSpan->toSeconds() <= 0) {
return 0.0;
}
$hours = $timeSpan->toSeconds() / 3600.0;
return count($this->accessTimes) / $hours;
}
}
/**
* Statistics for adaptive TTL optimization
*/
final class AdaptiveTtlStats
{
private int $hits = 0;
private int $misses = 0;
public function recordHitMiss(bool $isHit): void
{
if ($isHit) {
$this->hits++;
} else {
$this->misses++;
}
}
public function getHitRate(): float
{
$total = $this->hits + $this->misses;
return $total > 0 ? ($this->hits / $total) : 0.0;
}
public function getTotalRequests(): int
{
return $this->hits + $this->misses;
}
public function getHits(): int
{
return $this->hits;
}
public function getMisses(): int
{
return $this->misses;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
/**
* Unified Cache Strategy Interface
*
* Allows implementing various caching strategies that can be composed together
* in SmartCache for enhanced cache behavior.
*/
interface CacheStrategy
{
/**
* Called on every cache access (hit or miss)
*
* @param CacheKey $key The cache key being accessed
* @param bool $isHit Whether the access was a cache hit
* @param Duration|null $retrievalTime Time taken to retrieve the value
*/
public function onCacheAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void;
/**
* Called when setting a cache item - can modify the TTL
*
* @param CacheKey $key The cache key being set
* @param mixed $value The value being cached
* @param Duration|null $originalTtl The original TTL requested
* @return Duration The TTL to actually use (can be modified by strategy)
*/
public function onCacheSet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration;
/**
* Called when a cache item is forgotten/deleted
*
* @param CacheKey $key The cache key being deleted
*/
public function onCacheForget(CacheKey $key): void;
/**
* Get strategy-specific statistics and metrics
*
* @return array Strategy statistics
*/
public function getStats(): array;
/**
* Get the strategy name/identifier
*
* @return string Strategy name
*/
public function getName(): string;
/**
* Clear all strategy data and reset state
*/
public function clear(): void;
/**
* Check if strategy is enabled/active
*
* @return bool Whether strategy is active
*/
public function isEnabled(): bool;
}

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
/**
* Cache Strategy Manager
*
* Orchestrates multiple cache strategies and coordinates their execution.
* Provides a unified interface for strategy management and composition.
*/
final class CacheStrategyManager
{
/** @var array<string, CacheStrategy> */
private array $strategies = [];
/** @var array<string> */
private array $enabledStrategies = [];
public function __construct(
private readonly bool $enabled = true
) {}
/**
* Register a cache strategy
*/
public function addStrategy(CacheStrategy $strategy): self
{
$name = $strategy->getName();
$this->strategies[$name] = $strategy;
if ($strategy->isEnabled()) {
$this->enabledStrategies[] = $name;
}
return $this;
}
/**
* Remove a strategy
*/
public function removeStrategy(string $name): self
{
unset($this->strategies[$name]);
$this->enabledStrategies = array_filter(
$this->enabledStrategies,
fn($strategyName) => $strategyName !== $name
);
return $this;
}
/**
* Enable a strategy
*/
public function enableStrategy(string $name): self
{
if (isset($this->strategies[$name]) && !in_array($name, $this->enabledStrategies)) {
$this->enabledStrategies[] = $name;
}
return $this;
}
/**
* Disable a strategy
*/
public function disableStrategy(string $name): self
{
$this->enabledStrategies = array_filter(
$this->enabledStrategies,
fn($strategyName) => $strategyName !== $name
);
return $this;
}
/**
* Notify all enabled strategies of cache access
*/
public function notifyAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
return;
}
foreach ($this->getEnabledStrategies() as $strategy) {
try {
$strategy->onCacheAccess($key, $isHit, $retrievalTime);
} catch (\Throwable $e) {
// Log error but don't break cache operations
error_log("Cache strategy {$strategy->getName()} access notification failed: " . $e->getMessage());
}
}
}
/**
* Notify all enabled strategies of cache set and get final TTL
*/
public function notifySet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration
{
if (!$this->enabled) {
return $originalTtl ?? Duration::fromHours(1);
}
$finalTtl = $originalTtl ?? Duration::fromHours(1);
foreach ($this->getEnabledStrategies() as $strategy) {
try {
$finalTtl = $strategy->onCacheSet($key, $value, $finalTtl);
} catch (\Throwable $e) {
// Log error but continue with current TTL
error_log("Cache strategy {$strategy->getName()} set notification failed: " . $e->getMessage());
}
}
return $finalTtl;
}
/**
* Notify all enabled strategies of cache forget
*/
public function notifyForget(CacheKey $key): void
{
if (!$this->enabled) {
return;
}
foreach ($this->getEnabledStrategies() as $strategy) {
try {
$strategy->onCacheForget($key);
} catch (\Throwable $e) {
// Log error but don't break cache operations
error_log("Cache strategy {$strategy->getName()} forget notification failed: " . $e->getMessage());
}
}
}
/**
* Get comprehensive statistics from all strategies
*/
public function getStats(): array
{
$stats = [
'strategy_manager' => [
'enabled' => $this->enabled,
'total_strategies' => count($this->strategies),
'enabled_strategies' => count($this->enabledStrategies),
'strategy_names' => array_keys($this->strategies),
'enabled_strategy_names' => $this->enabledStrategies,
],
'strategies' => []
];
foreach ($this->strategies as $name => $strategy) {
try {
$stats['strategies'][$name] = $strategy->getStats();
} catch (\Throwable $e) {
$stats['strategies'][$name] = [
'error' => 'Failed to get stats: ' . $e->getMessage()
];
}
}
return $stats;
}
/**
* Clear all strategy data
*/
public function clearAll(): void
{
foreach ($this->strategies as $strategy) {
try {
$strategy->clear();
} catch (\Throwable $e) {
error_log("Failed to clear strategy {$strategy->getName()}: " . $e->getMessage());
}
}
}
/**
* Get a specific strategy by name
*/
public function getStrategy(string $name): ?CacheStrategy
{
return $this->strategies[$name] ?? null;
}
/**
* Get all registered strategies
*/
public function getAllStrategies(): array
{
return $this->strategies;
}
/**
* Get only enabled strategies
*/
public function getEnabledStrategies(): array
{
$enabledStrategies = [];
foreach ($this->enabledStrategies as $name) {
if (isset($this->strategies[$name])) {
$enabledStrategies[] = $this->strategies[$name];
}
}
return $enabledStrategies;
}
/**
* Check if a strategy is enabled
*/
public function isStrategyEnabled(string $name): bool
{
return in_array($name, $this->enabledStrategies);
}
/**
* Get strategy count
*/
public function getStrategyCount(): int
{
return count($this->strategies);
}
/**
* Check if manager is enabled
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Create a pre-configured strategy manager with common strategies
*/
public static function createDefault(): self
{
$manager = new self();
// Add default strategies
$manager->addStrategy(new AdaptiveTtlCacheStrategy());
$manager->addStrategy(new HeatMapCacheStrategy());
$manager->addStrategy(new PredictiveCacheStrategy());
return $manager;
}
/**
* Create a performance-focused strategy manager
*/
public static function createPerformanceFocused(): self
{
$manager = new self();
// Add performance-optimized strategies
$manager->addStrategy(new AdaptiveTtlCacheStrategy(
enabled: true,
learningWindow: 50, // Smaller window for faster adaptation
extensionFactor: 2.0, // More aggressive extension
reductionFactor: 0.6 // More aggressive reduction
));
$manager->addStrategy(new HeatMapCacheStrategy(
enabled: true,
maxTrackedKeys: 5000, // Smaller tracking for performance
hotThreshold: 30, // Higher threshold for hot keys
coldThreshold: 1 // Lower threshold for cold keys (must be int)
));
return $manager;
}
/**
* Create a development-focused strategy manager with detailed tracking
*/
public static function createDevelopmentFocused(): self
{
$manager = new self();
// Add development-friendly strategies with detailed tracking
$manager->addStrategy(new AdaptiveTtlCacheStrategy(
enabled: true,
learningWindow: 200, // Larger window for detailed analysis
extensionFactor: 1.3, // Conservative extension
reductionFactor: 0.9 // Conservative reduction
));
$manager->addStrategy(new HeatMapCacheStrategy(
enabled: true,
maxTrackedKeys: 20000, // More detailed tracking
hotThreshold: 10, // Lower threshold to catch more patterns
coldThreshold: 2, // Higher threshold for cold detection
analysisWindowHours: 6 // Longer analysis window
));
$manager->addStrategy(new PredictiveCacheStrategy(
enabled: true,
predictionWindowHours: 48, // Longer prediction window
confidenceThreshold: 0.5, // Lower threshold for more predictions
maxConcurrentWarming: 10 // More concurrent warming
));
return $manager;
}
}

View File

@@ -0,0 +1,430 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Heat Map Cache Strategy
*
* Tracks cache usage patterns to identify hot/cold keys and performance bottlenecks.
* Provides optimization insights and recommendations.
*/
final class HeatMapCacheStrategy implements CacheStrategy
{
private const int DEFAULT_MAX_TRACKED_KEYS = 10000;
private const int DEFAULT_HOT_THRESHOLD = 20; // accesses per hour
private const int DEFAULT_COLD_THRESHOLD = 1; // accesses per hour
private const int DEFAULT_ANALYSIS_WINDOW_HOURS = 2;
/** @var array<string, HeatMapEntry> */
private array $heatMap = [];
/** @var array<string, WriteOperation> */
private array $writeOperations = [];
public function __construct(
private readonly bool $enabled = true,
private readonly int $maxTrackedKeys = self::DEFAULT_MAX_TRACKED_KEYS,
private readonly int $hotThreshold = self::DEFAULT_HOT_THRESHOLD,
private readonly int $coldThreshold = self::DEFAULT_COLD_THRESHOLD,
private readonly int $analysisWindowHours = self::DEFAULT_ANALYSIS_WINDOW_HOURS
) {}
public function onCacheAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
return;
}
$keyString = $key->toString();
// Prevent memory overflow by limiting tracked keys
if (!isset($this->heatMap[$keyString]) && count($this->heatMap) >= $this->maxTrackedKeys) {
$this->evictOldestEntry();
}
if (!isset($this->heatMap[$keyString])) {
$this->heatMap[$keyString] = new HeatMapEntry($key);
}
$this->heatMap[$keyString]->recordAccess($isHit, $retrievalTime);
}
public function onCacheSet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration
{
if (!$this->enabled) {
return $originalTtl ?? Duration::fromHours(1);
}
$keyString = $key->toString();
$valueSize = $this->calculateValueSize($value);
$setTime = Duration::fromMilliseconds(1); // Minimal set time for now
$this->writeOperations[$keyString] = new WriteOperation(
$key,
$valueSize,
$setTime,
Timestamp::now()
);
return $originalTtl ?? Duration::fromHours(1);
}
public function onCacheForget(CacheKey $key): void
{
if (!$this->enabled) {
return;
}
$keyString = $key->toString();
unset($this->heatMap[$keyString]);
unset($this->writeOperations[$keyString]);
}
public function getStats(): array
{
$analysis = $this->getHeatMapAnalysis();
$bottlenecks = $this->getPerformanceBottlenecks();
return [
'strategy' => 'HeatMapCacheStrategy',
'enabled' => $this->enabled,
'total_tracked_keys' => count($this->heatMap),
'max_tracked_keys' => $this->maxTrackedKeys,
'thresholds' => [
'hot_threshold' => $this->hotThreshold,
'cold_threshold' => $this->coldThreshold,
'analysis_window_hours' => $this->analysisWindowHours,
],
'analysis' => $analysis,
'performance_bottlenecks' => $bottlenecks,
];
}
public function getName(): string
{
return 'heat_map';
}
public function clear(): void
{
$this->heatMap = [];
$this->writeOperations = [];
}
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Get comprehensive heat map analysis
*/
public function getHeatMapAnalysis(): array
{
$hotKeys = [];
$coldKeys = [];
$performanceInsights = [];
$cutoffTime = Timestamp::now()->subtract(Duration::fromHours($this->analysisWindowHours));
foreach ($this->heatMap as $keyString => $entry) {
$recentAccesses = $entry->getRecentAccesses($cutoffTime);
$accessesPerHour = $this->calculateAccessesPerHour($recentAccesses);
if ($accessesPerHour >= $this->hotThreshold) {
$hotKeys[] = [
'key' => $keyString,
'accesses_per_hour' => round($accessesPerHour, 2),
'hit_rate' => round($entry->getHitRate(), 3),
'avg_retrieval_time_ms' => round($entry->getAverageRetrievalTime() * 1000, 2),
'total_accesses' => count($recentAccesses),
];
} elseif ($accessesPerHour <= $this->coldThreshold) {
$coldKeys[] = [
'key' => $keyString,
'accesses_per_hour' => round($accessesPerHour, 2),
'hit_rate' => round($entry->getHitRate(), 3),
'last_access' => $entry->getLastAccessTime()?->format('Y-m-d H:i:s'),
];
}
// Performance insights
if ($entry->getAverageRetrievalTime() > 0.1) { // >100ms
$performanceInsights[] = [
'type' => 'slow_retrieval',
'key' => $keyString,
'avg_time_ms' => round($entry->getAverageRetrievalTime() * 1000, 2),
'recommendation' => 'Consider optimizing data source or adding caching layer',
];
}
if ($entry->getHitRate() < 0.5) { // <50% hit rate
$performanceInsights[] = [
'type' => 'low_hit_rate',
'key' => $keyString,
'hit_rate' => round($entry->getHitRate(), 3),
'recommendation' => 'Review TTL settings or access patterns',
];
}
}
// Sort by priority
usort($hotKeys, fn($a, $b) => $b['accesses_per_hour'] <=> $a['accesses_per_hour']);
usort($coldKeys, fn($a, $b) => $a['accesses_per_hour'] <=> $b['accesses_per_hour']);
return [
'total_tracked_keys' => count($this->heatMap),
'hot_keys' => array_slice($hotKeys, 0, 10),
'cold_keys' => array_slice($coldKeys, 0, 10),
'performance_insights' => $performanceInsights,
'analysis_window_hours' => $this->analysisWindowHours,
];
}
/**
* Get performance bottlenecks with impact scoring
*/
public function getPerformanceBottlenecks(): array
{
$bottlenecks = [];
foreach ($this->heatMap as $keyString => $entry) {
$avgRetrievalTime = $entry->getAverageRetrievalTime();
$hitRate = $entry->getHitRate();
$accessCount = $entry->getTotalAccesses();
// Calculate impact score (higher = more critical)
$impactScore = 0;
// Slow retrieval impact
if ($avgRetrievalTime > 0.1) {
$impactScore += ($avgRetrievalTime * 10) * $accessCount;
}
// Low hit rate impact
if ($hitRate < 0.7) {
$impactScore += (1 - $hitRate) * $accessCount * 5;
}
// High miss rate with frequent access
if ($hitRate < 0.5 && $accessCount > 50) {
$impactScore += 100;
}
if ($impactScore > 10) {
$bottlenecks[] = [
'key' => $keyString,
'impact_score' => $impactScore,
'type' => $this->classifyBottleneck($avgRetrievalTime, $hitRate),
'avg_retrieval_time_ms' => round($avgRetrievalTime * 1000, 2),
'hit_rate' => round($hitRate, 3),
'access_count' => $accessCount,
'recommendation' => $this->getBottleneckRecommendation($avgRetrievalTime, $hitRate),
];
}
}
// Sort by impact score (highest first)
usort($bottlenecks, fn($a, $b) => $b['impact_score'] <=> $a['impact_score']);
return array_slice($bottlenecks, 0, 20); // Top 20 bottlenecks
}
/**
* Get hot keys (most frequently accessed)
*/
public function getHotKeys(int $limit = 10): array
{
$hotKeys = [];
$cutoffTime = Timestamp::now()->subtract(Duration::fromHours($this->analysisWindowHours));
foreach ($this->heatMap as $keyString => $entry) {
$recentAccesses = $entry->getRecentAccesses($cutoffTime);
$accessesPerHour = $this->calculateAccessesPerHour($recentAccesses);
if ($accessesPerHour >= $this->hotThreshold) {
$hotKeys[$keyString] = $accessesPerHour;
}
}
arsort($hotKeys);
return array_slice($hotKeys, 0, $limit, true);
}
private function evictOldestEntry(): void
{
if (empty($this->heatMap)) {
return;
}
$oldestKey = null;
$oldestTime = null;
foreach ($this->heatMap as $keyString => $entry) {
$lastAccess = $entry->getLastAccessTime();
if ($oldestTime === null || ($lastAccess && $lastAccess->isBefore($oldestTime))) {
$oldestTime = $lastAccess;
$oldestKey = $keyString;
}
}
if ($oldestKey !== null) {
unset($this->heatMap[$oldestKey]);
}
}
private function calculateValueSize(mixed $value): int
{
try {
return strlen(serialize($value));
} catch (\Throwable) {
return 0;
}
}
private function calculateAccessesPerHour(array $accesses): float
{
if (empty($accesses)) {
return 0.0;
}
$hours = $this->analysisWindowHours;
return count($accesses) / $hours;
}
private function classifyBottleneck(float $retrievalTime, float $hitRate): string
{
if ($retrievalTime > 0.2) {
return 'slow_retrieval';
}
if ($hitRate < 0.3) {
return 'very_low_hit_rate';
}
if ($hitRate < 0.7) {
return 'low_hit_rate';
}
return 'performance_issue';
}
private function getBottleneckRecommendation(float $retrievalTime, float $hitRate): string
{
if ($retrievalTime > 0.2) {
return 'Optimize data source or add intermediate caching layer';
}
if ($hitRate < 0.3) {
return 'Review cache key patterns and TTL settings';
}
if ($hitRate < 0.7) {
return 'Consider increasing TTL or improving data freshness strategy';
}
return 'Monitor and analyze access patterns';
}
}
/**
* Heat map entry for tracking cache key usage
*/
final class HeatMapEntry
{
/** @var array<array{timestamp: Timestamp, is_hit: bool, retrieval_time: ?float}> */
private array $accesses = [];
private int $totalHits = 0;
private int $totalMisses = 0;
private float $totalRetrievalTime = 0.0;
private int $retrievalTimeCount = 0;
public function __construct(
private readonly CacheKey $key
) {}
public function recordAccess(bool $isHit, ?Duration $retrievalTime = null): void
{
$this->accesses[] = [
'timestamp' => Timestamp::now(),
'is_hit' => $isHit,
'retrieval_time' => $retrievalTime?->toSeconds(),
];
if ($isHit) {
$this->totalHits++;
} else {
$this->totalMisses++;
}
if ($retrievalTime !== null) {
$this->totalRetrievalTime += $retrievalTime->toSeconds();
$this->retrievalTimeCount++;
}
// Keep only recent data to prevent memory bloat
$cutoff = Timestamp::now()->subtract(Duration::fromHours(48)); // Keep 48 hours
$this->accesses = array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($cutoff)
);
}
public function getRecentAccesses(Timestamp $since): array
{
return array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($since)
);
}
public function getHitRate(): float
{
$total = $this->totalHits + $this->totalMisses;
return $total > 0 ? ($this->totalHits / $total) : 0.0;
}
public function getAverageRetrievalTime(): float
{
return $this->retrievalTimeCount > 0 ? ($this->totalRetrievalTime / $this->retrievalTimeCount) : 0.0;
}
public function getTotalAccesses(): int
{
return count($this->accesses);
}
public function getLastAccessTime(): ?Timestamp
{
if (empty($this->accesses)) {
return null;
}
return end($this->accesses)['timestamp'];
}
public function getKey(): CacheKey
{
return $this->key;
}
}
/**
* Write operation tracking
*/
final readonly class WriteOperation
{
public function __construct(
public CacheKey $key,
public int $valueSize,
public Duration $writeTime,
public Timestamp $timestamp
) {}
}

View File

@@ -0,0 +1,546 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheItem;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Predictive Cache Strategy
*
* Uses machine learning patterns to predict cache access and proactively warm cache.
* Analyzes time-based patterns, frequency patterns, and dependency relationships.
*/
final class PredictiveCacheStrategy implements CacheStrategy
{
private const int DEFAULT_PREDICTION_WINDOW_HOURS = 24;
private const int MIN_PATTERN_OCCURRENCES = 3;
private const float DEFAULT_CONFIDENCE_THRESHOLD = 0.7;
private const int MAX_CONCURRENT_WARMING = 5;
/** @var array<string, PredictionPattern> */
private array $patterns = [];
/** @var array<string, WarmingJob> */
private array $activeWarmingJobs = [];
/** @var array<string, WarmingResult> */
private array $warmingHistory = [];
/** @var array<string, callable> */
private array $warmingCallbacks = [];
public function __construct(
private readonly bool $enabled = true,
private readonly int $predictionWindowHours = self::DEFAULT_PREDICTION_WINDOW_HOURS,
private readonly float $confidenceThreshold = self::DEFAULT_CONFIDENCE_THRESHOLD,
private readonly int $maxConcurrentWarming = self::MAX_CONCURRENT_WARMING
) {}
public function onCacheAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
return;
}
$this->recordAccess($key, [
'is_hit' => $isHit,
'retrieval_time_ms' => $retrievalTime?->toMilliseconds() ?? 0,
'time_of_day' => (int) Timestamp::now()->format('H'),
'day_of_week' => (int) Timestamp::now()->format('N'),
]);
}
public function onCacheSet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration
{
// Predictive strategy doesn't modify TTL
return $originalTtl ?? Duration::fromHours(1);
}
public function onCacheForget(CacheKey $key): void
{
if (!$this->enabled) {
return;
}
$keyString = $key->toString();
unset($this->patterns[$keyString]);
unset($this->activeWarmingJobs[$keyString]);
unset($this->warmingHistory[$keyString]);
unset($this->warmingCallbacks[$keyString]);
}
public function getStats(): array
{
$totalPatterns = count($this->patterns);
$activeJobs = count($this->activeWarmingJobs);
$completedWarming = count($this->warmingHistory);
$successfulWarming = 0;
$totalWarmingTime = 0;
foreach ($this->warmingHistory as $result) {
if ($result->successful) {
$successfulWarming++;
}
$totalWarmingTime += $result->duration->toMilliseconds();
}
$avgWarmingTime = $completedWarming > 0 ? ($totalWarmingTime / $completedWarming) : 0;
$successRate = $completedWarming > 0 ? ($successfulWarming / $completedWarming) : 0;
return [
'strategy' => 'PredictiveCacheStrategy',
'enabled' => $this->enabled,
'total_patterns' => $totalPatterns,
'active_warming_jobs' => $activeJobs,
'completed_warming_operations' => $completedWarming,
'successful_warming_operations' => $successfulWarming,
'warming_success_rate' => round($successRate, 3),
'avg_warming_time_ms' => round($avgWarmingTime, 2),
'confidence_threshold' => $this->confidenceThreshold,
'prediction_window_hours' => $this->predictionWindowHours,
];
}
public function getName(): string
{
return 'predictive';
}
public function clear(): void
{
$this->patterns = [];
$this->warmingHistory = [];
$this->warmingCallbacks = [];
// Don't clear active jobs as they're still running
}
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Record access for pattern learning
*/
public function recordAccess(CacheKey $key, array $context = []): void
{
if (!$this->enabled) {
return;
}
$keyString = $key->toString();
$timestamp = Timestamp::now();
if (!isset($this->patterns[$keyString])) {
$this->patterns[$keyString] = new PredictionPattern($key);
}
$this->patterns[$keyString]->recordAccess($timestamp, $context);
}
/**
* Record cache dependency relationship
*/
public function recordDependency(CacheKey $primaryKey, CacheKey $dependentKey): void
{
if (!$this->enabled) {
return;
}
$primaryString = $primaryKey->toString();
if (!isset($this->patterns[$primaryString])) {
$this->patterns[$primaryString] = new PredictionPattern($primaryKey);
}
$this->patterns[$primaryString]->addDependency($dependentKey);
}
/**
* Generate predictions based on learned patterns
*/
public function generatePredictions(): array
{
if (!$this->enabled) {
return [];
}
$predictions = [];
$now = Timestamp::now();
foreach ($this->patterns as $keyString => $pattern) {
$keyPredictions = $this->predictKeyAccess($pattern, $now);
$predictions = array_merge($predictions, $keyPredictions);
}
// Sort by confidence score
usort($predictions, fn($a, $b) => $b['confidence'] <=> $a['confidence']);
return $predictions;
}
/**
* Perform predictive warming based on patterns
*/
public function performPredictiveWarming(): array
{
if (!$this->enabled) {
return [];
}
$predictions = $this->generatePredictions();
$warmingResults = [];
foreach ($predictions as $prediction) {
if ($prediction['confidence'] >= $this->confidenceThreshold) {
$result = $this->warmCacheKey(
$prediction['key'],
$prediction['callback'] ?? null,
$prediction['reason']
);
$warmingResults[] = $result;
// Limit concurrent warming operations
if (count($this->activeWarmingJobs) >= $this->maxConcurrentWarming) {
break;
}
}
}
return $warmingResults;
}
/**
* Register a warming callback for a specific key
*/
public function registerWarmingCallback(CacheKey $key, callable $callback): void
{
if (!$this->enabled) {
return;
}
$keyString = $key->toString();
$this->warmingCallbacks[$keyString] = $callback;
if (!isset($this->patterns[$keyString])) {
$this->patterns[$keyString] = new PredictionPattern($key);
}
$this->patterns[$keyString]->setWarmingCallback($callback);
}
/**
* Predict access patterns for a specific key
*/
private function predictKeyAccess(PredictionPattern $pattern, Timestamp $now): array
{
$predictions = [];
$key = $pattern->getKey();
// Time-based prediction (daily patterns)
$timeBasedConfidence = $this->calculateTimeBasedConfidence($pattern, $now);
if ($timeBasedConfidence > 0.3) {
$predictions[] = [
'key' => $key,
'confidence' => $timeBasedConfidence,
'reason' => 'time_based_pattern',
'predicted_access_time' => $now->add(Duration::fromMinutes(30)),
'callback' => $pattern->getWarmingCallback()
];
}
// Frequency-based prediction
$frequencyConfidence = $this->calculateFrequencyConfidence($pattern, $now);
if ($frequencyConfidence > 0.4) {
$predictions[] = [
'key' => $key,
'confidence' => $frequencyConfidence,
'reason' => 'access_frequency_pattern',
'predicted_access_time' => $now->add(Duration::fromMinutes(15)),
'callback' => $pattern->getWarmingCallback()
];
}
// Dependency-based prediction
$dependencyPredictions = $this->predictDependencyAccess($pattern, $now);
$predictions = array_merge($predictions, $dependencyPredictions);
return $predictions;
}
/**
* Calculate confidence based on time patterns (daily/hourly)
*/
private function calculateTimeBasedConfidence(PredictionPattern $pattern, Timestamp $now): float
{
$accesses = $pattern->getRecentAccesses($this->predictionWindowHours);
if (count($accesses) < self::MIN_PATTERN_OCCURRENCES) {
return 0.0;
}
$currentHour = (int) $now->format('H');
$currentDayOfWeek = (int) $now->format('N'); // 1 = Monday, 7 = Sunday
$hourMatches = 0;
$dayMatches = 0;
$totalAccesses = count($accesses);
foreach ($accesses as $access) {
$accessHour = (int) $access['timestamp']->format('H');
$accessDay = (int) $access['timestamp']->format('N');
// Hour pattern matching (±1 hour tolerance)
if (abs($accessHour - $currentHour) <= 1) {
$hourMatches++;
}
// Day pattern matching
if ($accessDay === $currentDayOfWeek) {
$dayMatches++;
}
}
$hourConfidence = $hourMatches / $totalAccesses;
$dayConfidence = $dayMatches / $totalAccesses;
// Combined confidence with hour pattern weighted more heavily
return ($hourConfidence * 0.7) + ($dayConfidence * 0.3);
}
/**
* Calculate confidence based on access frequency patterns
*/
private function calculateFrequencyConfidence(PredictionPattern $pattern, Timestamp $now): float
{
$accesses = $pattern->getRecentAccesses($this->predictionWindowHours);
if (count($accesses) < self::MIN_PATTERN_OCCURRENCES) {
return 0.0;
}
// Calculate average time between accesses
$intervals = [];
for ($i = 1; $i < count($accesses); $i++) {
$interval = $accesses[$i]['timestamp']->diff($accesses[$i-1]['timestamp']);
$intervals[] = $interval->toSeconds();
}
if (empty($intervals)) {
return 0.0;
}
$avgInterval = array_sum($intervals) / count($intervals);
$lastAccess = end($accesses)['timestamp'];
$timeSinceLastAccess = $now->diff($lastAccess)->toSeconds();
// Confidence increases as we approach the expected next access time
$expectedNextAccess = $avgInterval;
$confidence = 1.0 - abs($timeSinceLastAccess - $expectedNextAccess) / $expectedNextAccess;
return max(0.0, min(1.0, $confidence));
}
/**
* Predict access for dependent keys
*/
private function predictDependencyAccess(PredictionPattern $pattern, Timestamp $now): array
{
$predictions = [];
$dependencies = $pattern->getDependencies();
if (empty($dependencies)) {
return $predictions;
}
// If the primary key is likely to be accessed, warm dependencies too
$primaryConfidence = max(
$this->calculateTimeBasedConfidence($pattern, $now),
$this->calculateFrequencyConfidence($pattern, $now)
);
if ($primaryConfidence > 0.5) {
foreach ($dependencies as $dependentKey) {
$predictions[] = [
'key' => $dependentKey,
'confidence' => $primaryConfidence * 0.8, // Slightly lower confidence for dependencies
'reason' => 'dependency_pattern',
'predicted_access_time' => $now->add(Duration::fromMinutes(5)),
'callback' => $this->warmingCallbacks[$dependentKey->toString()] ?? null
];
}
}
return $predictions;
}
/**
* Warm a specific cache key
*/
private function warmCacheKey(CacheKey $key, ?callable $callback, string $reason): array
{
$startTime = Timestamp::now();
$keyString = $key->toString();
// Check if already warming this key
if (isset($this->activeWarmingJobs[$keyString])) {
return [
'key' => $keyString,
'status' => 'already_warming',
'reason' => $reason,
'timestamp' => $startTime->format('H:i:s')
];
}
// Create warming job
$job = new WarmingJob($key, $callback, $reason, $startTime);
$this->activeWarmingJobs[$keyString] = $job;
try {
// If no callback provided, we can't warm the cache
if (!$callback) {
return [
'key' => $keyString,
'status' => 'no_callback',
'reason' => $reason,
'timestamp' => $startTime->format('H:i:s')
];
}
// Execute warming (this would need cache access in real implementation)
$value = $callback();
$endTime = Timestamp::now();
$duration = $startTime->diff($endTime);
// Record warming result
$result = new WarmingResult($key, true, $duration, $reason);
$this->warmingHistory[$keyString] = $result;
return [
'key' => $keyString,
'status' => 'warmed',
'reason' => $reason,
'duration_ms' => $duration->toMilliseconds(),
'timestamp' => $startTime->format('H:i:s')
];
} catch (\Throwable $e) {
$endTime = Timestamp::now();
$duration = $startTime->diff($endTime);
return [
'key' => $keyString,
'status' => 'error',
'reason' => $reason,
'error' => $e->getMessage(),
'duration_ms' => $duration->toMilliseconds(),
'timestamp' => $startTime->format('H:i:s')
];
} finally {
// Remove from active jobs
unset($this->activeWarmingJobs[$keyString]);
}
}
}
/**
* Prediction pattern for a cache key
*/
final class PredictionPattern
{
/** @var array<array{timestamp: Timestamp, context: array}> */
private array $accesses = [];
/** @var array<CacheKey> */
private array $dependencies = [];
private mixed $warmingCallback = null;
public function __construct(
private readonly CacheKey $key
) {}
public function recordAccess(Timestamp $timestamp, array $context = []): void
{
$this->accesses[] = [
'timestamp' => $timestamp,
'context' => $context
];
// Keep only recent accesses to prevent memory bloat
$cutoff = $timestamp->subtract(Duration::fromHours(168)); // 1 week
$this->accesses = array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($cutoff)
);
}
public function addDependency(CacheKey $dependentKey): void
{
$this->dependencies[] = $dependentKey;
}
public function setWarmingCallback(callable $callback): void
{
$this->warmingCallback = $callback;
}
public function getKey(): CacheKey
{
return $this->key;
}
public function getRecentAccesses(int $hours): array
{
$cutoff = Timestamp::now()->subtract(Duration::fromHours($hours));
return array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($cutoff)
);
}
public function getDependencies(): array
{
return $this->dependencies;
}
public function getWarmingCallback(): ?callable
{
return $this->warmingCallback;
}
}
/**
* Active warming job
*/
final readonly class WarmingJob
{
public function __construct(
public CacheKey $key,
public mixed $callback,
public string $reason,
public Timestamp $startTime
) {}
}
/**
* Warming operation result
*/
final readonly class WarmingResult
{
public function __construct(
public CacheKey $key,
public bool $successful,
public Duration $duration,
public string $reason
) {}
}

View File

@@ -36,7 +36,7 @@ final readonly class TaggedCache
*/
public function put(string|CacheKey $key, mixed $value, ?Duration $ttl = null): bool
{
$cacheKey = $key instanceof CacheKey ? $key : CacheKey::from($key);
$cacheKey = $key instanceof CacheKey ? $key : CacheKey::fromString($key);
$item = CacheItem::forSet($cacheKey, $value, $ttl);
// Store the item
@@ -55,7 +55,7 @@ final readonly class TaggedCache
*/
public function get(string|CacheKey $key): CacheItem
{
$cacheKey = $key instanceof CacheKey ? $key : CacheKey::from($key);
$cacheKey = $key instanceof CacheKey ? $key : CacheKey::fromString($key);
$result = $this->cache->get($cacheKey);
return $result->getItem($cacheKey);
@@ -66,7 +66,7 @@ final readonly class TaggedCache
*/
public function remember(string|CacheKey $key, callable $callback, ?Duration $ttl = null): CacheItem
{
$cacheKey = $key instanceof CacheKey ? $key : CacheKey::from($key);
$cacheKey = $key instanceof CacheKey ? $key : CacheKey::fromString($key);
$item = $this->get($cacheKey);
if ($item->isHit) {

View File

@@ -12,6 +12,7 @@ enum EnvKey: string
case APP_KEY = 'APP_KEY';
case APP_TIMEZONE = 'APP_TIMEZONE';
case APP_LOCALE = 'APP_LOCALE';
case APP_URL = 'APP_URL';
// Feature Flags
case ENABLE_CONTEXT_AWARE_INITIALIZERS = 'ENABLE_CONTEXT_AWARE_INITIALIZERS';
@@ -31,6 +32,18 @@ enum EnvKey: string
case RAPIDMAIL_PASSWORD = 'RAPIDMAIL_PASSWORD';
case RAPIDMAIL_TEST_MODE = 'RAPIDMAIL_TEST_MODE';
// OAuth - Spotify
case SPOTIFY_CLIENT_ID = 'SPOTIFY_CLIENT_ID';
case SPOTIFY_CLIENT_SECRET = 'SPOTIFY_CLIENT_SECRET';
case SPOTIFY_REDIRECT_URI = 'SPOTIFY_REDIRECT_URI';
// OAuth - Apple Music
case APPLE_MUSIC_CLIENT_ID = 'APPLE_MUSIC_CLIENT_ID';
case APPLE_MUSIC_TEAM_ID = 'APPLE_MUSIC_TEAM_ID';
case APPLE_MUSIC_KEY_ID = 'APPLE_MUSIC_KEY_ID';
case APPLE_MUSIC_PRIVATE_KEY = 'APPLE_MUSIC_PRIVATE_KEY';
case APPLE_MUSIC_REDIRECT_URI = 'APPLE_MUSIC_REDIRECT_URI';
// ETag Configuration
case ETAG_ENABLED = 'ETAG_ENABLED';
case ETAG_PREFER_WEAK = 'ETAG_PREFER_WEAK';
@@ -42,4 +55,7 @@ enum EnvKey: string
case REDIS_PORT = 'REDIS_PORT';
case REDIS_PASSWORD = 'REDIS_PASSWORD';
// Vault Configuration
case VAULT_ENCRYPTION_KEY = 'VAULT_ENCRYPTION_KEY';
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics;
use App\Framework\Console\Analytics\Middleware\AnalyticsCollectionMiddleware;
use App\Framework\Console\Analytics\Repository\CommandUsageRepository;
use App\Framework\Console\Analytics\Repository\DatabaseCommandUsageRepository;
use App\Framework\DI\Attributes\Initializer;
use App\Framework\DI\Container;
use App\Framework\Environment\Environment;
use App\Framework\Environment\EnvKey;
final readonly class AnalyticsInitializer
{
public function __construct(
private Environment $environment
) {
}
#[Initializer]
public function initialize(Container $container): void
{
// Register repository
$container->bind(
CommandUsageRepository::class,
DatabaseCommandUsageRepository::class
);
// Register analytics service
$container->singleton(AnalyticsService::class, function (Container $container) {
return new AnalyticsService(
$container->get(CommandUsageRepository::class)
);
});
// Register analytics collection middleware
$container->singleton(AnalyticsCollectionMiddleware::class, function (Container $container) {
$enabled = $this->environment->getBool(EnvKey::CONSOLE_ANALYTICS_ENABLED, true);
return new AnalyticsCollectionMiddleware(
repository: $container->get(CommandUsageRepository::class),
enabled: $enabled
);
});
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics;
use App\Framework\Console\Analytics\Repository\CommandUsageRepository;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Analytics\ValueObjects\UsageStatistics;
use DateTimeImmutable;
final readonly class AnalyticsService
{
public function __construct(
private CommandUsageRepository $repository
) {
}
public function getCommandStatistics(string $commandName, ?DateTimeImmutable $since = null): UsageStatistics
{
return $this->repository->getUsageStatistics($commandName, $since);
}
public function getPopularCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
return $this->repository->getPopularCommands($limit, $since);
}
public function getCommandTrends(
string $commandName,
Period $period = Period::DAILY,
?DateTimeImmutable $start = null,
?DateTimeImmutable $end = null
): array {
$start ??= new DateTimeImmutable('-30 days');
$end ??= new DateTimeImmutable();
return $this->repository->getTrendData($commandName, $period, $start, $end);
}
public function getAllCommandNames(): array
{
return $this->repository->getAllCommandNames();
}
public function cleanupOldData(int $daysToKeep = 90): int
{
$cutoffDate = new DateTimeImmutable("-{$daysToKeep} days");
return $this->repository->cleanup($cutoffDate);
}
public function getOverallStatistics(?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$statistics = [];
foreach ($commands as $commandName) {
$statistics[$commandName] = $this->getCommandStatistics($commandName, $since);
}
return $statistics;
}
public function getMostFailingCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$failingCommands = [];
foreach ($commands as $commandName) {
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->totalExecutions > 0) {
$failingCommands[] = [
'command_name' => $commandName,
'failure_rate' => $stats->getFailureRate(),
'total_executions' => $stats->totalExecutions,
'failed_executions' => $stats->failedExecutions,
];
}
}
// Sort by failure rate descending
usort($failingCommands, function ($a, $b) {
return $b['failure_rate']->getValue() <=> $a['failure_rate']->getValue();
});
return array_slice($failingCommands, 0, $limit);
}
public function getSlowestCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$slowCommands = [];
foreach ($commands as $commandName) {
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->totalExecutions > 0) {
$slowCommands[] = [
'command_name' => $commandName,
'average_execution_time' => $stats->averageExecutionTime,
'max_execution_time' => $stats->maxExecutionTime,
'total_executions' => $stats->totalExecutions,
];
}
}
// Sort by average execution time descending
usort($slowCommands, function ($a, $b) {
return $b['average_execution_time']->toMilliseconds() <=>
$a['average_execution_time']->toMilliseconds();
});
return array_slice($slowCommands, 0, $limit);
}
public function getUsageByHour(?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$hourlyUsage = array_fill(0, 24, 0);
foreach ($commands as $commandName) {
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->hourlyDistribution) {
foreach ($stats->hourlyDistribution as $hour => $count) {
$hourlyUsage[$hour] += $count;
}
}
}
return $hourlyUsage;
}
public function getCommandHealthScore(string $commandName, ?DateTimeImmutable $since = null): float
{
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->totalExecutions === 0) {
return 0.0;
}
// Health score based on success rate (70%) and performance (30%)
$successScore = $stats->getSuccessRate()->getValue() / 100;
// Performance score: inverse of execution time (normalized)
$avgTimeMs = $stats->averageExecutionTime->toMilliseconds();
$performanceScore = $avgTimeMs > 0 ? min(1.0, 1000 / $avgTimeMs) : 1.0;
return ($successScore * 0.7) + ($performanceScore * 0.3);
}
}

View File

@@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Commands;
use App\Framework\Console\Analytics\AnalyticsService;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Attributes\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Layout\ResponsiveOutput;
use App\Framework\Console\Output\ConsoleOutput;
use DateTimeImmutable;
final readonly class AnalyticsCommand
{
public function __construct(
private AnalyticsService $analyticsService
) {
}
#[ConsoleCommand(
name: 'analytics:usage',
description: 'Show command usage analytics and statistics'
)]
public function showUsage(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$commandName = $input->getArgument('command');
$days = (int) ($input->getOption('days') ?? 30);
$since = new DateTimeImmutable("-{$days} days");
if ($commandName) {
return $this->showCommandUsage($commandName, $since, $output);
}
return $this->showOverallUsage($since, $output);
}
#[ConsoleCommand(
name: 'analytics:popular',
description: 'Show most popular commands'
)]
public function showPopularCommands(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$limit = (int) ($input->getOption('limit') ?? 10);
$days = (int) ($input->getOption('days') ?? 30);
$since = new DateTimeImmutable("-{$days} days");
$popularCommands = $this->analyticsService->getPopularCommands($limit, $since);
if (empty($popularCommands)) {
$output->writeLine('<red>No command usage data found.</red>');
return ExitCode::SUCCESS;
}
$responsiveOutput = ResponsiveOutput::create($output);
$output->writeLine("<yellow>Most Popular Commands (Last {$days} days)</yellow>\n");
// Prepare table data
$headers = ['Rank', 'Command', 'Executions', 'Usage %', 'Avg Time', 'Success Rate'];
$rows = [];
foreach ($popularCommands as $command) {
$rows[] = [
(string) $command->rank,
$command->commandName,
(string) $command->totalExecutions,
$command->usagePercentage->format(1),
$command->averageExecutionTime->toHumanReadable(),
$command->successRate->format(1),
];
}
$responsiveOutput->writeTable($headers, $rows);
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'analytics:trends',
description: 'Show command usage trends over time'
)]
public function showTrends(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$commandName = $input->getArgument('command');
if (! $commandName) {
$output->writeLine('<red>Command name is required for trend analysis.</red>');
return ExitCode::INVALID_ARGUMENTS;
}
$periodStr = $input->getOption('period') ?? 'daily';
$period = match (strtolower($periodStr)) {
'hourly' => Period::HOURLY,
'daily' => Period::DAILY,
'weekly' => Period::WEEKLY,
'monthly' => Period::MONTHLY,
'yearly' => Period::YEARLY,
default => Period::DAILY
};
$days = (int) ($input->getOption('days') ?? 30);
$start = new DateTimeImmutable("-{$days} days");
$end = new DateTimeImmutable();
$trends = $this->analyticsService->getCommandTrends($commandName, $period, $start, $end);
if (empty($trends)) {
$output->writeLine('<red>No trend data found for this command.</red>');
return ExitCode::SUCCESS;
}
$responsiveOutput = ResponsiveOutput::create($output);
$trend = $trends[0];
$output->writeLine("<yellow>Usage Trends for '{$commandName}' ({$period->getDescription()})</yellow>\n");
// Show trend summary
$summary = [
'Period' => "{$trend->startDate->format('Y-m-d')} to {$trend->endDate->format('Y-m-d')}",
'Trend' => $trend->getTrendDescription(),
'Direction' => ($trend->trendDirection > 0 ? '+' : '') . round($trend->trendDirection, 3),
'Strength' => round($trend->trendStrength, 3),
];
$responsiveOutput->writeKeyValue($summary);
$output->writeLine('');
// Show trend data points as responsive table
$headers = ['Date', 'Executions', 'Avg Time', 'Success Rate'];
$rows = [];
foreach ($trend->dataPoints as $point) {
$rows[] = [
$point->date->format($period->getDateFormat()),
(string) $point->executionCount,
$point->averageExecutionTime->toHumanReadable(),
$point->successRate->format(1),
];
}
$responsiveOutput->writeTable($headers, $rows);
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'analytics:health',
description: 'Show command health scores and problematic commands'
)]
public function showHealth(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$days = (int) ($input->getOption('days') ?? 30);
$since = new DateTimeImmutable("-{$days} days");
$output->writeLine("<yellow>Command Health Analysis (Last {$days} days)</yellow>\n");
// Show failing commands
$failingCommands = $this->analyticsService->getMostFailingCommands(10, $since);
if (! empty($failingCommands)) {
$output->writeLine("<red>Commands with Highest Failure Rates:</red>");
$output->writeLine(sprintf(
"%-25s %-12s %-12s %s",
'Command',
'Failure Rate',
'Total Runs',
'Failures'
));
$output->writeLine(str_repeat('-', 70));
foreach ($failingCommands as $command) {
$output->writeLine(sprintf(
"%-25s %-12s %-12d %d",
$command['command_name'],
$command['failure_rate']->format(1),
$command['total_executions'],
$command['failed_executions']
));
}
$output->writeLine('');
}
// Show slow commands
$slowCommands = $this->analyticsService->getSlowestCommands(10, $since);
if (! empty($slowCommands)) {
$output->writeLine("<yellow>Slowest Commands:</yellow>");
$output->writeLine(sprintf(
"%-25s %-15s %-15s %s",
'Command',
'Avg Time',
'Max Time',
'Total Runs'
));
$output->writeLine(str_repeat('-', 80));
foreach ($slowCommands as $command) {
$output->writeLine(sprintf(
"%-25s %-15s %-15s %d",
$command['command_name'],
$command['average_execution_time']->toHumanReadable(),
$command['max_execution_time']->toHumanReadable(),
$command['total_executions']
));
}
$output->writeLine('');
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'analytics:cleanup',
description: 'Clean up old analytics data'
)]
public function cleanup(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$days = (int) ($input->getOption('days') ?? 90);
$output->writeLine("<yellow>Cleaning up analytics data older than {$days} days...</yellow>");
$deletedRecords = $this->analyticsService->cleanupOldData($days);
$output->writeLine("<green>Successfully deleted {$deletedRecords} old analytics records.</green>");
return ExitCode::SUCCESS;
}
private function showCommandUsage(string $commandName, DateTimeImmutable $since, ConsoleOutput $output): ExitCode
{
$stats = $this->analyticsService->getCommandStatistics($commandName, $since);
$output->writeLine("<yellow>Usage Statistics for '{$commandName}'</yellow>\n");
if ($stats->totalExecutions === 0) {
$output->writeLine('<red>No usage data found for this command.</red>');
return ExitCode::SUCCESS;
}
$output->writeLine("Total Executions: {$stats->totalExecutions}");
$output->writeLine("Successful: {$stats->successfulExecutions} ({$stats->getSuccessRate()->format(1)})");
$output->writeLine("Failed: {$stats->failedExecutions} ({$stats->getFailureRate()->format(1)})");
$output->writeLine('');
$output->writeLine("Performance:");
$output->writeLine(" Average Time: {$stats->averageExecutionTime->toHumanReadable()}");
$output->writeLine(" Min Time: {$stats->minExecutionTime->toHumanReadable()}");
$output->writeLine(" Max Time: {$stats->maxExecutionTime->toHumanReadable()}");
if ($stats->medianExecutionTime) {
$output->writeLine(" Median Time: {$stats->medianExecutionTime->toHumanReadable()}");
}
// Show hourly distribution
if ($stats->hourlyDistribution) {
$output->writeLine('');
$output->writeLine("Usage by Hour:");
$maxUsage = max($stats->hourlyDistribution);
for ($hour = 0; $hour < 24; $hour++) {
$usage = $stats->hourlyDistribution[$hour];
$percentage = $maxUsage > 0 ? ($usage / $maxUsage) * 100 : 0;
$bar = str_repeat('▓', (int) ($percentage / 5));
$output->writeLine(sprintf(
"%02d:00 %4d %s",
$hour,
$usage,
$bar
));
}
}
return ExitCode::SUCCESS;
}
private function showOverallUsage(DateTimeImmutable $since, ConsoleOutput $output): ExitCode
{
$commands = $this->analyticsService->getAllCommandNames();
$output->writeLine("<yellow>Overall Command Usage Statistics</yellow>\n");
if (empty($commands)) {
$output->writeLine('<red>No command usage data found.</red>');
return ExitCode::SUCCESS;
}
$totalExecutions = 0;
$totalCommands = count($commands);
foreach ($commands as $commandName) {
$stats = $this->analyticsService->getCommandStatistics($commandName, $since);
$totalExecutions += $stats->totalExecutions;
}
$output->writeLine("Total Commands: {$totalCommands}");
$output->writeLine("Total Executions: {$totalExecutions}");
$output->writeLine("Average per Command: " . round($totalExecutions / $totalCommands, 2));
$output->writeLine('');
// Show usage by hour
$hourlyUsage = $this->analyticsService->getUsageByHour($since);
$output->writeLine("Usage Distribution by Hour:");
$maxHourly = max($hourlyUsage);
for ($hour = 0; $hour < 24; $hour++) {
$usage = $hourlyUsage[$hour];
$percentage = $maxHourly > 0 ? ($usage / $maxHourly) * 100 : 0;
$bar = str_repeat('▓', (int) ($percentage / 5));
$output->writeLine(sprintf(
"%02d:00 %4d %s",
$hour,
$usage,
$bar
));
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Middleware;
use App\Framework\Console\Analytics\Repository\CommandUsageRepository;
use App\Framework\Console\Analytics\ValueObjects\CommandUsageMetric;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Middleware\ConsoleMiddleware;
use App\Framework\Console\Output\ConsoleOutput;
use App\Framework\Core\ValueObjects\Duration;
use DateTimeImmutable;
final readonly class AnalyticsCollectionMiddleware implements ConsoleMiddleware
{
public function __construct(
private CommandUsageRepository $repository,
private bool $enabled = true
) {
}
public function handle(ConsoleInput $input, ConsoleOutput $output, callable $next): ExitCode
{
if (! $this->enabled) {
return $next($input, $output);
}
$commandName = $this->extractCommandName($input);
$startTime = hrtime(true);
$executedAt = new DateTimeImmutable();
try {
$exitCode = $next($input, $output);
} catch (\Throwable $e) {
// Record the failure and re-throw
$this->recordUsage(
$commandName,
$executedAt,
$startTime,
ExitCode::GENERAL_ERROR,
$input
);
throw $e;
}
$this->recordUsage($commandName, $executedAt, $startTime, $exitCode, $input);
return $exitCode;
}
private function recordUsage(
string $commandName,
DateTimeImmutable $executedAt,
int $startTime,
ExitCode $exitCode,
ConsoleInput $input
): void {
try {
$endTime = hrtime(true);
$executionTimeNs = $endTime - $startTime;
$executionTime = Duration::fromNanoseconds($executionTimeNs);
$metric = CommandUsageMetric::create(
commandName: $commandName,
executionTime: $executionTime,
exitCode: $exitCode,
argumentCount: count($input->getArguments()),
userId: $this->getCurrentUserId(),
metadata: $this->collectMetadata($input, $exitCode)
);
$this->repository->store($metric);
} catch (\Throwable $e) {
// Analytics collection should never break the command execution
// In production, this could be logged to a separate error log
error_log("Analytics collection failed: " . $e->getMessage());
}
}
private function extractCommandName(ConsoleInput $input): string
{
$arguments = $input->getArguments();
// First argument after script name is usually the command
return $arguments[1] ?? 'unknown';
}
private function getCurrentUserId(): ?string
{
// In a real implementation, this would extract user ID from context
// Could be from environment variables, session data, etc.
return $_ENV['CONSOLE_USER_ID'] ?? null;
}
private function collectMetadata(ConsoleInput $input, ExitCode $exitCode): array
{
$metadata = [
'options' => $input->getOptions(),
'has_stdin' => ! empty(stream_get_contents(STDIN, 1)),
'php_version' => PHP_VERSION,
'memory_peak' => memory_get_peak_usage(true),
'exit_code_name' => $exitCode->name,
];
// Add environment context
if (isset($_ENV['APP_ENV'])) {
$metadata['environment'] = $_ENV['APP_ENV'];
}
// Add process info
if (function_exists('posix_getpid')) {
$metadata['process_id'] = posix_getpid();
}
return $metadata;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
final readonly class CreateCommandUsageAnalyticsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = "CREATE TABLE IF NOT EXISTS command_usage_analytics (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
command_name VARCHAR(255) NOT NULL,
executed_at DATETIME NOT NULL,
execution_time_ms DECIMAL(10, 3) NOT NULL,
exit_code TINYINT NOT NULL DEFAULT 0,
argument_count INT NOT NULL DEFAULT 0,
user_id VARCHAR(255) NULL,
metadata JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_command_name (command_name),
INDEX idx_executed_at (executed_at),
INDEX idx_user_id (user_id),
INDEX idx_command_executed (command_name, executed_at),
INDEX idx_command_exit (command_name, exit_code),
INDEX idx_executed_exit (executed_at, exit_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
$connection->execute($sql);
}
public function down(ConnectionInterface $connection): void
{
$connection->execute("DROP TABLE IF EXISTS command_usage_analytics");
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2024_01_01_000001');
}
public function getDescription(): string
{
return 'Create command_usage_analytics table for console command metrics';
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Repository;
use App\Framework\Console\Analytics\ValueObjects\CommandUsageMetric;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Analytics\ValueObjects\UsageStatistics;
use DateTimeImmutable;
interface CommandUsageRepository
{
public function store(CommandUsageMetric $metric): void;
public function storeMultiple(CommandUsageMetric ...$metrics): void;
public function getUsageStatistics(string $commandName, ?DateTimeImmutable $since = null): UsageStatistics;
public function getPopularCommands(int $limit = 10, ?DateTimeImmutable $since = null): array;
public function getTrendData(
string $commandName,
Period $period,
DateTimeImmutable $start,
DateTimeImmutable $end
): array;
public function getAllCommandNames(): array;
public function cleanup(DateTimeImmutable $before): int;
}

View File

@@ -0,0 +1,391 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Repository;
use App\Framework\Console\Analytics\ValueObjects\CommandPopularity;
use App\Framework\Console\Analytics\ValueObjects\CommandUsageMetric;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Analytics\ValueObjects\UsageStatistics;
use App\Framework\Console\Analytics\ValueObjects\UsageTrend;
use App\Framework\Console\Analytics\ValueObjects\UsageTrendPoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Database\Connection;
use DateTimeImmutable;
final readonly class DatabaseCommandUsageRepository implements CommandUsageRepository
{
public function __construct(
private Connection $connection
) {
}
public function store(CommandUsageMetric $metric): void
{
$sql = "INSERT INTO command_usage_analytics (
command_name, executed_at, execution_time_ms, exit_code,
argument_count, user_id, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?)";
$this->connection->execute($sql, [
$metric->commandName,
$metric->executedAt->format('Y-m-d H:i:s'),
$metric->executionTime->toMilliseconds(),
$metric->exitCode->value,
$metric->argumentCount,
$metric->userId,
$metric->metadata ? json_encode($metric->metadata) : null,
]);
}
public function storeMultiple(CommandUsageMetric ...$metrics): void
{
if (empty($metrics)) {
return;
}
$values = [];
$placeholders = [];
foreach ($metrics as $metric) {
$placeholders[] = "(?, ?, ?, ?, ?, ?, ?)";
$values[] = $metric->commandName;
$values[] = $metric->executedAt->format('Y-m-d H:i:s');
$values[] = $metric->executionTime->toMilliseconds();
$values[] = $metric->exitCode->value;
$values[] = $metric->argumentCount;
$values[] = $metric->userId;
$values[] = $metric->metadata ? json_encode($metric->metadata) : null;
}
$sql = "INSERT INTO command_usage_analytics (
command_name, executed_at, execution_time_ms, exit_code,
argument_count, user_id, metadata
) VALUES " . implode(', ', $placeholders);
$this->connection->execute($sql, $values);
}
public function getUsageStatistics(string $commandName, ?DateTimeImmutable $since = null): UsageStatistics
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
COUNT(*) as total_executions,
SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) as successful_executions,
SUM(CASE WHEN exit_code != 0 THEN 1 ELSE 0 END) as failed_executions,
AVG(execution_time_ms) as avg_execution_time,
MIN(execution_time_ms) as min_execution_time,
MAX(execution_time_ms) as max_execution_time,
GROUP_CONCAT(DISTINCT exit_code) as exit_codes
FROM command_usage_analytics
{$whereClause}";
$result = $this->connection->query($sql, $params);
if (empty($result) || $result[0]['total_executions'] == 0) {
return UsageStatistics::empty($commandName);
}
$row = $result[0];
$hourlyDistribution = $this->getHourlyDistribution($commandName, $since);
$exitCodeDistribution = $this->getExitCodeDistribution($commandName, $since);
return new UsageStatistics(
commandName: $commandName,
totalExecutions: (int) $row['total_executions'],
successfulExecutions: (int) $row['successful_executions'],
failedExecutions: (int) $row['failed_executions'],
averageExecutionTime: Duration::fromMilliseconds((float) $row['avg_execution_time']),
minExecutionTime: Duration::fromMilliseconds((float) $row['min_execution_time']),
maxExecutionTime: Duration::fromMilliseconds((float) $row['max_execution_time']),
medianExecutionTime: $this->getMedianExecutionTime($commandName, $since),
hourlyDistribution: $hourlyDistribution,
exitCodeDistribution: $exitCodeDistribution
);
}
public function getPopularCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
$whereClause = '';
$params = [];
if ($since) {
$whereClause = 'WHERE executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
command_name,
COUNT(*) as total_executions,
AVG(execution_time_ms) as avg_execution_time,
SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) / COUNT(*) * 100 as success_rate
FROM command_usage_analytics
{$whereClause}
GROUP BY command_name
ORDER BY total_executions DESC
LIMIT ?";
$params[] = $limit;
$results = $this->connection->query($sql, $params);
$totalExecutions = $this->getTotalExecutions($since);
$popularCommands = [];
foreach ($results as $index => $row) {
$usagePercentage = $totalExecutions > 0
? Percentage::fromValue(($row['total_executions'] / $totalExecutions) * 100)
: Percentage::zero();
$popularCommands[] = new CommandPopularity(
commandName: $row['command_name'],
rank: $index + 1,
totalExecutions: (int) $row['total_executions'],
usagePercentage: $usagePercentage,
averageExecutionTime: Duration::fromMilliseconds((float) $row['avg_execution_time']),
successRate: Percentage::fromValue((float) $row['success_rate'])
);
}
return $popularCommands;
}
public function getTrendData(
string $commandName,
Period $period,
DateTimeImmutable $start,
DateTimeImmutable $end
): array {
$groupByClause = match ($period) {
Period::HOURLY => "DATE_FORMAT(executed_at, '%Y-%m-%d %H:00:00')",
Period::DAILY => "DATE(executed_at)",
Period::WEEKLY => "YEARWEEK(executed_at, 1)",
Period::MONTHLY => "DATE_FORMAT(executed_at, '%Y-%m')",
Period::YEARLY => "YEAR(executed_at)"
};
$sql = "SELECT
{$groupByClause} as period_date,
COUNT(*) as execution_count,
AVG(execution_time_ms) as avg_execution_time,
SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) / COUNT(*) * 100 as success_rate
FROM command_usage_analytics
WHERE command_name = ?
AND executed_at BETWEEN ? AND ?
GROUP BY {$groupByClause}
ORDER BY period_date";
$results = $this->connection->query($sql, [
$commandName,
$start->format('Y-m-d H:i:s'),
$end->format('Y-m-d H:i:s'),
]);
$trendPoints = [];
foreach ($results as $row) {
$date = match ($period) {
Period::WEEKLY => new DateTimeImmutable($row['period_date'] . '-1'), // First day of week
default => new DateTimeImmutable($row['period_date'])
};
$trendPoints[] = new UsageTrendPoint(
date: $date,
executionCount: (int) $row['execution_count'],
averageExecutionTime: Duration::fromMilliseconds((float) $row['avg_execution_time']),
successRate: Percentage::fromValue((float) $row['success_rate'])
);
}
// Calculate trend direction and strength
$trendDirection = $this->calculateTrendDirection($trendPoints);
$trendStrength = $this->calculateTrendStrength($trendPoints);
return [new UsageTrend(
commandName: $commandName,
period: $period,
startDate: $start,
endDate: $end,
dataPoints: $trendPoints,
trendDirection: $trendDirection,
trendStrength: $trendStrength
)];
}
public function getAllCommandNames(): array
{
$sql = "SELECT DISTINCT command_name FROM command_usage_analytics ORDER BY command_name";
$results = $this->connection->query($sql);
return array_column($results, 'command_name');
}
public function cleanup(DateTimeImmutable $before): int
{
$sql = "DELETE FROM command_usage_analytics WHERE executed_at < ?";
return $this->connection->execute($sql, [$before->format('Y-m-d H:i:s')]);
}
private function getHourlyDistribution(string $commandName, ?DateTimeImmutable $since): array
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
HOUR(executed_at) as hour,
COUNT(*) as count
FROM command_usage_analytics
{$whereClause}
GROUP BY HOUR(executed_at)
ORDER BY hour";
$results = $this->connection->query($sql, $params);
// Initialize all hours to 0
$distribution = array_fill(0, 24, 0);
foreach ($results as $row) {
$distribution[(int) $row['hour']] = (int) $row['count'];
}
return $distribution;
}
private function getExitCodeDistribution(string $commandName, ?DateTimeImmutable $since): array
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
exit_code,
COUNT(*) as count
FROM command_usage_analytics
{$whereClause}
GROUP BY exit_code
ORDER BY exit_code";
$results = $this->connection->query($sql, $params);
$distribution = [];
foreach ($results as $row) {
$distribution[$row['exit_code']] = (int) $row['count'];
}
return $distribution;
}
private function getMedianExecutionTime(string $commandName, ?DateTimeImmutable $since): ?Duration
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
// MySQL median calculation using percentiles
$sql = "SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY execution_time_ms) as median_time
FROM command_usage_analytics
{$whereClause}";
$result = $this->connection->query($sql, $params);
if (empty($result) || $result[0]['median_time'] === null) {
return null;
}
return Duration::fromMilliseconds((float) $result[0]['median_time']);
}
private function getTotalExecutions(?DateTimeImmutable $since): int
{
$whereClause = '';
$params = [];
if ($since) {
$whereClause = 'WHERE executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT COUNT(*) as total FROM command_usage_analytics {$whereClause}";
$result = $this->connection->query($sql, $params);
return (int) $result[0]['total'];
}
private function calculateTrendDirection(array $trendPoints): float
{
if (count($trendPoints) < 2) {
return 0.0;
}
$xValues = array_keys($trendPoints);
$yValues = array_map(fn ($point) => $point->executionCount, $trendPoints);
// Simple linear regression slope calculation
$n = count($trendPoints);
$sumX = array_sum($xValues);
$sumY = array_sum($yValues);
$sumXY = 0;
$sumXX = 0;
for ($i = 0; $i < $n; $i++) {
$sumXY += $xValues[$i] * $yValues[$i];
$sumXX += $xValues[$i] * $xValues[$i];
}
$denominator = $n * $sumXX - $sumX * $sumX;
if ($denominator == 0) {
return 0.0;
}
return ($n * $sumXY - $sumX * $sumY) / $denominator;
}
private function calculateTrendStrength(array $trendPoints): float
{
if (count($trendPoints) < 2) {
return 0.0;
}
$values = array_map(fn ($point) => $point->executionCount, $trendPoints);
$mean = array_sum($values) / count($values);
// Calculate coefficient of variation
$variance = 0;
foreach ($values as $value) {
$variance += ($value - $mean) ** 2;
}
$variance /= count($values);
$stdDev = sqrt($variance);
if ($mean == 0) {
return 0.0;
}
$coefficientOfVariation = $stdDev / $mean;
// Convert to 0-1 scale where higher variation = stronger trend
return min(1.0, $coefficientOfVariation);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Value object representing command popularity ranking
*/
final readonly class CommandPopularity
{
public function __construct(
public string $commandName,
public int $rank,
public int $totalExecutions,
public Percentage $usagePercentage,
public Duration $averageExecutionTime,
public Percentage $successRate,
public ?string $category = null
) {
}
public function isPopular(): bool
{
return $this->rank <= 10 || $this->usagePercentage->getValue() >= 5.0;
}
public function isRare(): bool
{
return $this->usagePercentage->getValue() < 0.5;
}
public function getPopularityLevel(): string
{
$usage = $this->usagePercentage->getValue();
return match (true) {
$usage >= 20 => 'very_high',
$usage >= 10 => 'high',
$usage >= 5 => 'medium',
$usage >= 1 => 'low',
default => 'very_low'
};
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'rank' => $this->rank,
'total_executions' => $this->totalExecutions,
'usage_percentage' => $this->usagePercentage->format(2),
'usage_percentage_value' => $this->usagePercentage->getValue(),
'average_execution_time' => $this->averageExecutionTime->toHumanReadable(),
'success_rate' => $this->successRate->format(1),
'success_rate_value' => $this->successRate->getValue(),
'category' => $this->category,
'popularity_level' => $this->getPopularityLevel(),
'is_popular' => $this->isPopular(),
'is_rare' => $this->isRare(),
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Duration;
use DateTimeImmutable;
/**
* Value object representing a single command usage metric
*/
final readonly class CommandUsageMetric
{
public function __construct(
public string $commandName,
public DateTimeImmutable $executedAt,
public Duration $executionTime,
public ExitCode $exitCode,
public int $argumentCount,
public ?string $userId = null,
public ?array $metadata = null
) {
}
public function isSuccessful(): bool
{
return $this->exitCode === ExitCode::SUCCESS;
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'executed_at' => $this->executedAt->format('Y-m-d H:i:s'),
'execution_time_ms' => $this->executionTime->toMilliseconds(),
'execution_time_seconds' => $this->executionTime->toSeconds(),
'execution_time_human' => $this->executionTime->toHumanReadable(),
'exit_code' => $this->exitCode->value,
'argument_count' => $this->argumentCount,
'user_id' => $this->userId,
'metadata' => $this->metadata,
'is_successful' => $this->isSuccessful(),
];
}
public static function fromArray(array $data): self
{
return new self(
commandName: $data['command_name'],
executedAt: new DateTimeImmutable($data['executed_at']),
executionTime: Duration::fromMilliseconds((float) $data['execution_time_ms']),
exitCode: ExitCode::from((int) $data['exit_code']),
argumentCount: (int) $data['argument_count'],
userId: $data['user_id'] ?? null,
metadata: $data['metadata'] ?? null
);
}
public static function create(
string $commandName,
Duration $executionTime,
ExitCode $exitCode,
int $argumentCount,
?string $userId = null,
?array $metadata = null
): self {
return new self(
commandName: $commandName,
executedAt: new DateTimeImmutable(),
executionTime: $executionTime,
exitCode: $exitCode,
argumentCount: $argumentCount,
userId: $userId,
metadata: $metadata
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
/**
* Enum for analytics time periods
*/
enum Period: string
{
case HOURLY = 'hourly';
case DAILY = 'daily';
case WEEKLY = 'weekly';
case MONTHLY = 'monthly';
case YEARLY = 'yearly';
public function getDescription(): string
{
return match ($this) {
self::HOURLY => 'Hourly analysis',
self::DAILY => 'Daily analysis',
self::WEEKLY => 'Weekly analysis',
self::MONTHLY => 'Monthly analysis',
self::YEARLY => 'Yearly analysis',
};
}
public function getDateFormat(): string
{
return match ($this) {
self::HOURLY => 'Y-m-d H:00',
self::DAILY => 'Y-m-d',
self::WEEKLY => 'Y-\WW',
self::MONTHLY => 'Y-m',
self::YEARLY => 'Y',
};
}
public function getIntervalSpec(): string
{
return match ($this) {
self::HOURLY => 'PT1H',
self::DAILY => 'P1D',
self::WEEKLY => 'P1W',
self::MONTHLY => 'P1M',
self::YEARLY => 'P1Y',
};
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Value object representing aggregated usage statistics for a command
*/
final readonly class UsageStatistics
{
public function __construct(
public string $commandName,
public int $totalExecutions,
public int $successfulExecutions,
public int $failedExecutions,
public Duration $averageExecutionTime,
public Duration $minExecutionTime,
public Duration $maxExecutionTime,
public ?Duration $medianExecutionTime = null,
public ?array $hourlyDistribution = null,
public ?array $exitCodeDistribution = null
) {
}
public function getSuccessRate(): Percentage
{
if ($this->totalExecutions === 0) {
return Percentage::zero();
}
return Percentage::fromRatio($this->successfulExecutions, $this->totalExecutions);
}
public function getFailureRate(): Percentage
{
if ($this->totalExecutions === 0) {
return Percentage::zero();
}
return Percentage::fromRatio($this->failedExecutions, $this->totalExecutions);
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'total_executions' => $this->totalExecutions,
'successful_executions' => $this->successfulExecutions,
'failed_executions' => $this->failedExecutions,
'success_rate' => $this->getSuccessRate()->format(2),
'failure_rate' => $this->getFailureRate()->format(2),
'average_execution_time' => $this->averageExecutionTime->toHumanReadable(),
'average_execution_time_ms' => $this->averageExecutionTime->toMilliseconds(),
'min_execution_time' => $this->minExecutionTime->toHumanReadable(),
'max_execution_time' => $this->maxExecutionTime->toHumanReadable(),
'median_execution_time' => $this->medianExecutionTime?->toHumanReadable(),
'hourly_distribution' => $this->hourlyDistribution,
'exit_code_distribution' => $this->exitCodeDistribution,
];
}
public static function empty(string $commandName): self
{
return new self(
commandName: $commandName,
totalExecutions: 0,
successfulExecutions: 0,
failedExecutions: 0,
averageExecutionTime: Duration::zero(),
minExecutionTime: Duration::zero(),
maxExecutionTime: Duration::zero(),
medianExecutionTime: null,
hourlyDistribution: array_fill(0, 24, 0),
exitCodeDistribution: []
);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use DateTimeImmutable;
/**
* Value object representing usage trends over time
*/
final readonly class UsageTrend
{
public function __construct(
public string $commandName,
public Period $period,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
public array $dataPoints, // Array of UsageTrendPoint
public float $trendDirection, // Positive = increasing, negative = decreasing
public float $trendStrength // 0-1, how strong the trend is
) {
}
public function isIncreasing(): bool
{
return $this->trendDirection > 0;
}
public function isDecreasing(): bool
{
return $this->trendDirection < 0;
}
public function isStable(): bool
{
return abs($this->trendDirection) < 0.1;
}
public function getTrendDescription(): string
{
if ($this->isStable()) {
return 'stable';
}
$strength = $this->trendStrength > 0.7 ? 'strongly' :
($this->trendStrength > 0.4 ? 'moderately' : 'slightly');
return $this->isIncreasing() ? "$strength increasing" : "$strength decreasing";
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'period' => $this->period->value,
'period_description' => $this->period->getDescription(),
'start_date' => $this->startDate->format('Y-m-d'),
'end_date' => $this->endDate->format('Y-m-d'),
'data_points' => array_map(fn ($point) => $point->toArray(), $this->dataPoints),
'trend_direction' => $this->trendDirection,
'trend_strength' => $this->trendStrength,
'trend_description' => $this->getTrendDescription(),
];
}
}
/**
* Single data point in a usage trend
*/
final readonly class UsageTrendPoint
{
public function __construct(
public DateTimeImmutable $date,
public int $executionCount,
public Duration $averageExecutionTime,
public Percentage $successRate
) {
}
public function toArray(): array
{
return [
'date' => $this->date->format('Y-m-d'),
'execution_count' => $this->executionCount,
'average_execution_time' => $this->averageExecutionTime->toHumanReadable(),
'average_execution_time_ms' => $this->averageExecutionTime->toMilliseconds(),
'success_rate' => $this->successRate->format(2),
'success_rate_value' => $this->successRate->getValue(),
];
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Definition of a console command argument or option
*/
final readonly class ArgumentDefinition
{
public function __construct(
public string $name,
public ArgumentType $type = ArgumentType::STRING,
public bool $required = false,
public mixed $default = null,
public string $description = '',
public ?string $shortName = null,
/** @var string[] */
public array $allowedValues = []
) {
// Validate configuration
if (empty(trim($this->name))) {
throw new \InvalidArgumentException('Argument name cannot be empty');
}
if ($this->shortName !== null && strlen($this->shortName) !== 1) {
throw new \InvalidArgumentException('Short name must be exactly one character');
}
if ($this->required && $this->default !== null) {
throw new \InvalidArgumentException('Required arguments cannot have default values');
}
if ($this->type === ArgumentType::BOOLEAN && ! empty($this->allowedValues)) {
throw new \InvalidArgumentException('Boolean arguments cannot have allowed values');
}
}
/**
* Create a required string argument
*/
public static function required(string $name, string $description = ''): self
{
return new self($name, ArgumentType::STRING, required: true, description: $description);
}
/**
* Create an optional string argument with default
*/
public static function optional(string $name, string $default = '', string $description = ''): self
{
return new self($name, ArgumentType::STRING, default: $default, description: $description);
}
/**
* Create a boolean flag
*/
public static function flag(string $name, ?string $shortName = null, string $description = ''): self
{
return new self($name, ArgumentType::BOOLEAN, shortName: $shortName, description: $description);
}
/**
* Create an email argument
*/
public static function email(string $name, bool $required = true, string $description = ''): self
{
return new self($name, ArgumentType::EMAIL, required: $required, description: $description);
}
/**
* Create an integer argument
*/
public static function integer(string $name, bool $required = false, ?int $default = null, string $description = ''): self
{
return new self($name, ArgumentType::INTEGER, required: $required, default: $default, description: $description);
}
/**
* Create a choice argument with allowed values
*/
public static function choice(string $name, array $allowedValues, bool $required = false, ?string $default = null, string $description = ''): self
{
return new self(
$name,
ArgumentType::STRING,
required: $required,
default: $default,
description: $description,
allowedValues: $allowedValues
);
}
/**
* Get the display name for help text
*/
public function getDisplayName(): string
{
$display = $this->name;
if ($this->shortName) {
$display = "{$this->shortName}, {$display}";
}
return $display;
}
/**
* Get usage text for this argument
*/
public function getUsageText(): string
{
if ($this->type === ArgumentType::BOOLEAN) {
return $this->required ? "--{$this->name}" : "[--{$this->name}]";
}
$usage = "--{$this->name}";
if (! empty($this->allowedValues)) {
$usage .= "=" . implode('|', $this->allowedValues);
} else {
$usage .= "={$this->type->getExample()}";
}
return $this->required ? "<{$usage}>" : "[{$usage}]";
}
/**
* Validate a value against this argument definition
*/
public function validateValue(mixed $value): void
{
if ($this->required && ($value === null || $value === '')) {
throw new \InvalidArgumentException("Required argument '{$this->name}' is missing");
}
if (! empty($this->allowedValues) && ! in_array($value, $this->allowedValues, true)) {
throw new \InvalidArgumentException(
"Invalid value '{$value}' for argument '{$this->name}'. Allowed values: " .
implode(', ', $this->allowedValues)
);
}
}
}

View File

@@ -0,0 +1,378 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Advanced argument parser for console commands
*
* Supports:
* - Long options: --option=value, --option value, --flag
* - Short options: -o value, -f, -abc (combined flags)
* - Positional arguments
* - Type validation and casting
* - Required/optional arguments
*/
final readonly class ArgumentParser
{
/** @param array<string, ArgumentDefinition> $definitions */
public function __construct(
private array $definitions = []
) {
}
/**
* Parse command line arguments
*
* @param string[] $arguments Raw command line arguments
*/
public function parse(array $arguments): ParsedArguments
{
$parsed = [
'arguments' => [],
'options' => [],
'flags' => [],
'positional' => [],
];
$i = 0;
while ($i < count($arguments)) {
$arg = $arguments[$i];
if (str_starts_with($arg, '--')) {
$i = $this->parseLongOption($arguments, $i, $parsed);
} elseif (str_starts_with($arg, '-') && strlen($arg) > 1) {
$i = $this->parseShortOption($arguments, $i, $parsed);
} else {
$this->parsePositionalArgument($arg, $parsed);
$i++;
}
}
// Merge all parsed values
$allValues = array_merge($parsed['arguments'], $parsed['options'], $parsed['flags']);
// Apply defaults for missing values
foreach ($this->definitions as $name => $definition) {
if (! array_key_exists($name, $allValues) && $definition->default !== null) {
$allValues[$name] = $definition->default;
}
}
$result = new ParsedArguments(
$parsed['arguments'],
array_merge($parsed['options'], $parsed['flags']),
$this->definitions
);
// Validate all requirements
$result->validate();
return $result;
}
/**
* Parse long option (--option or --option=value)
*/
private function parseLongOption(array $arguments, int $index, array &$parsed): int
{
$arg = $arguments[$index];
$optionPart = substr($arg, 2); // Remove '--'
// Handle --option=value
if (str_contains($optionPart, '=')) {
[$name, $value] = explode('=', $optionPart, 2);
$parsed['options'][$name] = $this->parseValue($name, $value);
return $index + 1;
}
// Handle --option value or --flag
$definition = $this->findDefinitionByName($optionPart);
if ($definition && $definition->type === ArgumentType::BOOLEAN) {
// Boolean flag
$parsed['flags'][$optionPart] = true;
return $index + 1;
}
// Option that expects a value
if ($index + 1 >= count($arguments) || str_starts_with($arguments[$index + 1], '-')) {
if ($definition && $definition->required) {
throw new \InvalidArgumentException("Option '--{$optionPart}' requires a value");
}
// Optional option without value - treat as flag
$parsed['flags'][$optionPart] = true;
return $index + 1;
}
$parsed['options'][$optionPart] = $this->parseValue($optionPart, $arguments[$index + 1]);
return $index + 2;
}
/**
* Parse short option (-o or -o value or -abc)
*/
private function parseShortOption(array $arguments, int $index, array &$parsed): int
{
$arg = $arguments[$index];
$options = substr($arg, 1); // Remove '-'
// Handle combined short options: -abc = -a -b -c
if (strlen($options) > 1) {
foreach (str_split($options) as $shortOption) {
$definition = $this->findDefinitionByShortName($shortOption);
$longName = $definition?->name ?? $shortOption;
if ($definition && $definition->type !== ArgumentType::BOOLEAN) {
throw new \InvalidArgumentException(
"Short option '-{$shortOption}' requires a value and cannot be combined with other options"
);
}
$parsed['flags'][$longName] = true;
}
return $index + 1;
}
// Handle single short option: -f or -f value
$shortName = $options;
$definition = $this->findDefinitionByShortName($shortName);
$longName = $definition?->name ?? $shortName;
if ($definition && $definition->type === ArgumentType::BOOLEAN) {
$parsed['flags'][$longName] = true;
return $index + 1;
}
// Option that expects a value
if ($index + 1 >= count($arguments) || str_starts_with($arguments[$index + 1], '-')) {
if ($definition && $definition->required) {
throw new \InvalidArgumentException("Option '-{$shortName}' requires a value");
}
// Optional option without value - treat as flag
$parsed['flags'][$longName] = true;
return $index + 1;
}
$parsed['options'][$longName] = $this->parseValue($longName, $arguments[$index + 1]);
return $index + 2;
}
/**
* Parse positional argument
*/
private function parsePositionalArgument(string $value, array &$parsed): void
{
$parsed['positional'][] = $value;
// Try to match with positional argument definitions
$positionalIndex = count($parsed['positional']) - 1;
$positionalDefs = array_values(array_filter(
$this->definitions,
fn ($def) => $def->type !== ArgumentType::BOOLEAN
));
if (isset($positionalDefs[$positionalIndex])) {
$definition = $positionalDefs[$positionalIndex];
$parsed['arguments'][$definition->name] = $this->parseValue($definition->name, $value);
}
}
/**
* Parse and validate a value according to its definition
*/
private function parseValue(string $name, string $value): mixed
{
$definition = $this->findDefinitionByName($name);
if (! $definition) {
return $value; // Unknown option, keep as string
}
// Validate against allowed values
if (! empty($definition->allowedValues) && ! in_array($value, $definition->allowedValues, true)) {
throw new \InvalidArgumentException(
"Invalid value '{$value}' for '{$name}'. Allowed values: " .
implode(', ', $definition->allowedValues)
);
}
return $this->castToType($value, $definition->type);
}
/**
* Cast value to appropriate type
*/
private function castToType(mixed $value, ArgumentType $type): mixed
{
return match($type) {
ArgumentType::STRING => (string) $value,
ArgumentType::INTEGER => $this->parseInteger($value),
ArgumentType::FLOAT => $this->parseFloat($value),
ArgumentType::BOOLEAN => $this->parseBoolean($value),
ArgumentType::ARRAY => $this->parseArray($value),
ArgumentType::EMAIL => $this->validateEmail($value),
ArgumentType::URL => $this->validateUrl($value),
};
}
private function parseInteger(mixed $value): int
{
if (! is_numeric($value)) {
throw new \InvalidArgumentException("Value '{$value}' is not a valid integer");
}
return (int) $value;
}
private function parseFloat(mixed $value): float
{
if (! is_numeric($value)) {
throw new \InvalidArgumentException("Value '{$value}' is not a valid number");
}
return (float) $value;
}
private function parseBoolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
$lowered = strtolower((string) $value);
return in_array($lowered, ['true', '1', 'yes', 'on'], true);
}
private function parseArray(mixed $value): array
{
if (is_array($value)) {
return $value;
}
$items = array_map('trim', explode(',', (string) $value));
return array_filter($items, fn ($item) => $item !== '');
}
private function validateEmail(mixed $value): string
{
$email = (string) $value;
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("'{$email}' is not a valid email address");
}
return $email;
}
private function validateUrl(mixed $value): string
{
$url = (string) $value;
if (! filter_var($url, FILTER_VALIDATE_URL)) {
throw new \InvalidArgumentException("'{$url}' is not a valid URL");
}
return $url;
}
/**
* Find argument definition by long name
*/
private function findDefinitionByName(string $name): ?ArgumentDefinition
{
return $this->definitions[$name] ?? null;
}
/**
* Find argument definition by short name
*/
private function findDefinitionByShortName(string $shortName): ?ArgumentDefinition
{
foreach ($this->definitions as $definition) {
if ($definition->shortName === $shortName) {
return $definition;
}
}
return null;
}
/**
* Get all argument definitions
*
* @return array<string, ArgumentDefinition>
*/
public function getDefinitions(): array
{
return $this->definitions;
}
/**
* Create parser with fluent interface
*/
public static function create(): ArgumentParserBuilder
{
return new ArgumentParserBuilder();
}
}
/**
* Fluent builder for ArgumentParser
*/
final class ArgumentParserBuilder
{
/** @var ArgumentDefinition[] */
private array $definitions = [];
public function addArgument(ArgumentDefinition $definition): self
{
$this->definitions[$definition->name] = $definition;
return $this;
}
public function requiredString(string $name, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::required($name, $description));
}
public function optionalString(string $name, string $default = '', string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::optional($name, $default, $description));
}
public function flag(string $name, ?string $shortName = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::flag($name, $shortName, $description));
}
public function email(string $name, bool $required = true, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::email($name, $required, $description));
}
public function integer(string $name, bool $required = false, ?int $default = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::integer($name, $required, $default, $description));
}
public function choice(string $name, array $choices, bool $required = false, ?string $default = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::choice($name, $choices, $required, $default, $description));
}
public function build(): ArgumentParser
{
return new ArgumentParser($this->definitions);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Supported argument types for console commands
*/
enum ArgumentType: string
{
case STRING = 'string';
case INTEGER = 'int';
case FLOAT = 'float';
case BOOLEAN = 'bool';
case ARRAY = 'array';
case EMAIL = 'email';
case URL = 'url';
/**
* Get human-readable description of the type
*/
public function getDescription(): string
{
return match($this) {
self::STRING => 'Text string',
self::INTEGER => 'Integer number',
self::FLOAT => 'Decimal number',
self::BOOLEAN => 'True/false flag',
self::ARRAY => 'Comma-separated values',
self::EMAIL => 'Valid email address',
self::URL => 'Valid URL',
};
}
/**
* Check if type requires a value (not just a flag)
*/
public function requiresValue(): bool
{
return $this !== self::BOOLEAN;
}
/**
* Get example value for help text
*/
public function getExample(): string
{
return match($this) {
self::STRING => 'text',
self::INTEGER => '123',
self::FLOAT => '12.34',
self::BOOLEAN => 'true|false',
self::ARRAY => 'item1,item2,item3',
self::EMAIL => 'user@example.com',
self::URL => 'https://example.com',
};
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use Attribute;
/**
* Attribute to define command groups for better organization and workflows
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final readonly class CommandGroup
{
public function __construct(
public string $name,
public string $description = '',
public string $icon = '📁',
public int $priority = 0,
public array $dependencies = [],
public array $tags = [],
public bool $hidden = false
) {
}
/**
* Get group name
*/
public function getName(): string
{
return $this->name;
}
/**
* Get group description
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Get group icon
*/
public function getIcon(): string
{
return $this->icon;
}
/**
* Get priority (higher = shown first)
*/
public function getPriority(): int
{
return $this->priority;
}
/**
* Get group dependencies
*/
public function getDependencies(): array
{
return $this->dependencies;
}
/**
* Get group tags
*/
public function getTags(): array
{
return $this->tags;
}
/**
* Check if group is hidden
*/
public function isHidden(): bool
{
return $this->hidden;
}
/**
* Check if group has tag
*/
public function hasTag(string $tag): bool
{
return in_array($tag, $this->tags, true);
}
}

View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Discovery\Results\DiscoveryRegistry;
/**
* Registry for command groups and workflows
*/
final readonly class CommandGroupRegistry
{
private array $groups;
private array $workflows;
private array $commandGroupMapping;
public function __construct(
private DiscoveryRegistry $discoveryRegistry
) {
$this->groups = $this->loadGroups();
$this->workflows = $this->loadWorkflows();
$this->commandGroupMapping = $this->buildCommandGroupMapping();
}
/**
* Get all command groups
*/
public function getGroups(): array
{
return $this->groups;
}
/**
* Get group by name
*/
public function getGroup(string $name): ?array
{
return $this->groups[$name] ?? null;
}
/**
* Get all workflows
*/
public function getWorkflows(): array
{
return $this->workflows;
}
/**
* Get workflow by name
*/
public function getWorkflow(string $name): ?array
{
return $this->workflows[$name] ?? null;
}
/**
* Get command group mapping
*/
public function getCommandGroupMapping(): array
{
return $this->commandGroupMapping;
}
/**
* Get commands for a group
*/
public function getCommandsForGroup(string $groupName): array
{
$commands = [];
$allCommands = $this->discoveryRegistry->attributes()->get(ConsoleCommand::class);
foreach ($allCommands as $commandData) {
$commandGroup = $this->getCommandGroup($commandData);
if ($commandGroup === $groupName) {
$commands[] = $commandData;
}
}
return $commands;
}
/**
* Get organized command structure
*/
public function getOrganizedCommands(): array
{
$organized = [];
$allCommands = $this->discoveryRegistry->attributes()->get(ConsoleCommand::class);
// Group commands by their groups
foreach ($allCommands as $commandData) {
$groupName = $this->getCommandGroup($commandData);
$group = $this->getGroup($groupName);
if (! isset($organized[$groupName])) {
$organized[$groupName] = [
'name' => $groupName,
'description' => $group['description'] ?? '',
'icon' => $group['icon'] ?? $this->inferIcon($groupName),
'priority' => $group['priority'] ?? 0,
'commands' => [],
];
}
$organized[$groupName]['commands'][] = $commandData;
}
// Sort by priority
uasort($organized, fn ($a, $b) => $b['priority'] <=> $a['priority']);
// Convert to numeric array for TUI navigation (TuiState expects numeric indices)
return array_values($organized);
}
/**
* Check if command has explicit group
*/
public function hasExplicitGroup(array $commandData): bool
{
return $this->getExplicitGroup($commandData) !== null;
}
/**
* Load groups from discovery
*/
private function loadGroups(): array
{
$groups = [];
$results = $this->discoveryRegistry->attributes()->get(CommandGroup::class);
foreach ($results as $result) {
$group = $result->createAttributeInstance();
if ($group === null) {
continue;
}
$groups[$group->name] = [
'name' => $group->name,
'description' => $group->description,
'icon' => $group->icon,
'priority' => $group->priority,
'dependencies' => $group->dependencies,
'tags' => $group->tags,
'hidden' => $group->hidden,
'class' => $result->className->getFullyQualified(),
];
}
return $groups;
}
/**
* Load workflows from discovery
*/
private function loadWorkflows(): array
{
$workflows = [];
$results = $this->discoveryRegistry->attributes()->get(CommandWorkflow::class);
foreach ($results as $result) {
$workflow = $result->createAttributeInstance();
if ($workflow === null) {
continue;
}
$workflows[$workflow->name] = [
'name' => $workflow->name,
'description' => $workflow->description,
'steps' => array_map(
fn ($step) => is_array($step) ? WorkflowStep::fromArray($step) : $step,
$workflow->steps
),
'prerequisites' => $workflow->prerequisites,
'stopOnError' => $workflow->stopOnError,
'rollbackSteps' => $workflow->rollbackSteps,
'timeoutSeconds' => $workflow->timeoutSeconds,
'environment' => $workflow->environment,
'class' => $result->className->getFullyQualified(),
'method' => $result->methodName?->toString(),
];
}
return $workflows;
}
/**
* Build command to group mapping
*/
private function buildCommandGroupMapping(): array
{
$mapping = [];
$allCommands = $this->discoveryRegistry->attributes()->get(ConsoleCommand::class);
foreach ($allCommands as $commandData) {
$attribute = $commandData->createAttributeInstance();
if ($attribute === null) {
continue;
}
$commandName = $attribute->name;
$groupName = $this->getCommandGroup($commandData);
$mapping[$commandName] = $groupName;
}
return $mapping;
}
/**
* Get command group (explicit or inferred)
*/
private function getCommandGroup($commandData): string
{
// Check for explicit group attribute
$explicitGroup = $this->getExplicitGroup($commandData);
if ($explicitGroup !== null) {
return $explicitGroup;
}
// Fall back to inference - get command name from attribute instance
$attribute = $commandData->createAttributeInstance();
if ($attribute === null) {
return 'General';
}
return $this->inferCategory($attribute->name);
}
/**
* Get explicit group from command
*/
private function getExplicitGroup($commandData): ?string
{
$class = $commandData->className->getFullyQualified();
$method = $commandData->methodName?->toString();
// Check method-level group attribute
if ($method) {
$reflection = new \ReflectionMethod($class, $method);
$attributes = $reflection->getAttributes(CommandGroup::class);
if (! empty($attributes)) {
return $attributes[0]->newInstance()->name;
}
}
// Check class-level group attribute
$reflection = new \ReflectionClass($class);
$attributes = $reflection->getAttributes(CommandGroup::class);
if (! empty($attributes)) {
return $attributes[0]->newInstance()->name;
}
return null;
}
/**
* Infer category from command name (fallback)
*/
private function inferCategory(string $commandName): string
{
if (str_contains($commandName, ':')) {
return ucfirst(explode(':', $commandName)[0]);
}
return match (true) {
str_starts_with($commandName, 'test') => 'Testing',
str_starts_with($commandName, 'demo') => 'Demo',
str_starts_with($commandName, 'make') => 'Generator',
str_starts_with($commandName, 'db') => 'Database',
str_starts_with($commandName, 'mcp') => 'MCP',
default => 'General'
};
}
/**
* Infer icon for group
*/
private function inferIcon(string $groupName): string
{
return match (strtolower($groupName)) {
'testing' => '🧪',
'demo' => '🎮',
'generator' => '⚙️',
'database' => '🗄️',
'mcp' => '🤖',
'workflow' => '🔄',
'system' => '⚙️',
'security' => '🛡️',
'general' => '📂',
default => '📁'
};
}
}

View File

@@ -0,0 +1,378 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Value object containing comprehensive command help information
*/
final readonly class CommandHelp
{
public function __construct(
public string $name,
public string $description,
public string $usage,
public array $parameters,
public array $examples,
public string $className,
public string $methodName,
public array $aliases = []
) {
}
/**
* Format help as plain text
*/
public function formatAsText(): string
{
$output = [];
// Header
$output[] = "Command: {$this->name}";
if (! empty($this->aliases)) {
$output[] = "Aliases: " . implode(', ', $this->aliases);
}
$output[] = str_repeat('=', 60);
// Description
if (! empty($this->description)) {
$output[] = "";
$output[] = "DESCRIPTION:";
$output[] = $this->wrapText($this->description, 2);
}
// Usage
$output[] = "";
$output[] = "USAGE:";
$output[] = " {$this->usage}";
// Parameters
if (! empty($this->parameters)) {
$output[] = "";
$output[] = "PARAMETERS:";
foreach ($this->parameters as $param) {
$paramLine = " {$param['name']}";
// Add type info
$type = $param['type']['name'];
if (isset($param['type']['nullable']) && $param['type']['nullable']) {
$type .= '?';
}
$paramLine .= " ({$type})";
// Add required/optional indicator
$paramLine .= $param['required'] ? " *required*" : " [optional]";
$output[] = $paramLine;
// Add description
if (! empty($param['description'])) {
$output[] = " " . $param['description'];
}
// Add default value
if ($param['default'] !== null) {
$defaultValue = is_bool($param['default'])
? ($param['default'] ? 'true' : 'false')
: $param['default'];
$output[] = " Default: {$defaultValue}";
}
// Add validation rules
if (! empty($param['validation_rules'])) {
$rules = [];
foreach ($param['validation_rules'] as $rule => $value) {
if ($rule === 'required') {
continue;
}
$rules[] = "{$rule}: {$value}";
}
if (! empty($rules)) {
$output[] = " Validation: " . implode(', ', $rules);
}
}
$output[] = "";
}
}
// Examples
if (! empty($this->examples)) {
$output[] = "EXAMPLES:";
foreach ($this->examples as $i => $example) {
$output[] = " " . ($i + 1) . ". {$example['description']}";
$output[] = " {$example['command']}";
$output[] = "";
}
}
// Technical info
$output[] = "TECHNICAL INFO:";
$output[] = " Class: {$this->className}";
$output[] = " Method: {$this->methodName}";
return implode("\n", $output);
}
/**
* Format help as colored console output
*/
public function formatAsColoredText(): array
{
$sections = [];
// Header
$sections[] = [
'text' => "Command: {$this->name}",
'color' => 'BRIGHT_CYAN',
];
if (! empty($this->aliases)) {
$sections[] = [
'text' => "Aliases: " . implode(', ', $this->aliases),
'color' => 'GRAY',
];
}
$sections[] = [
'text' => str_repeat('=', 60),
'color' => 'GRAY',
];
// Description
if (! empty($this->description)) {
$sections[] = ['text' => "", 'color' => 'WHITE'];
$sections[] = [
'text' => "DESCRIPTION:",
'color' => 'BRIGHT_YELLOW',
];
$sections[] = [
'text' => $this->wrapText($this->description, 2),
'color' => 'WHITE',
];
}
// Usage
$sections[] = ['text' => "", 'color' => 'WHITE'];
$sections[] = [
'text' => "USAGE:",
'color' => 'BRIGHT_YELLOW',
];
$sections[] = [
'text' => " {$this->usage}",
'color' => 'BRIGHT_WHITE',
];
// Parameters
if (! empty($this->parameters)) {
$sections[] = ['text' => "", 'color' => 'WHITE'];
$sections[] = [
'text' => "PARAMETERS:",
'color' => 'BRIGHT_YELLOW',
];
foreach ($this->parameters as $param) {
$paramText = " {$param['name']}";
// Add type info
$type = $param['type']['name'];
if (isset($param['type']['nullable']) && $param['type']['nullable']) {
$type .= '?';
}
$paramText .= " ({$type})";
// Add required/optional indicator
if ($param['required']) {
$sections[] = [
'text' => $paramText . " *required*",
'color' => 'BRIGHT_WHITE',
];
} else {
$sections[] = [
'text' => $paramText . " [optional]",
'color' => 'WHITE',
];
}
// Add description
if (! empty($param['description'])) {
$sections[] = [
'text' => " " . $param['description'],
'color' => 'GRAY',
];
}
// Add default value
if ($param['default'] !== null) {
$defaultValue = is_bool($param['default'])
? ($param['default'] ? 'true' : 'false')
: $param['default'];
$sections[] = [
'text' => " Default: {$defaultValue}",
'color' => 'YELLOW',
];
}
}
}
// Examples
if (! empty($this->examples)) {
$sections[] = ['text' => "", 'color' => 'WHITE'];
$sections[] = [
'text' => "EXAMPLES:",
'color' => 'BRIGHT_YELLOW',
];
foreach ($this->examples as $i => $example) {
$sections[] = [
'text' => " " . ($i + 1) . ". {$example['description']}",
'color' => 'WHITE',
];
$sections[] = [
'text' => " {$example['command']}",
'color' => 'BRIGHT_GREEN',
];
}
}
return $sections;
}
/**
* Get quick summary for lists
*/
public function getQuickSummary(): string
{
$summary = $this->name;
if (! empty($this->description)) {
$firstLine = strtok($this->description, "\n");
$summary .= " - " . $firstLine;
}
if (! empty($this->parameters)) {
$paramCount = count($this->parameters);
$requiredCount = count(array_filter($this->parameters, fn ($p) => $p['required']));
$summary .= " ({$requiredCount}/{$paramCount} params)";
}
return $summary;
}
/**
* Get parameter summary
*/
public function getParameterSummary(): string
{
if (empty($this->parameters)) {
return "No parameters";
}
$required = array_filter($this->parameters, fn ($p) => $p['required']);
$optional = array_filter($this->parameters, fn ($p) => ! $p['required']);
$summary = [];
if (! empty($required)) {
$summary[] = count($required) . " required";
}
if (! empty($optional)) {
$summary[] = count($optional) . " optional";
}
return implode(', ', $summary) . " parameters";
}
/**
* Check if command has parameters
*/
public function hasParameters(): bool
{
return ! empty($this->parameters);
}
/**
* Check if command has required parameters
*/
public function hasRequiredParameters(): bool
{
return ! empty(array_filter($this->parameters, fn ($p) => $p['required']));
}
/**
* Get all aliases including the main name
*/
public function getAllNames(): array
{
return array_merge([$this->name], $this->aliases);
}
/**
* Wrap text to specified width with indent
*/
private function wrapText(string $text, int $indent = 0): string
{
$width = 76 - $indent;
$indentStr = str_repeat(' ', $indent);
$lines = explode("\n", $text);
$wrappedLines = [];
foreach ($lines as $line) {
if (empty($line)) {
$wrappedLines[] = $indentStr;
continue;
}
$wrapped = wordwrap($line, $width, "\n", true);
$subLines = explode("\n", $wrapped);
foreach ($subLines as $subLine) {
$wrappedLines[] = $indentStr . $subLine;
}
}
return implode("\n", $wrappedLines);
}
/**
* Create empty help object
*/
public static function empty(string $className): self
{
return new self(
name: 'unknown',
description: 'No help available',
usage: 'unknown',
parameters: [],
examples: [],
className: $className,
methodName: '__invoke'
);
}
/**
* Convert to array format
*/
public function toArray(): array
{
return [
'name' => $this->name,
'description' => $this->description,
'usage' => $this->usage,
'parameters' => $this->parameters,
'examples' => $this->examples,
'className' => $this->className,
'methodName' => $this->methodName,
'aliases' => $this->aliases,
'summary' => [
'parameter_count' => count($this->parameters),
'required_parameters' => count(array_filter($this->parameters, fn ($p) => $p['required'])),
'has_examples' => ! empty($this->examples),
'quick_summary' => $this->getQuickSummary(),
],
];
}
}

View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
/**
* Generates comprehensive help text from command method signatures
*/
final readonly class CommandHelpGenerator
{
public function __construct(
private readonly ParameterInspector $parameterInspector
) {
}
/**
* Generate comprehensive help for a command
*/
public function generateHelp(object $command, string $methodName = '__invoke'): CommandHelp
{
try {
$reflection = new ReflectionMethod($command, $methodName);
$classReflection = new ReflectionClass($command);
$commandInfo = $this->extractCommandInfo($reflection);
$parameters = $this->parameterInspector->inspectCommand($command, $methodName);
$usage = $this->generateUsageExample($commandInfo['name'], $parameters);
$description = $this->generateDescription($commandInfo, $classReflection);
$examples = $this->generateExamples($commandInfo['name'], $parameters);
return new CommandHelp(
name: $commandInfo['name'],
description: $description,
usage: $usage,
parameters: $parameters,
examples: $examples,
className: $classReflection->getName(),
methodName: $methodName,
aliases: $commandInfo['aliases'] ?? []
);
} catch (ReflectionException $e) {
return CommandHelp::empty($command::class);
}
}
/**
* Extract command information from ConsoleCommand attribute
*/
private function extractCommandInfo(ReflectionMethod $method): array
{
$attributes = $method->getAttributes();
foreach ($attributes as $attribute) {
if ($attribute->getName() === ConsoleCommand::class) {
$args = $attribute->getArguments();
return [
'name' => $args[0] ?? $args['name'] ?? 'unknown',
'description' => $args[1] ?? $args['description'] ?? '',
'aliases' => $args['aliases'] ?? [],
];
}
}
return [
'name' => 'unknown',
'description' => '',
'aliases' => [],
];
}
/**
* Generate enhanced description with parameter analysis
*/
private function generateDescription(array $commandInfo, ReflectionClass $class): string
{
$description = $commandInfo['description'];
if (empty($description)) {
// Generate description from class name and method analysis
$className = $class->getShortName();
$cleanName = preg_replace('/Command$/', '', $className);
$description = "Execute {$cleanName} operation";
}
// Add class information
$classDoc = $class->getDocComment();
if ($classDoc && preg_match('/\*\s*(.+?)(?:\n|\*\/)/s', $classDoc, $matches)) {
$classDescription = trim($matches[1]);
if ($classDescription && $classDescription !== $description) {
$description .= "\n\n" . $classDescription;
}
}
return $description;
}
/**
* Generate usage example from parameters
*/
private function generateUsageExample(string $commandName, array $parameters): string
{
$usage = $commandName;
foreach ($parameters as $param) {
$paramName = $param['name'];
$isRequired = $param['required'];
$paramType = $param['type']['name'];
// Generate parameter format based on type
$paramFormat = match ($paramType) {
'bool' => $isRequired ? "--{$paramName}" : "[--{$paramName}]",
'int', 'float' => $isRequired ? "<{$paramName}>" : "[<{$paramName}>]",
'string' => $isRequired ? "<{$paramName}>" : "[<{$paramName}>]",
'array' => $isRequired ? "<{$paramName}...>" : "[<{$paramName}...>]",
default => $isRequired ? "<{$paramName}>" : "[<{$paramName}>]"
};
$usage .= " {$paramFormat}";
}
return $usage;
}
/**
* Generate usage examples based on parameter types
*/
private function generateExamples(string $commandName, array $parameters): array
{
$examples = [];
if (empty($parameters)) {
return [
[
'command' => $commandName,
'description' => 'Execute command with default settings',
],
];
}
// Basic example with required parameters
$requiredParams = array_filter($parameters, fn ($p) => $p['required']);
if (! empty($requiredParams)) {
$basicExample = $commandName;
$description = 'Execute with required parameters:';
foreach ($requiredParams as $param) {
$exampleValue = $this->generateExampleValue($param);
$basicExample .= " {$exampleValue}";
$description .= " {$param['name']}";
}
$examples[] = [
'command' => $basicExample,
'description' => $description,
];
}
// Advanced example with all parameters
if (count($parameters) > count($requiredParams)) {
$advancedExample = $commandName;
$description = 'Execute with all parameters:';
foreach ($parameters as $param) {
$exampleValue = $this->generateExampleValue($param);
$advancedExample .= " {$exampleValue}";
}
$examples[] = [
'command' => $advancedExample,
'description' => $description,
];
}
// Type-specific examples
$this->addTypeSpecificExamples($examples, $commandName, $parameters);
return $examples;
}
/**
* Generate example value for a parameter
*/
private function generateExampleValue(array $param): string
{
$name = $param['name'];
$type = $param['type']['name'];
$default = $param['default'];
return match ($type) {
'bool' => "--{$name}",
'int' => $this->generateIntExample($name, $default),
'float' => $this->generateFloatExample($name, $default),
'string' => $this->generateStringExample($name, $default),
'array' => $this->generateArrayExample($name),
default => "\"{$name}_value\""
};
}
/**
* Generate integer example based on parameter name
*/
private function generateIntExample(string $name, mixed $default): string
{
if ($default !== null) {
return (string) $default;
}
return match (true) {
str_contains($name, 'port') => '8080',
str_contains($name, 'count') => '10',
str_contains($name, 'limit') => '100',
str_contains($name, 'timeout') => '30',
str_contains($name, 'id') => '123',
default => '42'
};
}
/**
* Generate float example based on parameter name
*/
private function generateFloatExample(string $name, mixed $default): string
{
if ($default !== null) {
return (string) $default;
}
return match (true) {
str_contains($name, 'rate') => '0.5',
str_contains($name, 'percent') => '85.5',
str_contains($name, 'ratio') => '1.25',
default => '3.14'
};
}
/**
* Generate string example based on parameter name
*/
private function generateStringExample(string $name, mixed $default): string
{
if ($default !== null && is_string($default)) {
return "\"{$default}\"";
}
return match (true) {
str_contains($name, 'email') => '"user@example.com"',
str_contains($name, 'name') => '"example"',
str_contains($name, 'path') => '"/path/to/file"',
str_contains($name, 'url') => '"https://example.com"',
str_contains($name, 'password') => '"secretpassword"',
str_contains($name, 'key') => '"api_key_123"',
str_contains($name, 'token') => '"token_abc123"',
default => "\"{$name}_value\""
};
}
/**
* Generate array example based on parameter name
*/
private function generateArrayExample(string $name): string
{
return match (true) {
str_contains($name, 'file') => '"file1.txt,file2.txt"',
str_contains($name, 'tag') => '"tag1,tag2,tag3"',
str_contains($name, 'option') => '"option1,option2"',
default => '"item1,item2,item3"'
};
}
/**
* Add type-specific usage examples
*/
private function addTypeSpecificExamples(array &$examples, string $commandName, array $parameters): void
{
// Boolean parameter examples
$boolParams = array_filter($parameters, fn ($p) => $p['type']['name'] === 'bool');
if (! empty($boolParams)) {
foreach ($boolParams as $param) {
$examples[] = [
'command' => "{$commandName} --{$param['name']}",
'description' => "Enable {$param['name']} option",
];
}
}
// File/Path parameter examples
$pathParams = array_filter(
$parameters,
fn ($p) =>
str_contains($p['name'], 'path') || str_contains($p['name'], 'file')
);
if (! empty($pathParams)) {
$pathParam = reset($pathParams);
$examples[] = [
'command' => "{$commandName} \"/absolute/path/to/file\"",
'description' => "Use absolute path for {$pathParam['name']}",
];
}
}
/**
* Generate help for multiple commands
*/
public function generateMultipleHelp(array $commands): array
{
$helpTexts = [];
foreach ($commands as $command) {
$help = $this->generateHelp($command);
$helpTexts[$help->name] = $help;
}
return $helpTexts;
}
/**
* Quick help summary for command overview
*/
public function generateQuickHelp(object $command, string $methodName = '__invoke'): string
{
try {
$reflection = new ReflectionMethod($command, $methodName);
$commandInfo = $this->extractCommandInfo($reflection);
$parameters = $this->parameterInspector->inspectCommand($command, $methodName);
$summary = $commandInfo['name'];
if (! empty($commandInfo['description'])) {
$summary .= " - {$commandInfo['description']}";
}
if (! empty($parameters)) {
$paramCount = count($parameters);
$requiredCount = count(array_filter($parameters, fn ($p) => $p['required']));
$summary .= " ({$requiredCount}/{$paramCount} parameters)";
}
return $summary;
} catch (ReflectionException) {
return get_class($command) . " - No help available";
}
}
}

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Filesystem\FilePath;
/**
* Command history and favorites management for console TUI
*/
final class CommandHistory
{
private array $history = [];
private array $favorites = [];
private int $maxHistorySize;
private FilePath $historyFile;
private FilePath $favoritesFile;
public function __construct(
int $maxHistorySize = 100,
?FilePath $storageDirectory = null
) {
$this->maxHistorySize = $maxHistorySize;
$storageDir = $storageDirectory ?? FilePath::create(sys_get_temp_dir() . '/console-history');
$this->historyFile = $storageDir->join('command_history.json');
$this->favoritesFile = $storageDir->join('command_favorites.json');
$this->loadHistory();
$this->loadFavorites();
}
/**
* Add a command to history
*/
public function addToHistory(string $commandName): void
{
// Remove if already exists to move to top
$this->history = array_filter($this->history, fn ($entry) => $entry['command'] !== $commandName);
// Add to beginning
array_unshift($this->history, [
'command' => $commandName,
'timestamp' => time(),
'count' => $this->getCommandCount($commandName) + 1,
]);
// Limit history size
if (count($this->history) > $this->maxHistorySize) {
$this->history = array_slice($this->history, 0, $this->maxHistorySize);
}
$this->saveHistory();
}
/**
* Add command to favorites
*/
public function addToFavorites(string $commandName): void
{
if (! $this->isFavorite($commandName)) {
$this->favorites[] = [
'command' => $commandName,
'added_at' => time(),
];
$this->saveFavorites();
}
}
/**
* Remove command from favorites
*/
public function removeFromFavorites(string $commandName): void
{
$this->favorites = array_filter(
$this->favorites,
fn ($entry) => $entry['command'] !== $commandName
);
$this->favorites = array_values($this->favorites); // Re-index
$this->saveFavorites();
}
/**
* Toggle favorite status
*/
public function toggleFavorite(string $commandName): bool
{
if ($this->isFavorite($commandName)) {
$this->removeFromFavorites($commandName);
return false;
} else {
$this->addToFavorites($commandName);
return true;
}
}
/**
* Check if command is in favorites
*/
public function isFavorite(string $commandName): bool
{
return array_reduce(
$this->favorites,
fn ($carry, $entry) => $carry || $entry['command'] === $commandName,
false
);
}
/**
* Get recent command history
*/
public function getRecentHistory(int $limit = 10): array
{
return array_slice($this->history, 0, $limit);
}
/**
* Get frequently used commands
*/
public function getFrequentCommands(int $limit = 10): array
{
$commands = $this->history;
// Sort by usage count (descending)
usort($commands, fn ($a, $b) => $b['count'] <=> $a['count']);
return array_slice($commands, 0, $limit);
}
/**
* Get all favorites
*/
public function getFavorites(): array
{
return $this->favorites;
}
/**
* Get command usage statistics
*/
public function getCommandStats(string $commandName): array
{
$entry = array_find($this->history, fn ($entry) => $entry['command'] === $commandName);
if (! $entry) {
return [
'command' => $commandName,
'count' => 0,
'last_used' => null,
'is_favorite' => $this->isFavorite($commandName),
];
}
return [
'command' => $commandName,
'count' => $entry['count'],
'last_used' => $entry['timestamp'],
'is_favorite' => $this->isFavorite($commandName),
];
}
/**
* Clear all history
*/
public function clearHistory(): void
{
$this->history = [];
$this->saveHistory();
}
/**
* Clear all favorites
*/
public function clearFavorites(): void
{
$this->favorites = [];
$this->saveFavorites();
}
/**
* Get suggestions based on partial input
*/
public function getSuggestions(string $partial, int $limit = 5): array
{
$suggestions = [];
// Add matching favorites first
foreach ($this->favorites as $favorite) {
if (str_starts_with($favorite['command'], $partial)) {
$suggestions[] = [
'command' => $favorite['command'],
'type' => 'favorite',
'score' => 1000, // High priority for favorites
];
}
}
// Add matching recent commands
foreach ($this->history as $entry) {
$command = $entry['command'];
if (str_starts_with($command, $partial) && ! $this->isFavorite($command)) {
$suggestions[] = [
'command' => $command,
'type' => 'recent',
'score' => 500 + $entry['count'], // Score based on usage count
];
}
}
// Sort by score (descending) and limit
usort($suggestions, fn ($a, $b) => $b['score'] <=> $a['score']);
return array_slice($suggestions, 0, $limit);
}
/**
* Get command count from history
*/
private function getCommandCount(string $commandName): int
{
$entry = array_find($this->history, fn ($entry) => $entry['command'] === $commandName);
return $entry ? $entry['count'] : 0;
}
/**
* Load history from file
*/
private function loadHistory(): void
{
if ($this->historyFile->exists()) {
$content = file_get_contents($this->historyFile->toString());
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$this->history = $decoded;
}
}
}
/**
* Save history to file
*/
private function saveHistory(): void
{
$this->ensureDirectoryExists($this->historyFile->getDirectory());
file_put_contents(
$this->historyFile->toString(),
json_encode($this->history, JSON_PRETTY_PRINT)
);
}
/**
* Load favorites from file
*/
private function loadFavorites(): void
{
if ($this->favoritesFile->exists()) {
$content = file_get_contents($this->favoritesFile->toString());
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$this->favorites = $decoded;
}
}
}
/**
* Save favorites to file
*/
private function saveFavorites(): void
{
$this->ensureDirectoryExists($this->favoritesFile->getDirectory());
file_put_contents(
$this->favoritesFile->toString(),
json_encode($this->favorites, JSON_PRETTY_PRINT)
);
}
/**
* Ensure storage directory exists
*/
private function ensureDirectoryExists(FilePath $directory): void
{
if (! $directory->exists()) {
mkdir($directory->toString(), 0755, true);
}
}
}

View File

@@ -118,6 +118,14 @@ final readonly class CommandList implements IteratorAggregate, Countable
return $this->commands;
}
/**
* @return array<int, ConsoleCommand>
*/
public function getAllCommands(): array
{
return array_values($this->commands);
}
public function isEmpty(): bool
{
return empty($this->commands);

View File

@@ -0,0 +1,393 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Core\ValueObjects\Url;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
/**
* Resolves method parameters from parsed command line arguments
*/
final readonly class CommandParameterResolver
{
public function __construct(
private MethodSignatureAnalyzer $signatureAnalyzer
) {
}
/**
* Resolve method parameters from raw command line arguments
*
* @param string[] $rawArguments
* @return array<int, mixed>
*/
public function resolveParameters(ReflectionMethod $method, array $rawArguments): array
{
// Generate argument definitions from method signature
$definitions = $this->signatureAnalyzer->generateArgumentDefinitions($method);
// Create parser with auto-generated definitions
$parser = new ArgumentParser($definitions);
// Parse command line arguments
$parsedArgs = $parser->parse($rawArguments);
// Resolve each method parameter
$resolvedParams = [];
foreach ($method->getParameters() as $param) {
$value = $this->resolveParameterValue($param, $parsedArgs);
$resolvedParams[] = $value;
}
return $resolvedParams;
}
/**
* Resolve individual parameter value
*/
private function resolveParameterValue(ReflectionParameter $param, ParsedArguments $parsedArgs): mixed
{
$paramName = $param->getName();
$paramType = $param->getType();
// Get raw value from parsed arguments
$rawValue = $parsedArgs->get($paramName);
// Handle null/missing values
if ($rawValue === null || $rawValue === '') {
if ($param->isDefaultValueAvailable()) {
return $param->getDefaultValue();
}
if (! $param->allowsNull()) {
throw new \InvalidArgumentException("Required parameter '{$paramName}' is missing");
}
return null;
}
// Convert to appropriate type
return $this->convertToParameterType($rawValue, $paramType, $paramName);
}
/**
* Convert value to the appropriate parameter type
*/
private function convertToParameterType(mixed $value, ?\ReflectionType $type, string $paramName): mixed
{
if (! $type || ! ($type instanceof ReflectionNamedType)) {
return $value; // No type hint, return as-is
}
$typeName = $type->getName();
try {
return match (strtolower($typeName)) {
'string' => $this->convertToString($value),
'int', 'integer' => $this->convertToInt($value),
'float', 'double' => $this->convertToFloat($value),
'bool', 'boolean' => $this->convertToBool($value),
'array' => $this->convertToArray($value),
default => $this->convertToCustomType($value, $typeName, $paramName)
};
} catch (\Throwable $e) {
$valueString = $this->convertValueToString($value);
throw new \InvalidArgumentException(
"Cannot convert value '{$valueString}' to {$typeName} for parameter '{$paramName}': {$e->getMessage()}"
);
}
}
/**
* Convert value to string
*/
private function convertToString(mixed $value): string
{
if (is_string($value)) {
return $value;
}
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
if ($value instanceof \UnitEnum) {
return $value->name;
}
if (is_scalar($value)) {
return (string) $value;
}
if (method_exists($value, '__toString')) {
return (string) $value;
}
throw new \InvalidArgumentException('Value cannot be converted to string');
}
/**
* Convert value to integer
*/
private function convertToInt(mixed $value): int
{
if (is_int($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value;
}
if (is_string($value) && ctype_digit(ltrim($value, '-'))) {
return (int) $value;
}
throw new \InvalidArgumentException("Value '{$value}' is not a valid integer");
}
/**
* Convert value to float
*/
private function convertToFloat(mixed $value): float
{
if (is_float($value)) {
return $value;
}
if (is_numeric($value)) {
return (float) $value;
}
throw new \InvalidArgumentException("Value '{$value}' is not a valid number");
}
/**
* Convert value to boolean
*/
private function convertToBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_string($value)) {
$lowered = strtolower(trim($value));
return in_array($lowered, ['true', '1', 'yes', 'on', 'y'], true);
}
if (is_numeric($value)) {
return (bool) $value;
}
return (bool) $value;
}
/**
* Convert value to array
*/
private function convertToArray(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value)) {
// Split comma-separated values
$items = array_map('trim', explode(',', $value));
return array_filter($items, fn ($item) => $item !== '');
}
// Single value becomes array
return [$value];
}
/**
* Convert value to custom type (Value Objects, Enums, etc.)
*/
private function convertToCustomType(mixed $value, string $typeName, string $paramName): mixed
{
// Handle enums first
if (enum_exists($typeName)) {
return $this->convertToEnum($value, $typeName, $paramName);
}
// Handle framework value objects
return match ($typeName) {
Email::class => new Email($this->convertToString($value)),
Url::class => new Url($this->convertToString($value)),
default => $this->attemptGenericConstruction($value, $typeName, $paramName)
};
}
/**
* Convert value to enum
*/
private function convertToEnum(mixed $value, string $enumClass, string $paramName): mixed
{
$stringValue = $this->convertToString($value);
// Try backed enum first (string or int)
if (method_exists($enumClass, 'from')) {
try {
return $enumClass::from($stringValue);
} catch (\ValueError $e) {
// Try with integer conversion for int backed enums
if (is_numeric($stringValue)) {
try {
return $enumClass::from((int) $stringValue);
} catch (\ValueError) {
// Fall through to error below
}
}
}
}
// Try pure enum by name
if (method_exists($enumClass, 'cases')) {
foreach ($enumClass::cases() as $case) {
if (strcasecmp($case->name, $stringValue) === 0) {
return $case;
}
}
}
// Get valid values for error message
$validValues = [];
foreach ($enumClass::cases() as $case) {
if ($case instanceof \BackedEnum) {
$validValues[] = (string) $case->value;
} else {
$validValues[] = $case->name;
}
}
throw new \InvalidArgumentException(
"Invalid value '{$stringValue}' for enum parameter '{$paramName}'. " .
"Valid values are: " . implode(', ', $validValues)
);
}
/**
* Attempt to construct object generically
*/
private function attemptGenericConstruction(mixed $value, string $typeName, string $paramName): mixed
{
if (! class_exists($typeName)) {
throw new \InvalidArgumentException("Unknown type '{$typeName}' for parameter '{$paramName}'");
}
try {
// Try constructor with single string parameter
return new $typeName($this->convertToString($value));
} catch (\Throwable $e) {
throw new \InvalidArgumentException(
"Cannot construct {$typeName} from value '{$value}' for parameter '{$paramName}': {$e->getMessage()}"
);
}
}
/**
* Generate help text for method parameters
*/
public function generateMethodHelp(ReflectionMethod $method, string $commandName): string
{
$definitions = $this->signatureAnalyzer->generateArgumentDefinitions($method);
$helpGenerator = new HelpGenerator(new ConsoleOutput());
$description = $this->extractMethodDescription($method);
return $helpGenerator->generate($commandName, $definitions, $description);
}
/**
* Extract method description from docblock
*/
private function extractMethodDescription(ReflectionMethod $method): string
{
$docComment = $method->getDocComment();
if (! $docComment) {
return '';
}
// Extract first line of description (before any @tags)
$lines = explode("\n", $docComment);
$description = '';
foreach ($lines as $line) {
$line = trim($line, " \t\n\r\0\x0B/*");
if (empty($line) || str_starts_with($line, '@')) {
break;
}
if (! empty($description)) {
$description .= ' ';
}
$description .= $line;
}
return $description;
}
/**
* Create ArgumentParser for a method
*/
public function createParserForMethod(ReflectionMethod $method): ArgumentParser
{
$definitions = $this->signatureAnalyzer->generateArgumentDefinitions($method);
return new ArgumentParser($definitions);
}
/**
* Validate method signature is compatible
*/
public function validateMethodSignature(ReflectionMethod $method): void
{
if (! $this->signatureAnalyzer->isValidCommandMethod($method)) {
throw new \InvalidArgumentException(
"Method {$method->getDeclaringClass()->getName()}::{$method->getName()} " .
"is not compatible with command execution"
);
}
}
/**
* Convert value to string for error messages
*/
private function convertValueToString(mixed $value): string
{
if (is_string($value)) {
return $value;
}
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
if ($value instanceof \UnitEnum) {
return $value->name;
}
if (is_scalar($value)) {
return (string) $value;
}
if (method_exists($value, '__toString')) {
return (string) $value;
}
return get_class($value);
}
}

View File

@@ -4,11 +4,16 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Console\Performance\ConsolePerformanceCollector;
use App\Framework\Console\Progress\ProgressMiddleware;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use ReflectionException;
use ReflectionMethod;
use Throwable;
@@ -23,10 +28,19 @@ final readonly class CommandRegistry
/** @var array<string, DiscoveredAttribute> */
private array $discoveredAttributes;
private CommandParameterResolver $parameterResolver;
private ?ConsolePerformanceCollector $performanceCollector;
private ProgressMiddleware $progressMiddleware;
public function __construct(
private Container $container,
DiscoveryRegistry $discoveryRegistry
) {
$this->parameterResolver = new CommandParameterResolver(new MethodSignatureAnalyzer());
$this->performanceCollector = $this->createPerformanceCollector();
$this->progressMiddleware = new ProgressMiddleware();
$this->discoverCommands($discoveryRegistry);
}
@@ -51,6 +65,20 @@ final readonly class CommandRegistry
* @param array<int, string> $arguments
*/
public function executeCommand(string $commandName, array $arguments, ConsoleOutputInterface $output): ExitCode
{
if ($this->performanceCollector) {
return $this->performanceCollector->measureCommand(
$commandName,
fn () => $this->doExecuteCommand($commandName, $arguments, $output),
$arguments,
$output
);
}
return $this->doExecuteCommand($commandName, $arguments, $output);
}
private function doExecuteCommand(string $commandName, array $arguments, ConsoleOutputInterface $output): ExitCode
{
$command = $this->commandList->get($commandName);
$discoveredAttribute = $this->getDiscoveredAttribute($commandName);
@@ -60,8 +88,14 @@ final readonly class CommandRegistry
$className = $discoveredAttribute->className->getFullyQualified();
$methodName = $discoveredAttribute->methodName?->toString() ?? '__invoke';
// Get instance from container
// Performance tracking: Container resolution
$containerStart = microtime(true);
$instance = $this->container->get($className);
$containerDuration = (microtime(true) - $containerStart) * 1000;
if ($this->performanceCollector) {
$this->performanceCollector->recordContainerResolutionTime($commandName, $className, $containerDuration);
}
// Validate command structure
if (! is_object($instance) || ! method_exists($instance, $methodName)) {
@@ -75,21 +109,21 @@ final readonly class CommandRegistry
]);
}
// Create ConsoleInput
$input = new ConsoleInput($arguments, $output);
// Get reflection method for parameter resolution
$reflectionMethod = new ReflectionMethod($className, $methodName);
// Execute command
$startTime = microtime(true);
$result = $instance->$methodName($input, $output);
$executionTime = microtime(true) - $startTime;
// Performance tracking: Validation time
$validationStart = microtime(true);
$this->parameterResolver->validateMethodSignature($reflectionMethod);
$validationDuration = (microtime(true) - $validationStart) * 1000;
// Log long-running commands
if ($executionTime > 30.0) {
$output->writeLine(
sprintf("Warning: Command '%s' took %.2f seconds to execute", $commandName, $executionTime)
);
if ($this->performanceCollector) {
$this->performanceCollector->recordValidationTime($commandName, $validationDuration);
}
// Execute command with automatic parameter resolution
$result = $this->executeCommandWithReflection($instance, $reflectionMethod, $arguments, $output);
return $this->normalizeCommandResult($result);
} catch (Throwable $e) {
@@ -104,6 +138,21 @@ final readonly class CommandRegistry
}
}
private function createPerformanceCollector(): ?ConsolePerformanceCollector
{
try {
if ($this->container->has(PerformanceCollectorInterface::class)) {
$performanceCollector = $this->container->get(PerformanceCollectorInterface::class);
return new ConsolePerformanceCollector($performanceCollector);
}
} catch (Throwable $e) {
// Performance monitoring is optional - don't fail if unavailable
}
return null;
}
private function discoverCommands(DiscoveryRegistry $discoveryRegistry): void
{
$commands = [];
@@ -118,7 +167,15 @@ final readonly class CommandRegistry
} catch (Throwable $e) {
// Log warning but continue with other commands
error_log("Warning: Failed to register command from {$discoveredAttribute->className->getFullyQualified()}: {$e->getMessage()}");
if ($this->container->has(Logger::class)) {
$logger = $this->container->get(Logger::class);
$logger->warning('Failed to register command', LogContext::withData([
'class_name' => $discoveredAttribute->className->getFullyQualified(),
'error' => $e->getMessage(),
'error_class' => get_class($e),
'component' => 'CommandRegistry',
]));
}
}
}
@@ -189,4 +246,116 @@ final readonly class CommandRegistry
return ExitCode::SUCCESS;
}
/**
* Execute command with automatic parameter resolution
*/
private function executeCommandWithReflection(
object $instance,
ReflectionMethod $method,
array $arguments,
ConsoleOutputInterface $output
): ExitCode {
try {
// Create the actual command execution callback
$commandExecutor = function (ConsoleInput $input, ConsoleOutputInterface $progressAwareOutput) use ($instance, $method, $arguments) {
// Check if method uses the new reflection-based parameter style
if ($this->usesReflectionParameters($method)) {
// Performance tracking: Parameter resolution
$parameterStart = microtime(true);
$resolvedParams = $this->parameterResolver->resolveParameters($method, $arguments);
$parameterDuration = (microtime(true) - $parameterStart) * 1000;
if ($this->performanceCollector) {
$this->performanceCollector->recordParameterResolutionTime($method->getDeclaringClass()->getName(), $parameterDuration);
}
$result = $method->invokeArgs($instance, $resolvedParams);
} else {
// For legacy style, use the progress-aware output
$result = $method->invoke($instance, $input, $progressAwareOutput);
}
return $this->normalizeCommandResult($result);
};
// Wrap execution with ProgressMiddleware
$input = new ConsoleInput($arguments, $output);
return $this->progressMiddleware->handle($input, $output, $commandExecutor, $method, $instance);
} catch (\ArgumentCountError $e) {
throw new \InvalidArgumentException(
"Invalid number of arguments for command. " . $e->getMessage()
);
} catch (\TypeError $e) {
throw new \InvalidArgumentException(
"Type error in command execution: " . $e->getMessage()
);
}
}
/**
* Determine if method uses reflection-based parameters
*/
private function usesReflectionParameters(ReflectionMethod $method): bool
{
$parameters = $method->getParameters();
// If no parameters, use simple invocation (no ConsoleInput/Output needed)
if (empty($parameters)) {
return true;
}
// If first parameter is ConsoleInput, it's legacy style
$firstParam = $parameters[0];
$firstParamType = $firstParam->getType();
if ($firstParamType instanceof \ReflectionNamedType) {
$typeName = $firstParamType->getName();
if ($typeName === ConsoleInput::class || $typeName === ConsoleInputInterface::class) {
return false; // Legacy style
}
}
// If method has ConsoleInput or ConsoleOutput in parameters, it's legacy
foreach ($parameters as $param) {
$type = $param->getType();
if ($type instanceof \ReflectionNamedType) {
$typeName = $type->getName();
if (in_array($typeName, [ConsoleInput::class, ConsoleInputInterface::class, ConsoleOutputInterface::class], true)) {
return false;
}
}
}
// All other cases are considered reflection-based
return true;
}
/**
* Generate help for a specific command
*/
public function generateCommandHelp(string $commandName): string
{
$discoveredAttribute = $this->getDiscoveredAttribute($commandName);
$className = $discoveredAttribute->className->getFullyQualified();
$methodName = $discoveredAttribute->methodName?->toString() ?? '__invoke';
try {
$reflectionMethod = new ReflectionMethod($className, $methodName);
if ($this->usesReflectionParameters($reflectionMethod)) {
return $this->parameterResolver->generateMethodHelp($reflectionMethod, $commandName);
} else {
// Generate basic help for legacy commands
$command = $this->commandList->get($commandName);
return "Command: {$commandName}\nDescription: {$command->description}\n\nThis command uses legacy parameter style.";
}
} catch (ReflectionException $e) {
return "Command: {$commandName}\nError generating help: {$e->getMessage()}";
}
}
}

View File

@@ -0,0 +1,592 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\MethodName;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
/**
* Validates console command signatures and implementations
*/
final readonly class CommandValidator
{
/**
* Validate a command object and its method signature
*/
public function validateCommand(object $command, ?MethodName $methodName = null): ValidationResult
{
$errors = [];
$warnings = [];
try {
// If no method name provided, find the method with ConsoleCommand attribute
if ($methodName === null) {
$methodName = $this->findConsoleCommandMethod($command);
if ($methodName === null) {
$errors[] = [
'type' => 'no_command_method',
'message' => 'No method with #[ConsoleCommand] attribute found',
'severity' => 'critical',
'suggestion' => 'Add #[ConsoleCommand] attribute to a public method',
];
return new ValidationResult(
valid: false,
errors: $errors,
warnings: $warnings,
commandName: $this->extractCommandName($command)
);
}
}
$reflection = new ReflectionMethod($command, $methodName->toString());
// Validate method exists and is callable
$classValidation = $this->validateClassStructure($command);
$errors = array_merge($errors, $classValidation['errors']);
$warnings = array_merge($warnings, $classValidation['warnings']);
// Validate method signature
$signatureValidation = $this->validateMethodSignature($reflection);
$errors = array_merge($errors, $signatureValidation['errors']);
$warnings = array_merge($warnings, $signatureValidation['warnings']);
// Validate parameters
$parameterValidation = $this->validateParameters($reflection);
$errors = array_merge($errors, $parameterValidation['errors']);
$warnings = array_merge($warnings, $parameterValidation['warnings']);
// Validate return type
$returnValidation = $this->validateReturnType($reflection);
$errors = array_merge($errors, $returnValidation['errors']);
$warnings = array_merge($warnings, $returnValidation['warnings']);
// Validate ConsoleCommand attribute
$attributeValidation = $this->validateCommandAttribute($command, $methodName);
$errors = array_merge($errors, $attributeValidation['errors']);
$warnings = array_merge($warnings, $attributeValidation['warnings']);
} catch (ReflectionException $e) {
$methodNameStr = $methodName ? $methodName->toString() : 'unknown';
$errors[] = [
'type' => 'reflection_error',
'message' => "Could not reflect method '{$methodNameStr}': " . $e->getMessage(),
'severity' => 'critical',
'suggestion' => "Ensure the method '{$methodNameStr}' exists and is accessible",
];
}
return new ValidationResult(
valid: empty($errors),
errors: $errors,
warnings: $warnings,
commandName: $this->extractCommandName($command, $methodName)
);
}
/**
* Find the method with ConsoleCommand attribute
*/
private function findConsoleCommandMethod(object $command): ?MethodName
{
try {
$reflection = new ReflectionClass($command);
$methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC);
foreach ($methods as $method) {
$attributes = $method->getAttributes(ConsoleCommand::class);
if (! empty($attributes)) {
return MethodName::create($method->getName());
}
}
} catch (ReflectionException) {
// If reflection fails, return null
}
return null;
}
/**
* Validate class structure
*/
private function validateClassStructure(object $command): array
{
$errors = [];
$warnings = [];
$reflection = new ReflectionClass($command);
// Check if class is final
if (! $reflection->isFinal()) {
$warnings[] = [
'type' => 'class_structure',
'message' => 'Command class should be final for better performance and type safety',
'severity' => 'medium',
'suggestion' => 'Add "final" keyword to the class declaration',
];
}
// Check if class is readonly
if (PHP_VERSION_ID >= 80200 && ! $reflection->isReadOnly()) {
$warnings[] = [
'type' => 'class_structure',
'message' => 'Command class should be readonly for immutability',
'severity' => 'low',
'suggestion' => 'Add "readonly" keyword to the class declaration',
];
}
// Check for proper constructor dependency injection
$constructor = $reflection->getConstructor();
if ($constructor && ! $constructor->isPublic()) {
$errors[] = [
'type' => 'constructor',
'message' => 'Constructor must be public for dependency injection',
'severity' => 'critical',
'suggestion' => 'Change constructor visibility to public',
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate method signature structure
*/
private function validateMethodSignature(ReflectionMethod $method): array
{
$errors = [];
$warnings = [];
// Check method visibility
if (! $method->isPublic()) {
$errors[] = [
'type' => 'method_visibility',
'message' => 'Command method must be public',
'severity' => 'critical',
'suggestion' => 'Change method visibility to public',
];
}
// Check if method is static
if ($method->isStatic()) {
$errors[] = [
'type' => 'method_static',
'message' => 'Command method should not be static',
'severity' => 'critical',
'suggestion' => 'Remove static keyword from method declaration',
];
}
// Validate parameter count
$parameters = $method->getParameters();
$requiredParams = array_filter($parameters, fn ($p) => ! $p->isOptional());
if (count($requiredParams) < 2) {
$errors[] = [
'type' => 'parameter_count',
'message' => 'Command method must have at least ConsoleInput and ConsoleOutput parameters',
'severity' => 'critical',
'suggestion' => 'Add ConsoleInput and ConsoleOutput parameters to method signature',
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate method parameters
*/
private function validateParameters(ReflectionMethod $method): array
{
$errors = [];
$warnings = [];
$parameters = $method->getParameters();
if (count($parameters) < 2) {
return ['errors' => $errors, 'warnings' => $warnings];
}
// Validate first parameter (should be ConsoleInput)
$firstParam = $parameters[0];
$inputValidation = $this->validateConsoleInputParameter($firstParam);
$errors = array_merge($errors, $inputValidation['errors']);
$warnings = array_merge($warnings, $inputValidation['warnings']);
// Validate second parameter (should be ConsoleOutput)
$secondParam = $parameters[1];
$outputValidation = $this->validateConsoleOutputParameter($secondParam);
$errors = array_merge($errors, $outputValidation['errors']);
$warnings = array_merge($warnings, $outputValidation['warnings']);
// Validate additional parameters (business logic parameters)
if (count($parameters) > 2) {
for ($i = 2; $i < count($parameters); $i++) {
$param = $parameters[$i];
$businessValidation = $this->validateBusinessParameter($param, $i);
$errors = array_merge($errors, $businessValidation['errors']);
$warnings = array_merge($warnings, $businessValidation['warnings']);
}
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate ConsoleInput parameter
*/
private function validateConsoleInputParameter(ReflectionParameter $parameter): array
{
$errors = [];
$warnings = [];
$type = $parameter->getType();
if (! $type instanceof ReflectionNamedType) {
$errors[] = [
'type' => 'parameter_type',
'message' => 'First parameter must be typed as ConsoleInput',
'severity' => 'critical',
'suggestion' => 'Add type hint: ConsoleInput $input',
];
return ['errors' => $errors, 'warnings' => $warnings];
}
$expectedTypes = [
'App\Framework\Console\ConsoleInput',
'App\Framework\Console\Input\ConsoleInput',
ConsoleInput::class,
];
if (! in_array($type->getName(), $expectedTypes)) {
$errors[] = [
'type' => 'parameter_type',
'message' => "First parameter must be ConsoleInput, got {$type->getName()}",
'severity' => 'critical',
'suggestion' => 'Change first parameter type to ConsoleInput',
];
}
// Check parameter name convention
$expectedNames = ['input', 'consoleInput'];
if (! in_array($parameter->getName(), $expectedNames)) {
$warnings[] = [
'type' => 'parameter_naming',
'message' => "ConsoleInput parameter should be named 'input' by convention",
'severity' => 'low',
'suggestion' => "Rename parameter to '\$input'",
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate ConsoleOutput parameter
*/
private function validateConsoleOutputParameter(ReflectionParameter $parameter): array
{
$errors = [];
$warnings = [];
$type = $parameter->getType();
if (! $type instanceof ReflectionNamedType) {
$errors[] = [
'type' => 'parameter_type',
'message' => 'Second parameter must be typed as ConsoleOutput',
'severity' => 'critical',
'suggestion' => 'Add type hint: ConsoleOutput $output',
];
return ['errors' => $errors, 'warnings' => $warnings];
}
$expectedTypes = [
'App\Framework\Console\ConsoleOutput',
'App\Framework\Console\Output\ConsoleOutput',
'App\Framework\Console\ConsoleOutputInterface',
ConsoleOutput::class,
];
if (! in_array($type->getName(), $expectedTypes)) {
$errors[] = [
'type' => 'parameter_type',
'message' => "Second parameter must be ConsoleOutput, got {$type->getName()}",
'severity' => 'critical',
'suggestion' => 'Change second parameter type to ConsoleOutput',
];
}
// Check parameter name convention
$expectedNames = ['output', 'consoleOutput'];
if (! in_array($parameter->getName(), $expectedNames)) {
$warnings[] = [
'type' => 'parameter_naming',
'message' => "ConsoleOutput parameter should be named 'output' by convention",
'severity' => 'low',
'suggestion' => "Rename parameter to '\$output'",
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate business logic parameters
*/
private function validateBusinessParameter(ReflectionParameter $parameter, int $position): array
{
$errors = [];
$warnings = [];
$type = $parameter->getType();
// Check if parameter has type hint
if (! $type) {
$warnings[] = [
'type' => 'parameter_type',
'message' => "Parameter '{$parameter->getName()}' should have a type hint",
'severity' => 'medium',
'suggestion' => 'Add appropriate type hint for better type safety',
];
}
// Check for complex types that might be hard to handle via CLI
if ($type instanceof ReflectionNamedType) {
$typeName = $type->getName();
// Check for problematic types
$problematicTypes = ['resource', 'object', 'callable'];
if (in_array($typeName, $problematicTypes)) {
$errors[] = [
'type' => 'parameter_type',
'message' => "Parameter type '{$typeName}' cannot be provided via command line",
'severity' => 'critical',
'suggestion' => 'Use primitive types (string, int, float, bool) or simple value objects',
];
}
// Check for array without documentation
if ($typeName === 'array') {
$warnings[] = [
'type' => 'parameter_type',
'message' => "Array parameter '{$parameter->getName()}' should have documentation about expected format",
'severity' => 'medium',
'suggestion' => 'Add PHPDoc comment describing the array structure',
];
}
}
// Check if parameter has default value for non-required parameters
if (! $parameter->isOptional() && $position > 2) {
$warnings[] = [
'type' => 'parameter_optional',
'message' => "Consider making parameter '{$parameter->getName()}' optional with a default value",
'severity' => 'low',
'suggestion' => 'Add default value or make parameter optional for better UX',
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate return type
*/
private function validateReturnType(ReflectionMethod $method): array
{
$errors = [];
$warnings = [];
$returnType = $method->getReturnType();
if (! $returnType) {
$errors[] = [
'type' => 'return_type',
'message' => 'Command method must have ExitCode return type',
'severity' => 'critical',
'suggestion' => 'Add ": ExitCode" return type to method signature',
];
return ['errors' => $errors, 'warnings' => $warnings];
}
if ($returnType instanceof ReflectionNamedType) {
$expectedTypes = [
'App\Framework\Console\ExitCode',
ExitCode::class,
'int', // for backward compatibility
];
if (! in_array($returnType->getName(), $expectedTypes)) {
$errors[] = [
'type' => 'return_type',
'message' => "Return type must be ExitCode, got {$returnType->getName()}",
'severity' => 'critical',
'suggestion' => 'Change return type to ExitCode',
];
}
// Warn about using int instead of ExitCode
if ($returnType->getName() === 'int') {
$warnings[] = [
'type' => 'return_type',
'message' => 'Consider using ExitCode enum instead of int for better type safety',
'severity' => 'medium',
'suggestion' => 'Change return type from int to ExitCode',
];
}
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate ConsoleCommand attribute
*/
private function validateCommandAttribute(object $command, MethodName $methodName): array
{
$errors = [];
$warnings = [];
try {
$method = new ReflectionMethod($command, $methodName->toString());
$attributes = $method->getAttributes(ConsoleCommand::class);
if (empty($attributes)) {
$errors[] = [
'type' => 'missing_attribute',
'message' => 'Command method must have #[ConsoleCommand] attribute',
'severity' => 'critical',
'suggestion' => 'Add #[ConsoleCommand(name: "command-name", description: "Command description")] attribute',
];
return ['errors' => $errors, 'warnings' => $warnings];
}
$consoleCommandAttribute = $attributes[0];
// Validate attribute arguments
$args = $consoleCommandAttribute->getArguments();
if (empty($args)) {
$errors[] = [
'type' => 'attribute_arguments',
'message' => 'ConsoleCommand attribute must have name and description',
'severity' => 'critical',
'suggestion' => 'Add name and description: #[ConsoleCommand("command-name", "Description")]',
];
} else {
// Check command name
$commandName = $args[0] ?? $args['name'] ?? null;
if (! $commandName || ! is_string($commandName)) {
$errors[] = [
'type' => 'command_name',
'message' => 'Command name must be a non-empty string',
'severity' => 'critical',
'suggestion' => 'Provide valid command name as first argument',
];
} else {
// Validate command name format
if (! preg_match('/^[a-z0-9]+(?:[:-][a-z0-9]+)*$/', $commandName)) {
$warnings[] = [
'type' => 'command_naming',
'message' => 'Command name should follow kebab-case convention (e.g., "user:create")',
'severity' => 'medium',
'suggestion' => 'Use lowercase letters, numbers, and hyphens/colons only',
];
}
}
// Check description
$description = $args[1] ?? $args['description'] ?? null;
if (! $description || ! is_string($description)) {
$warnings[] = [
'type' => 'command_description',
'message' => 'Command should have a descriptive description',
'severity' => 'medium',
'suggestion' => 'Add meaningful description as second argument',
];
} elseif (strlen($description) < 10) {
$warnings[] = [
'type' => 'command_description',
'message' => 'Command description should be more descriptive',
'severity' => 'low',
'suggestion' => 'Provide more detailed description (at least 10 characters)',
];
}
}
} catch (ReflectionException $e) {
$errors[] = [
'type' => 'reflection_error',
'message' => 'Could not validate ConsoleCommand attribute: ' . $e->getMessage(),
'severity' => 'critical',
'suggestion' => 'Check if method exists and is properly defined',
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Extract command name from object
*/
private function extractCommandName(object $command, ?MethodName $methodName = null): ?string
{
try {
// If no method name provided, find the method with ConsoleCommand attribute
if ($methodName === null) {
$methodName = $this->findConsoleCommandMethod($command);
}
if ($methodName !== null) {
$method = new ReflectionMethod($command, $methodName->toString());
$attributes = $method->getAttributes(ConsoleCommand::class);
if (! empty($attributes)) {
$args = $attributes[0]->getArguments();
return $args[0] ?? $args['name'] ?? null;
}
}
} catch (ReflectionException) {
// Ignore
}
return get_class($command);
}
/**
* Validate multiple commands at once
*/
public function validateCommands(array $commands): array
{
$results = [];
foreach ($commands as $command) {
$result = $this->validateCommand($command);
$results[] = $result;
}
return $results;
}
/**
* Quick validation check (only critical errors)
*/
public function isValidCommand(object $command, ?MethodName $methodName = null): bool
{
$result = $this->validateCommand($command, $methodName);
return $result->isValid();
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use Attribute;
/**
* Attribute to define command workflows for automated sequences
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class CommandWorkflow
{
public function __construct(
public string $name,
public string $description = '',
public array $steps = [],
public array $prerequisites = [],
public bool $stopOnError = true,
public array $rollbackSteps = [],
public int $timeoutSeconds = 300,
public array $environment = []
) {
}
/**
* Get workflow name
*/
public function getName(): string
{
return $this->name;
}
/**
* Get workflow description
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Get workflow steps
*/
public function getSteps(): array
{
return $this->steps;
}
/**
* Get prerequisites
*/
public function getPrerequisites(): array
{
return $this->prerequisites;
}
/**
* Check if workflow should stop on error
*/
public function shouldStopOnError(): bool
{
return $this->stopOnError;
}
/**
* Get rollback steps
*/
public function getRollbackSteps(): array
{
return $this->rollbackSteps;
}
/**
* Get timeout in seconds
*/
public function getTimeoutSeconds(): int
{
return $this->timeoutSeconds;
}
/**
* Get environment variables
*/
public function getEnvironment(): array
{
return $this->environment;
}
}

View File

@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\CommandList;
use App\Framework\Console\CommandRegistry;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ErrorRecovery\CommandSuggestionEngine;
use App\Framework\Console\ExitCode;
#[CommandGroup(
name: 'System',
description: 'System commands for console management and diagnostics',
icon: '⚙️',
priority: 90
)]
final class ErrorRecoveryCommand
{
private ?CommandSuggestionEngine $suggestionEngine = null;
public function __construct(
private readonly CommandRegistry $commandRegistry
) {
}
private function getCommandList(): CommandList
{
return $this->commandRegistry->getCommandList();
}
private function getSuggestionEngine(): CommandSuggestionEngine
{
if ($this->suggestionEngine === null) {
$this->suggestionEngine = new CommandSuggestionEngine($this->getCommandList());
}
return $this->suggestionEngine;
}
#[ConsoleCommand('suggest', 'Get command suggestions for a given input')]
public function suggestCommands(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$command = $input->getArgument(0);
if ($command === null) {
$output->writeLine("❌ Please provide a command to get suggestions for.", ConsoleColor::RED);
$output->newLine();
$output->writeLine("Usage: suggest <command>", ConsoleColor::YELLOW);
$output->writeLine("Example: suggest database", ConsoleColor::GRAY);
return ExitCode::INVALID_INPUT;
}
$output->writeLine("🔍 Command Suggestions for: '{$command}'", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$suggestions = $this->getSuggestionEngine()->suggestCommand($command);
if ($suggestions->hasSuggestions()) {
$output->writeLine($suggestions->formatForDisplay(), ConsoleColor::WHITE);
} else {
$output->writeLine("No similar commands found.", ConsoleColor::YELLOW);
$this->showAlternativeHelp($output);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('commands:by-category', 'List commands by category')]
public function listCommandsByCategory(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$category = $input->getArgument(0);
if ($category) {
return $this->showSpecificCategory($category, $output);
}
$output->writeLine("📋 Commands by Category", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$categories = $this->categorizeCommands();
foreach ($categories as $categoryName => $commands) {
$count = count($commands);
$output->writeLine("📁 {$categoryName} ({$count} commands)", ConsoleColor::BRIGHT_YELLOW);
foreach (array_slice($commands, 0, 5) as $command) {
$output->writeLine("{$command->name} - {$command->description}", ConsoleColor::WHITE);
}
if ($count > 5) {
$remaining = $count - 5;
$output->writeLine(" ... and {$remaining} more", ConsoleColor::GRAY);
}
$output->newLine();
}
$output->writeLine("💡 Use 'commands:by-category <category>' to see all commands in a specific category", ConsoleColor::CYAN);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('commands:search', 'Search commands by keyword')]
public function searchCommands(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$keyword = $input->getArgument(0);
if ($keyword === null) {
$output->writeLine("❌ Please provide a keyword to search for.", ConsoleColor::RED);
$output->newLine();
$output->writeLine("Usage: commands:search <keyword>", ConsoleColor::YELLOW);
$output->writeLine("Example: commands:search database", ConsoleColor::GRAY);
return ExitCode::INVALID_INPUT;
}
$output->writeLine("🔍 Searching commands for: '{$keyword}'", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$matches = $this->searchCommandsByKeyword($keyword);
if (empty($matches)) {
$output->writeLine("No commands found matching '{$keyword}'.", ConsoleColor::YELLOW);
$output->newLine();
$this->showSearchHelp($output);
return ExitCode::SUCCESS;
}
$output->writeLine("Found " . count($matches) . " matching commands:", ConsoleColor::GREEN);
$output->newLine();
foreach ($matches as $match) {
$output->writeLine("📌 {$match['command']->name}", ConsoleColor::BRIGHT_WHITE);
$output->writeLine(" {$match['command']->description}", ConsoleColor::WHITE);
$output->writeLine(" Match reason: {$match['reason']}", ConsoleColor::GRAY);
$output->newLine();
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('commands:similar', 'Find commands similar to a specific command')]
public function findSimilarCommands(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$commandName = $input->getArgument(0);
if ($commandName === null) {
$output->writeLine("❌ Please provide a command name to find similar commands for.", ConsoleColor::RED);
$output->newLine();
$output->writeLine("Usage: commands:similar <command>", ConsoleColor::YELLOW);
$output->writeLine("Example: commands:similar db:migrate", ConsoleColor::GRAY);
return ExitCode::INVALID_INPUT;
}
if (! $this->getCommandList()->has($commandName)) {
$output->writeLine("❌ Command '{$commandName}' not found.", ConsoleColor::RED);
$output->newLine();
// Use suggestion engine for alternatives
$suggestions = $this->getSuggestionEngine()->suggestCommand($commandName);
if ($suggestions->hasSuggestions()) {
$output->writeLine("Did you mean:", ConsoleColor::YELLOW);
$output->writeLine($suggestions->formatForDisplay(), ConsoleColor::WHITE);
}
return ExitCode::COMMAND_NOT_FOUND;
}
$baseCommand = $this->getCommandList()->get($commandName);
$similar = $this->getSuggestionEngine()->suggestSimilarCommands($baseCommand, 10);
$output->writeLine("🔗 Commands similar to '{$commandName}':", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
if (empty($similar)) {
$output->writeLine("No similar commands found.", ConsoleColor::YELLOW);
return ExitCode::SUCCESS;
}
foreach ($similar as $rel) {
$percentage = round($rel['similarity'] * 100);
$output->writeLine("📌 {$rel['command']->name} ({$percentage}% similarity)", ConsoleColor::BRIGHT_WHITE);
$output->writeLine(" {$rel['command']->description}", ConsoleColor::WHITE);
$output->writeLine(" Relation: {$rel['relation']->getDisplayText()}", ConsoleColor::GRAY);
$output->newLine();
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('help:interactive', 'Interactive help system')]
public function interactiveHelp(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine("🤝 Interactive Help System", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$output->writeLine("What would you like to do?", ConsoleColor::BRIGHT_YELLOW);
$output->writeLine("1. Find a command for a specific task", ConsoleColor::WHITE);
$output->writeLine("2. Explore commands by category", ConsoleColor::WHITE);
$output->writeLine("3. Search commands by keyword", ConsoleColor::WHITE);
$output->writeLine("4. Get help with a specific command", ConsoleColor::WHITE);
$output->writeLine("5. See all available commands", ConsoleColor::WHITE);
$output->newLine();
$output->writeLine("Examples:", ConsoleColor::CYAN);
$output->writeLine(" php console.php suggest database", ConsoleColor::GRAY);
$output->writeLine(" php console.php commands:by-category db", ConsoleColor::GRAY);
$output->writeLine(" php console.php commands:search migration", ConsoleColor::GRAY);
$output->writeLine(" php console.php help db:migrate", ConsoleColor::GRAY);
$output->writeLine(" php console.php help", ConsoleColor::GRAY);
return ExitCode::SUCCESS;
}
private function showSpecificCategory(string $category, ConsoleOutputInterface $output): ExitCode
{
$categories = $this->categorizeCommands();
if (! isset($categories[$category])) {
$output->writeLine("❌ Category '{$category}' not found.", ConsoleColor::RED);
$output->newLine();
$availableCategories = array_keys($categories);
$output->writeLine("Available categories:", ConsoleColor::YELLOW);
foreach ($availableCategories as $cat) {
$output->writeLine("{$cat}", ConsoleColor::WHITE);
}
return ExitCode::COMMAND_NOT_FOUND;
}
$commands = $categories[$category];
$output->writeLine("📁 Commands in category '{$category}' (" . count($commands) . " commands)", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
foreach ($commands as $command) {
$output->writeLine("📌 {$command->name}", ConsoleColor::BRIGHT_WHITE);
$output->writeLine(" {$command->description}", ConsoleColor::WHITE);
$output->newLine();
}
return ExitCode::SUCCESS;
}
private function categorizeCommands(): array
{
$categories = [];
foreach ($this->getCommandList()->getAllCommands() as $command) {
$parts = explode(':', $command->name);
$category = $parts[0];
if (! isset($categories[$category])) {
$categories[$category] = [];
}
$categories[$category][] = $command;
}
ksort($categories);
return $categories;
}
private function searchCommandsByKeyword(string $keyword): array
{
$matches = [];
$keyword = strtolower($keyword);
foreach ($this->getCommandList()->getAllCommands() as $command) {
$reasons = [];
// Check command name
if (str_contains(strtolower($command->name), $keyword)) {
$reasons[] = 'command name';
}
// Check description
if (str_contains(strtolower($command->description), $keyword)) {
$reasons[] = 'description';
}
// Check individual words
$commandWords = preg_split('/[:\-_\s]+/', strtolower($command->name));
$descWords = preg_split('/\s+/', strtolower($command->description));
if (in_array($keyword, $commandWords, true)) {
$reasons[] = 'command word';
}
if (in_array($keyword, $descWords, true)) {
$reasons[] = 'description word';
}
if (! empty($reasons)) {
$matches[] = [
'command' => $command,
'reason' => implode(', ', $reasons),
];
}
}
return $matches;
}
private function showAlternativeHelp(ConsoleOutputInterface $output): void
{
$output->writeLine("💡 Try these alternatives:", ConsoleColor::CYAN);
$output->writeLine(" • Use 'help' to see all available commands", ConsoleColor::WHITE);
$output->writeLine(" • Use 'commands:by-category' to browse by category", ConsoleColor::WHITE);
$output->writeLine(" • Use 'commands:search <keyword>' to search by keyword", ConsoleColor::WHITE);
$output->writeLine(" • Use 'help:interactive' for guided help", ConsoleColor::WHITE);
}
private function showSearchHelp(ConsoleOutputInterface $output): void
{
$output->writeLine("💡 Search tips:", ConsoleColor::CYAN);
$output->writeLine(" • Try simpler keywords (e.g., 'db' instead of 'database')", ConsoleColor::WHITE);
$output->writeLine(" • Try synonyms or related terms", ConsoleColor::WHITE);
$output->writeLine(" • Use 'commands:by-category' to browse categories", ConsoleColor::WHITE);
$output->writeLine(" • Use 'help' to see all commands", ConsoleColor::WHITE);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Attributes\ConsoleCommand;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Output\ConsoleOutput;
use App\Framework\Development\HotReload\HotReloadServer;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\FileWatcher;
use App\Framework\Http\SseStream;
use Psr\Log\LoggerInterface;
/**
* Console command for running the Hot Reload server
*/
#[ConsoleCommand(
name: 'dev:hot-reload',
description: 'Start the Hot Reload server for development'
)]
final readonly class HotReloadCommand
{
public function __construct(
private Environment $environment,
private ?LoggerInterface $logger = null
) {
}
public function execute(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
// Check if we're in development mode
if (! $this->environment->getBool(EnvKey::APP_DEBUG, false)) {
$output->error('Hot Reload is only available in development mode');
$output->writeln('Set APP_DEBUG=true in your .env file');
return ExitCode::GENERAL_ERROR;
}
$output->title('🔥 Hot Reload Server');
$output->writeln('Starting file watcher for hot reload...');
// Show configuration
$output->section('Configuration');
$output->writeln([
'• Base Path: ' . dirname(__DIR__, 3),
'• Poll Interval: 500ms',
'• Watching: PHP, CSS, JS, Templates',
'• Ignoring: vendor/, var/, storage/, tests/, public/',
]);
// Create components
$fileWatcher = new FileWatcher(FilePath::fromString(dirname(__DIR__, 3)));
$sseStream = new SseStream();
// Create and configure hot reload server
$hotReloadServer = new HotReloadServer($fileWatcher, $sseStream, $this->logger);
// Register shutdown handler
register_shutdown_function(function () use ($hotReloadServer, $output) {
$output->writeln('');
$output->info('Shutting down Hot Reload server...');
$hotReloadServer->stop();
});
// Handle signals for graceful shutdown
if (extension_loaded('pcntl')) {
pcntl_signal(SIGINT, function () use ($hotReloadServer, $output) {
$output->writeln('');
$output->info('Received interrupt signal, shutting down...');
$hotReloadServer->stop();
exit(0);
});
pcntl_signal(SIGTERM, function () use ($hotReloadServer, $output) {
$output->writeln('');
$output->info('Received termination signal, shutting down...');
$hotReloadServer->stop();
exit(0);
});
}
$output->success('Hot Reload server is running!');
$output->writeln([
'',
'Open your browser and navigate to your application.',
'The browser will automatically reload when files change.',
'',
'Press Ctrl+C to stop the server.',
'',
]);
// This would normally start the watcher, but we need to adjust it
// to work without blocking the main thread in a real implementation
$output->comment('Note: File watching implementation needs to be run in a separate process.');
$output->comment('For now, use with the SSE endpoint: /dev/hot-reload');
// Keep the command running
while (true) {
sleep(1);
// Process signals if available
if (extension_loaded('pcntl')) {
pcntl_signal_dispatch();
}
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
#[CommandGroup(
name: 'Performance',
description: 'Console performance monitoring and analysis commands',
icon: '📊',
priority: 80
)]
final readonly class PerformanceStatsCommand
{
public function __construct(
private PerformanceCollectorInterface $performanceCollector
) {
}
#[ConsoleCommand('perf:console', 'Show console command performance statistics')]
public function showConsoleStats(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('📊 Console Performance Statistics', ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
if (empty($consoleMetrics)) {
$output->writeLine('No console performance metrics available.', ConsoleColor::YELLOW);
$output->writeLine('Performance monitoring may not be enabled or no commands have been executed yet.', ConsoleColor::GRAY);
return ExitCode::SUCCESS;
}
$this->displayPerformanceSummary($output, $consoleMetrics);
$output->newLine();
$this->displayCommandBreakdown($output, $consoleMetrics);
$output->newLine();
$this->displayMemoryUsage($output, $consoleMetrics);
$output->newLine();
$this->displayErrorStats($output, $consoleMetrics);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('perf:console:clear', 'Clear console performance metrics')]
public function clearConsoleStats(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🧹 Clearing Console Performance Metrics', ConsoleColor::BRIGHT_YELLOW);
$output->newLine();
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
$metricsCount = count($consoleMetrics);
// Clear console-specific metrics
foreach ($consoleMetrics as $metric) {
// Note: Actual clearing would depend on the performance collector implementation
// This is a demonstration of the interface
}
$output->writeLine("✅ Cleared {$metricsCount} console performance metrics", ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('perf:console:export', 'Export console performance metrics to JSON')]
public function exportConsoleStats(string $filename = 'console-performance.json'): ExitCode
{
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
$exportData = [
'timestamp' => date('c'),
'total_metrics' => count($consoleMetrics),
'metrics' => array_map(fn ($metric) => $metric->toArray(), $consoleMetrics),
'summary' => $this->generateSummaryData($consoleMetrics),
];
file_put_contents($filename, json_encode($exportData, JSON_PRETTY_PRINT));
return ExitCode::SUCCESS;
}
private function displayPerformanceSummary(ConsoleOutputInterface $output, array $metrics): void
{
$summary = $this->generateSummaryData($metrics);
$output->writeLine('🎯 Performance Summary', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────────', ConsoleColor::GRAY);
$output->writeLine(sprintf('Total Commands Executed: %d', $summary['total_executions']), ConsoleColor::WHITE);
$output->writeLine(
sprintf('Success Rate: %.1f%%', $summary['success_rate']),
$summary['success_rate'] >= 95 ? ConsoleColor::GREEN :
($summary['success_rate'] >= 85 ? ConsoleColor::YELLOW : ConsoleColor::RED)
);
$output->writeLine(sprintf('Average Execution Time: %.2fms', $summary['average_duration']), ConsoleColor::WHITE);
$output->writeLine(sprintf('Average Memory Usage: %.2fMB', $summary['average_memory']), ConsoleColor::WHITE);
}
private function displayCommandBreakdown(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('📋 Command Usage Breakdown', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────────────', ConsoleColor::GRAY);
$commandStats = $this->getCommandStats($metrics);
if (empty($commandStats)) {
$output->writeLine('No command statistics available.', ConsoleColor::GRAY);
return;
}
// Sort by execution count
arsort($commandStats);
foreach (array_slice($commandStats, 0, 10) as $command => $count) {
$output->writeLine(sprintf(' %s: %d executions', $command, $count), ConsoleColor::WHITE);
}
if (count($commandStats) > 10) {
$output->writeLine(sprintf(' ... and %d more commands', count($commandStats) - 10), ConsoleColor::GRAY);
}
}
private function displayMemoryUsage(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('💾 Memory Usage Analysis', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('───────────────────────', ConsoleColor::GRAY);
$memoryMetrics = array_filter($metrics, fn ($metric) => str_contains($metric->getKey(), 'memory'));
if (empty($memoryMetrics)) {
$output->writeLine('No memory usage data available.', ConsoleColor::GRAY);
return;
}
$totalMemory = 0;
$peakMemory = 0;
$count = 0;
foreach ($memoryMetrics as $metric) {
if (str_contains($metric->getKey(), 'memory_usage')) {
$totalMemory += $metric->getValue();
$count++;
} elseif (str_contains($metric->getKey(), 'peak_memory')) {
$peakMemory = max($peakMemory, $metric->getValue());
}
}
if ($count > 0) {
$averageMemory = $totalMemory / $count;
$output->writeLine(sprintf('Average Memory Usage: %.2f MB', $averageMemory), ConsoleColor::WHITE);
}
if ($peakMemory > 0) {
$output->writeLine(
sprintf('Peak Memory Usage: %.2f MB', $peakMemory),
$peakMemory > 100 ? ConsoleColor::RED : ($peakMemory > 50 ? ConsoleColor::YELLOW : ConsoleColor::GREEN)
);
}
}
private function displayErrorStats(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('❌ Error Statistics', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────', ConsoleColor::GRAY);
$errorMetrics = array_filter($metrics, fn ($metric) => str_contains($metric->getKey(), 'error'));
if (empty($errorMetrics)) {
$output->writeLine('No errors recorded! 🎉', ConsoleColor::GREEN);
return;
}
$errorsByType = [];
$totalErrors = 0;
foreach ($errorMetrics as $metric) {
$context = $metric->getContext();
$errorType = $context['error_type'] ?? 'Unknown';
$errorsByType[$errorType] = ($errorsByType[$errorType] ?? 0) + $metric->getValue();
$totalErrors += $metric->getValue();
}
$output->writeLine(
sprintf('Total Errors: %d', $totalErrors),
$totalErrors > 0 ? ConsoleColor::RED : ConsoleColor::GREEN
);
if (! empty($errorsByType)) {
$output->writeLine('Error Types:', ConsoleColor::WHITE);
foreach ($errorsByType as $type => $count) {
$output->writeLine(sprintf(' %s: %d', $type, $count), ConsoleColor::RED);
}
}
}
private function generateSummaryData(array $metrics): array
{
$totalExecutions = 0;
$totalErrors = 0;
$totalDuration = 0;
$totalMemory = 0;
$durationCount = 0;
$memoryCount = 0;
foreach ($metrics as $metric) {
$key = $metric->getKey();
if (str_contains($key, '_executions')) {
$totalExecutions += $metric->getValue();
} elseif (str_contains($key, '_error')) {
$totalErrors += $metric->getValue();
} elseif (str_contains($key, 'console_command_') && ! str_contains($key, '_')) {
$totalDuration += $metric->getTotalDuration();
$durationCount++;
} elseif (str_contains($key, 'memory_usage')) {
$totalMemory += $metric->getValue();
$memoryCount++;
}
}
return [
'total_executions' => $totalExecutions,
'total_errors' => $totalErrors,
'success_rate' => $totalExecutions > 0 ? (($totalExecutions - $totalErrors) / $totalExecutions) * 100 : 100,
'average_duration' => $durationCount > 0 ? $totalDuration / $durationCount : 0,
'average_memory' => $memoryCount > 0 ? $totalMemory / $memoryCount : 0,
];
}
private function getCommandStats(array $metrics): array
{
$commandStats = [];
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), '_executions')) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? 'unknown';
$commandStats[$commandName] = ($commandStats[$commandName] ?? 0) + $metric->getValue();
}
}
return $commandStats;
}
}

View File

@@ -0,0 +1,384 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
#[CommandGroup(
name: 'Performance',
description: 'Console performance monitoring and analysis commands',
icon: '📊',
priority: 80
)]
final readonly class PerformanceTrendsCommand
{
public function __construct(
private PerformanceCollectorInterface $performanceCollector
) {
}
#[ConsoleCommand('perf:trends', 'Show console performance trends and insights')]
public function showPerformanceTrends(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('📈 Console Performance Trends', ConsoleColor::BRIGHT_GREEN);
$output->newLine();
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
if (empty($consoleMetrics)) {
$output->writeLine('No performance data available for trend analysis.', ConsoleColor::YELLOW);
return ExitCode::SUCCESS;
}
$this->displayExecutionTrends($output, $consoleMetrics);
$output->newLine();
$this->displayMemoryTrends($output, $consoleMetrics);
$output->newLine();
$this->displayCommandFrequency($output, $consoleMetrics);
$output->newLine();
$this->displayPerformanceInsights($output, $consoleMetrics);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('perf:benchmark', 'Run performance benchmarks for console commands')]
public function runBenchmarks(ConsoleInput $input, ConsoleOutputInterface $output, int $iterations = 5): ExitCode
{
$output->writeLine('🏃 Console Performance Benchmarks', ConsoleColor::BRIGHT_BLUE);
$output->writeLine(sprintf('Running %d iterations of each command...', $iterations), ConsoleColor::GRAY);
$output->newLine();
// This would require integration with the command registry to run actual benchmarks
$output->writeLine('⚠️ Benchmark functionality requires command registry integration', ConsoleColor::YELLOW);
$output->writeLine('This is a placeholder for future implementation.', ConsoleColor::GRAY);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('perf:health', 'Check console performance health status')]
public function checkPerformanceHealth(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🏥 Console Performance Health Check', ConsoleColor::BRIGHT_MAGENTA);
$output->newLine();
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
$healthScore = $this->calculateHealthScore($consoleMetrics);
$this->displayHealthScore($output, $healthScore);
$output->newLine();
$this->displayHealthDetails($output, $consoleMetrics, $healthScore);
$output->newLine();
$this->displayHealthRecommendations($output, $healthScore);
return ExitCode::SUCCESS;
}
private function displayExecutionTrends(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('⏱️ Execution Time Trends', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────────────', ConsoleColor::GRAY);
$executionMetrics = array_filter(
$metrics,
fn ($metric) =>
str_contains($metric->getKey(), 'console_command_') &&
! str_contains($metric->getKey(), '_error') &&
! str_contains($metric->getKey(), '_memory')
);
if (empty($executionMetrics)) {
$output->writeLine('No execution time data available.', ConsoleColor::GRAY);
return;
}
$fastCommands = 0;
$mediumCommands = 0;
$slowCommands = 0;
$totalDuration = 0;
$commandCount = 0;
foreach ($executionMetrics as $metric) {
$avgDuration = $metric->getAverageDuration();
$totalDuration += $metric->getTotalDuration();
$commandCount++;
if ($avgDuration < 100) {
$fastCommands++;
} elseif ($avgDuration < 1000) {
$mediumCommands++;
} else {
$slowCommands++;
}
}
$output->writeLine(sprintf('Fast Commands (<100ms): %d', $fastCommands), ConsoleColor::GREEN);
$output->writeLine(sprintf('Medium Commands (100-1000ms): %d', $mediumCommands), ConsoleColor::YELLOW);
$output->writeLine(sprintf('Slow Commands (>1000ms): %d', $slowCommands), ConsoleColor::RED);
if ($commandCount > 0) {
$avgOverall = $totalDuration / $commandCount;
$output->writeLine(sprintf('Overall Average: %.2fms', $avgOverall), ConsoleColor::WHITE);
}
}
private function displayMemoryTrends(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('💾 Memory Usage Trends', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('────────────────────', ConsoleColor::GRAY);
$memoryMetrics = array_filter($metrics, fn ($metric) => str_contains($metric->getKey(), 'memory'));
if (empty($memoryMetrics)) {
$output->writeLine('No memory usage data available.', ConsoleColor::GRAY);
return;
}
$lowMemory = 0;
$mediumMemory = 0;
$highMemory = 0;
$totalMemory = 0;
$count = 0;
foreach ($memoryMetrics as $metric) {
$memory = $metric->getValue();
$totalMemory += $memory;
$count++;
if ($memory < 10) {
$lowMemory++;
} elseif ($memory < 50) {
$mediumMemory++;
} else {
$highMemory++;
}
}
$output->writeLine(sprintf('Low Memory Usage (<10MB): %d commands', $lowMemory), ConsoleColor::GREEN);
$output->writeLine(sprintf('Medium Memory Usage (10-50MB): %d commands', $mediumMemory), ConsoleColor::YELLOW);
$output->writeLine(sprintf('High Memory Usage (>50MB): %d commands', $highMemory), ConsoleColor::RED);
if ($count > 0) {
$avgMemory = $totalMemory / $count;
$output->writeLine(sprintf('Average Memory Usage: %.2fMB', $avgMemory), ConsoleColor::WHITE);
}
}
private function displayCommandFrequency(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('📊 Command Usage Frequency', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────────────', ConsoleColor::GRAY);
$executionCounts = [];
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), '_executions')) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? 'unknown';
$executionCounts[$commandName] = ($executionCounts[$commandName] ?? 0) + $metric->getValue();
}
}
if (empty($executionCounts)) {
$output->writeLine('No execution frequency data available.', ConsoleColor::GRAY);
return;
}
arsort($executionCounts);
$topCommands = array_slice($executionCounts, 0, 5, true);
foreach ($topCommands as $command => $count) {
$bar = str_repeat('█', min(20, intval($count / max($executionCounts) * 20)));
$output->writeLine(sprintf(' %-20s %s (%d)', $command, $bar, $count), ConsoleColor::CYAN);
}
}
private function displayPerformanceInsights(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('💡 Performance Insights', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('──────────────────────', ConsoleColor::GRAY);
$insights = $this->generateInsights($metrics);
if (empty($insights)) {
$output->writeLine('No specific insights available.', ConsoleColor::GRAY);
return;
}
foreach ($insights as $insight) {
$output->writeLine(" {$insight}", ConsoleColor::WHITE);
}
}
private function calculateHealthScore(array $metrics): array
{
$score = 100;
$issues = [];
// Check error rate
$totalExecutions = 0;
$totalErrors = 0;
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), '_executions')) {
$totalExecutions += $metric->getValue();
} elseif (str_contains($metric->getKey(), '_error')) {
$totalErrors += $metric->getValue();
}
}
$errorRate = $totalExecutions > 0 ? ($totalErrors / $totalExecutions) * 100 : 0;
if ($errorRate > 10) {
$score -= 30;
$issues[] = 'High error rate detected';
} elseif ($errorRate > 5) {
$score -= 15;
$issues[] = 'Moderate error rate detected';
}
// Check for slow commands
$slowCommandCount = 0;
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), 'console_command_') &&
! str_contains($metric->getKey(), '_error') &&
! str_contains($metric->getKey(), '_memory')) {
if ($metric->getAverageDuration() > 5000) {
$slowCommandCount++;
}
}
}
if ($slowCommandCount > 3) {
$score -= 20;
$issues[] = 'Multiple slow commands detected';
} elseif ($slowCommandCount > 0) {
$score -= 10;
$issues[] = 'Some slow commands detected';
}
// Check memory usage
$highMemoryCount = 0;
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), 'memory_usage') && $metric->getValue() > 100) {
$highMemoryCount++;
}
}
if ($highMemoryCount > 0) {
$score -= 15;
$issues[] = 'High memory usage detected';
}
return [
'score' => max(0, $score),
'issues' => $issues,
'total_executions' => $totalExecutions,
'error_rate' => $errorRate,
'slow_commands' => $slowCommandCount,
'high_memory_commands' => $highMemoryCount,
];
}
private function displayHealthScore(ConsoleOutputInterface $output, array $health): void
{
$score = $health['score'];
$color = $score >= 90 ? ConsoleColor::GREEN :
($score >= 70 ? ConsoleColor::YELLOW : ConsoleColor::RED);
$status = $score >= 90 ? 'Excellent' :
($score >= 70 ? 'Good' :
($score >= 50 ? 'Fair' : 'Poor'));
$output->writeLine(sprintf('Health Score: %d/100 (%s)', $score, $status), $color);
}
private function displayHealthDetails(ConsoleOutputInterface $output, array $metrics, array $health): void
{
$output->writeLine('📋 Health Details', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('───────────────', ConsoleColor::GRAY);
$output->writeLine(sprintf('Total Executions: %d', $health['total_executions']), ConsoleColor::WHITE);
$output->writeLine(
sprintf('Error Rate: %.2f%%', $health['error_rate']),
$health['error_rate'] > 5 ? ConsoleColor::RED : ConsoleColor::GREEN
);
$output->writeLine(
sprintf('Slow Commands: %d', $health['slow_commands']),
$health['slow_commands'] > 0 ? ConsoleColor::YELLOW : ConsoleColor::GREEN
);
$output->writeLine(
sprintf('High Memory Commands: %d', $health['high_memory_commands']),
$health['high_memory_commands'] > 0 ? ConsoleColor::YELLOW : ConsoleColor::GREEN
);
}
private function displayHealthRecommendations(ConsoleOutputInterface $output, array $health): void
{
if (empty($health['issues'])) {
$output->writeLine('🎉 No performance issues detected!', ConsoleColor::GREEN);
return;
}
$output->writeLine('⚠️ Recommendations', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────', ConsoleColor::GRAY);
foreach ($health['issues'] as $issue) {
$output->writeLine("{$issue}", ConsoleColor::YELLOW);
}
}
private function generateInsights(array $metrics): array
{
$insights = [];
// Analyze command patterns
$commandCounts = [];
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), '_executions')) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? 'unknown';
$commandCounts[$commandName] = ($commandCounts[$commandName] ?? 0) + $metric->getValue();
}
}
if (! empty($commandCounts)) {
$mostUsed = array_keys($commandCounts, max($commandCounts))[0];
$insights[] = "Most frequently used command: {$mostUsed}";
if (count($commandCounts) > 5) {
$insights[] = "You're using a diverse set of commands (" . count($commandCounts) . " different commands)";
}
}
// Memory insights
$memoryMetrics = array_filter($metrics, fn ($metric) => str_contains($metric->getKey(), 'memory_usage'));
if (! empty($memoryMetrics)) {
$totalMemory = array_sum(array_map(fn ($metric) => $metric->getValue(), $memoryMetrics));
$avgMemory = $totalMemory / count($memoryMetrics);
if ($avgMemory < 5) {
$insights[] = "Memory usage is very efficient (avg: " . number_format($avgMemory, 1) . "MB)";
} elseif ($avgMemory > 50) {
$insights[] = "Memory usage is higher than expected (avg: " . number_format($avgMemory, 1) . "MB)";
}
}
return $insights;
}
}

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Progress\LongRunning;
use App\Framework\Console\Progress\ProgressAwareOutput;
use App\Framework\Console\Progress\ProgressType;
#[CommandGroup(
name: 'Demo',
description: 'Demonstration commands for framework features',
icon: '🎯',
priority: 100
)]
final readonly class ProgressDemoCommand
{
#[ConsoleCommand('demo:progress', 'Demonstrate progress tracking features')]
public function demoProgress(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$progressOutput = new ProgressAwareOutput($output);
$output->writeLine("🎯 Progress Tracking Demonstration", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
// Demo 1: Basic Progress Tracker
$output->writeLine("1. Basic Progress Tracker:", ConsoleColor::BRIGHT_YELLOW);
$this->demoProgressTracker($progressOutput);
$output->newLine();
// Demo 2: Spinner Progress
$output->writeLine("2. Spinner Progress (indeterminate):", ConsoleColor::BRIGHT_YELLOW);
$this->demoSpinnerProgress($progressOutput);
$output->newLine();
// Demo 3: Progress Bar
$output->writeLine("3. Simple Progress Bar:", ConsoleColor::BRIGHT_YELLOW);
$this->demoProgressBar($progressOutput);
$output->newLine();
// Demo 4: Nested Progress
$output->writeLine("4. Nested Progress Operations:", ConsoleColor::BRIGHT_YELLOW);
$this->demoNestedProgress($progressOutput);
$output->writeLine("✅ All progress demos completed!", ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:migration', 'Simulate database migration with progress')]
public function demoMigration(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$progressOutput = new ProgressAwareOutput($output);
$output->writeLine("📦 Simulating Database Migration", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$migrationFiles = [
'2024_01_01_create_users_table.php',
'2024_01_02_create_posts_table.php',
'2024_01_03_add_user_indexes.php',
'2024_01_04_create_comments_table.php',
'2024_01_05_add_foreign_keys.php',
];
return $progressOutput->withProgress(
count($migrationFiles),
'Running Migrations',
function ($progress) use ($migrationFiles) {
foreach ($migrationFiles as $i => $file) {
$progress->setTask("Processing {$file}");
// Simulate migration steps
usleep(500000); // 0.5 seconds
$progress->advance(1);
}
return ExitCode::SUCCESS;
}
);
}
#[ConsoleCommand('demo:backup', 'Simulate backup process with progress')]
public function demoBackup(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$progressOutput = new ProgressAwareOutput($output);
$output->writeLine("💾 Simulating Database Backup", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
return $progressOutput->withSpinner(
'Creating Backup',
function ($spinner) {
$tasks = [
'Connecting to database',
'Analyzing tables',
'Exporting users table',
'Exporting posts table',
'Exporting comments table',
'Compressing backup file',
'Verifying backup integrity',
];
foreach ($tasks as $task) {
$spinner->setTask($task);
usleep(800000); // 0.8 seconds
$spinner->advance();
}
$spinner->finish('Backup completed successfully');
return ExitCode::SUCCESS;
}
);
}
private function demoProgressTracker(ProgressAwareOutput $output): void
{
$progress = $output->createProgress(10, 'Processing Items');
for ($i = 1; $i <= 10; $i++) {
usleep(200000); // 0.2 seconds
$progress->advance(1, "Processing item {$i}");
}
$progress->finish('All items processed');
}
private function demoSpinnerProgress(ProgressAwareOutput $output): void
{
$spinner = $output->createSpinner('Analyzing Data');
$tasks = ['Loading', 'Analyzing', 'Computing', 'Finalizing'];
foreach ($tasks as $task) {
$spinner->setTask($task);
usleep(600000); // 0.6 seconds
$spinner->advance();
}
$spinner->finish('Analysis complete');
}
private function demoProgressBar(ProgressAwareOutput $output): void
{
$bar = $output->createProgressBar(40);
for ($i = 0; $i <= 100; $i += 10) {
$bar->render($i / 100, "Step {$i}%");
usleep(150000); // 0.15 seconds
}
$bar->finish('Simple progress complete');
}
private function demoNestedProgress(ProgressAwareOutput $output): void
{
$mainProgress = $output->createProgress(3, 'Main Operation');
// Phase 1
$mainProgress->setTask('Phase 1: Initialization');
$subProgress = $output->createProgress(5, 'Initializing');
for ($i = 1; $i <= 5; $i++) {
$subProgress->advance(1, "Init step {$i}");
usleep(100000);
}
$subProgress->finish();
$mainProgress->advance(1);
$output->newLine();
// Phase 2
$mainProgress->setTask('Phase 2: Processing');
$subProgress = $output->createProgress(8, 'Processing');
for ($i = 1; $i <= 8; $i++) {
$subProgress->advance(1, "Process step {$i}");
usleep(100000);
}
$subProgress->finish();
$mainProgress->advance(1);
$output->newLine();
// Phase 3
$mainProgress->setTask('Phase 3: Cleanup');
$subProgress = $output->createProgress(3, 'Cleaning Up');
for ($i = 1; $i <= 3; $i++) {
$subProgress->advance(1, "Cleanup step {$i}");
usleep(100000);
}
$subProgress->finish();
$mainProgress->advance(1);
$mainProgress->finish('Nested operation complete');
}
#[ConsoleCommand('demo:auto-progress', 'Demonstrate automatic progress detection')]
#[LongRunning(estimatedSteps: 15, progressType: ProgressType::TRACKER, title: 'Auto Progress Demo')]
public function demoAutoProgress(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
// This command will automatically get progress tracking via ProgressMiddleware
// The middleware detects the LongRunning attribute and sets up progress
$output->writeLine("🤖 Automatic Progress Detection Demo", ConsoleColor::BRIGHT_CYAN);
$output->writeLine("This command uses the #[LongRunning] attribute for automatic progress", ConsoleColor::GRAY);
$output->newLine();
// Simulate a long-running operation
$steps = [
'Initializing system',
'Loading configuration',
'Connecting to database',
'Reading user data',
'Processing records batch 1',
'Processing records batch 2',
'Processing records batch 3',
'Validating data integrity',
'Generating reports',
'Optimizing indexes',
'Creating backups',
'Sending notifications',
'Cleaning temporary files',
'Updating statistics',
'Finalizing operation',
];
// Get progress from ProgressAwareOutput if available
if ($output instanceof ProgressAwareOutput) {
$progress = $output->getCurrentProgress();
if ($progress) {
foreach ($steps as $i => $step) {
$progress->advance(1, $step);
usleep(300000); // 0.3 seconds per step
}
$progress->finish('Auto progress demo completed');
} else {
$output->writeLine("No automatic progress detected, running without progress...", ConsoleColor::YELLOW);
foreach ($steps as $step) {
$output->writeLine("$step", ConsoleColor::GRAY);
usleep(300000);
}
}
} else {
$output->writeLine("Standard output - no progress tracking available", ConsoleColor::YELLOW);
}
$output->writeLine("✅ Auto progress demo completed!", ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:spinner-auto', 'Demonstrate automatic spinner progress')]
#[LongRunning(progressType: ProgressType::SPINNER, title: 'Processing Data')]
public function demoSpinnerAuto(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
// This will automatically get a spinner via ProgressMiddleware
$output->writeLine("🌀 Automatic Spinner Demo", ConsoleColor::BRIGHT_CYAN);
$output->writeLine("This command uses the #[LongRunning] attribute with SPINNER type", ConsoleColor::GRAY);
$output->newLine();
$tasks = [
'Connecting to API',
'Fetching data',
'Processing results',
'Analyzing patterns',
'Generating insights',
'Preparing output',
];
if ($output instanceof ProgressAwareOutput) {
$spinner = $output->getCurrentProgress();
if ($spinner) {
foreach ($tasks as $task) {
$spinner->setTask($task);
usleep(800000); // 0.8 seconds per task
$spinner->advance();
}
$spinner->finish('Data processing completed');
} else {
foreach ($tasks as $task) {
$output->writeLine("$task", ConsoleColor::GRAY);
usleep(800000);
}
}
}
$output->writeLine("✅ Spinner demo completed!", ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
#[CommandGroup(
name: 'Performance',
description: 'Console performance monitoring and analysis commands',
icon: '📊',
priority: 80
)]
final readonly class SlowCommandsCommand
{
public function __construct(
private PerformanceCollectorInterface $performanceCollector
) {
}
#[ConsoleCommand('perf:slow-commands', 'Show slowest console commands')]
public function showSlowCommands(ConsoleInput $input, ConsoleOutputInterface $output, int $limit = 10, float $threshold = 1000.0): ExitCode
{
$output->writeLine('🐌 Slowest Console Commands', ConsoleColor::BRIGHT_YELLOW);
$output->writeLine(sprintf('Threshold: %.0fms | Showing top %d', $threshold, $limit), ConsoleColor::GRAY);
$output->newLine();
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
$slowCommands = $this->findSlowCommands($consoleMetrics, $threshold);
if (empty($slowCommands)) {
$output->writeLine('🎉 No slow commands found!', ConsoleColor::GREEN);
$output->writeLine(sprintf('All commands executed faster than %.0fms', $threshold), ConsoleColor::GRAY);
return ExitCode::SUCCESS;
}
// Sort by duration (descending)
usort($slowCommands, fn ($a, $b) => $b['duration'] <=> $a['duration']);
$slowCommands = array_slice($slowCommands, 0, $limit);
$this->displaySlowCommandsTable($output, $slowCommands);
$output->newLine();
$this->displayPerformanceRecommendations($output, $slowCommands);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('perf:watch-slow', 'Monitor for slow commands in real-time')]
public function watchSlowCommands(ConsoleInput $input, ConsoleOutputInterface $output, float $threshold = 5000.0): ExitCode
{
$output->writeLine('👀 Monitoring Slow Commands', ConsoleColor::BRIGHT_CYAN);
$output->writeLine(sprintf('Threshold: %.0fms | Press Ctrl+C to stop', $threshold), ConsoleColor::GRAY);
$output->newLine();
$lastMetricsCount = 0;
while (true) {
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
$currentMetricsCount = count($consoleMetrics);
// Check if new metrics were added
if ($currentMetricsCount > $lastMetricsCount) {
$newMetrics = array_slice($consoleMetrics, $lastMetricsCount);
$this->checkForSlowCommands($output, $newMetrics, $threshold);
$lastMetricsCount = $currentMetricsCount;
}
usleep(500000); // Wait 0.5 seconds
}
}
#[ConsoleCommand('perf:profile-command', 'Profile a specific command execution')]
public function profileCommand(ConsoleInput $input, ConsoleOutputInterface $output, string $commandName, string ...$args): ExitCode
{
$output->writeLine("🔍 Profiling Command: {$commandName}", ConsoleColor::BRIGHT_BLUE);
$output->newLine();
// Get baseline metrics
$beforeMetrics = $this->getCommandMetrics($commandName);
$output->writeLine('📊 Baseline metrics captured', ConsoleColor::GRAY);
$output->writeLine("Execute the command now: php console.php {$commandName} " . implode(' ', $args), ConsoleColor::YELLOW);
$output->writeLine('Press Enter when command execution is complete...', ConsoleColor::GRAY);
// Wait for user input
fgets(STDIN);
// Get after metrics
$afterMetrics = $this->getCommandMetrics($commandName);
$this->displayProfilingResults($output, $commandName, $beforeMetrics, $afterMetrics);
return ExitCode::SUCCESS;
}
private function findSlowCommands(array $metrics, float $threshold): array
{
$slowCommands = [];
foreach ($metrics as $metric) {
$key = $metric->getKey();
// Look for command timing metrics
if (str_contains($key, 'console_command_') && ! str_contains($key, '_error') && ! str_contains($key, '_memory')) {
$duration = $metric->getTotalDuration();
if ($duration >= $threshold) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? $this->extractCommandNameFromKey($key);
$slowCommands[] = [
'command' => $commandName,
'duration' => $duration,
'executions' => $metric->getCount(),
'average_duration' => $metric->getAverageDuration(),
'memory_usage' => $this->getMemoryUsageForCommand($metrics, $commandName),
];
}
}
}
return $slowCommands;
}
private function displaySlowCommandsTable(ConsoleOutputInterface $output, array $slowCommands): void
{
$output->writeLine('┌─────────────────────────────────────────────────────────────────────┐', ConsoleColor::GRAY);
$output->writeLine('│ Command │ Duration │ Executions │ Avg │ Memory │', ConsoleColor::GRAY);
$output->writeLine('├─────────────────────────────────────────────────────────────────────┤', ConsoleColor::GRAY);
foreach ($slowCommands as $command) {
$commandName = str_pad(substr($command['command'], 0, 25), 26);
$duration = str_pad(sprintf('%.0fms', $command['duration']), 8);
$executions = str_pad((string) $command['executions'], 10);
$average = str_pad(sprintf('%.0fms', $command['average_duration']), 6);
$memory = str_pad(sprintf('%.1fMB', $command['memory_usage']), 6);
$color = $command['duration'] > 10000 ? ConsoleColor::RED :
($command['duration'] > 5000 ? ConsoleColor::YELLOW : ConsoleColor::WHITE);
$output->writeLine("{$commandName}{$duration}{$executions}{$average}{$memory}", $color);
}
$output->writeLine('└─────────────────────────────────────────────────────────────────────┘', ConsoleColor::GRAY);
}
private function displayPerformanceRecommendations(ConsoleOutputInterface $output, array $slowCommands): void
{
$output->writeLine('💡 Performance Recommendations', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────────────────', ConsoleColor::GRAY);
$recommendations = [];
foreach ($slowCommands as $command) {
if ($command['duration'] > 30000) {
$recommendations[] = "🔴 {$command['command']}: Consider breaking into smaller operations or adding progress indicators";
} elseif ($command['duration'] > 10000) {
$recommendations[] = "🟡 {$command['command']}: Investigate for optimization opportunities";
}
if ($command['memory_usage'] > 100) {
$recommendations[] = "💾 {$command['command']}: High memory usage - consider streaming or batch processing";
}
}
if (empty($recommendations)) {
$output->writeLine('All commands are performing within acceptable limits! ✅', ConsoleColor::GREEN);
} else {
foreach ($recommendations as $recommendation) {
$output->writeLine(" {$recommendation}", ConsoleColor::WHITE);
}
}
}
private function checkForSlowCommands(ConsoleOutputInterface $output, array $newMetrics, float $threshold): void
{
foreach ($newMetrics as $metric) {
$duration = $metric->getTotalDuration();
if ($duration >= $threshold) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? 'unknown';
$timestamp = date('H:i:s');
$output->writeLine(
sprintf('[%s] 🐌 Slow command detected: %s (%.0fms)', $timestamp, $commandName, $duration),
ConsoleColor::RED
);
}
}
}
private function getCommandMetrics(string $commandName): array
{
$allMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
return array_filter($allMetrics, function ($metric) use ($commandName) {
return str_contains($metric->getKey(), $commandName);
});
}
private function displayProfilingResults(ConsoleOutputInterface $output, string $commandName, array $before, array $after): void
{
$output->writeLine("📈 Profiling Results for: {$commandName}", ConsoleColor::BRIGHT_GREEN);
$output->writeLine('───────────────────────────────────────', ConsoleColor::GRAY);
$newMetrics = array_diff_key($after, $before);
if (empty($newMetrics)) {
$output->writeLine('No new metrics detected for this command execution.', ConsoleColor::YELLOW);
return;
}
foreach ($newMetrics as $metric) {
$output->writeLine(sprintf(' %s: %s', $metric->getKey(), $metric->getFormattedValue()), ConsoleColor::WHITE);
}
}
private function extractCommandNameFromKey(string $key): string
{
if (preg_match('/console_command_(.+)/', $key, $matches)) {
return $matches[1];
}
return 'unknown';
}
private function getMemoryUsageForCommand(array $metrics, string $commandName): float
{
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), "console_memory_usage") &&
str_contains($metric->getKey(), $commandName)) {
return $metric->getValue();
}
}
return 0.0;
}
}

View File

@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\CommandGroupRegistry;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\Screen\ScreenControlCode;
use App\Framework\Console\SimpleWorkflowExecutor;
use App\Framework\Console\TuiView;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
/**
* Main TUI orchestrator - coordinates specialized components
* Refactored to follow Single Responsibility Principle
*/
final readonly class ConsoleTUI
{
public function __construct(
private ConsoleOutputInterface $output,
private Container $container,
private DiscoveryRegistry $discoveryRegistry,
private TuiState $state,
private TuiRenderer $renderer,
private TuiInputHandler $inputHandler,
private TuiCommandExecutor $commandExecutor,
private CommandHistory $commandHistory,
private CommandGroupRegistry $groupRegistry,
private SimpleWorkflowExecutor $workflowExecutor
) {
}
/**
* Run the interactive TUI
*/
public function run(): ExitCode
{
$this->initialize();
$this->mainLoop();
$this->cleanup();
return ExitCode::SUCCESS;
}
/**
* Initialize TUI system
*/
private function initialize(): void
{
$this->setupTerminal();
$this->showWelcomeScreen();
$this->loadCommands();
$this->state->setRunning(true);
}
/**
* Main event loop
*/
private function mainLoop(): void
{
while ($this->state->isRunning()) {
$this->renderCurrentView();
$this->handleUserInput();
}
}
/**
* Cleanup and restore terminal
*/
private function cleanup(): void
{
$this->restoreTerminal();
$this->output->writeLine('');
$this->output->writeLine('👋 Console session ended. Goodbye!');
}
/**
* Setup terminal for interactive mode
*/
private function setupTerminal(): void
{
$this->output->screen->setInteractiveMode();
$this->setRawMode(true);
$this->output->write(CursorControlCode::HIDE->format());
// Welcome message
$this->output->write(ScreenControlCode::CLEAR_ALL->format());
$this->output->write(CursorControlCode::POSITION->format(1, 1));
}
/**
* Restore terminal to normal mode
*/
private function restoreTerminal(): void
{
$this->setRawMode(false);
$this->output->write(CursorControlCode::SHOW->format());
$this->output->screen->setInteractiveMode(false);
}
/**
* Load and categorize all available commands
*/
private function loadCommands(): void
{
// Use the new CommandGroupRegistry for organized commands
$categories = $this->groupRegistry->getOrganizedCommands();
$this->state->setCategories($categories);
// Load available workflows
$workflows = $this->groupRegistry->getWorkflows();
$this->state->setWorkflows($workflows);
}
/**
* Render the current view
*/
private function renderCurrentView(): void
{
$this->renderer->render($this->state, $this->commandHistory);
}
/**
* Handle user input
*/
private function handleUserInput(): void
{
$key = $this->readKey();
if ($key !== '') {
// Handle form mode specially
if ($this->state->getCurrentView() === TuiView::FORM) {
$this->handleFormMode();
return;
}
$this->inputHandler->handleInput($key, $this->state, $this->commandHistory);
}
}
/**
* Handle form mode interaction
*/
private function handleFormMode(): void
{
$command = $this->state->getSelectedCommandForForm();
if ($command) {
$this->commandExecutor->startInteractiveForm($command, $this->state);
}
}
/**
* Read a single key from input (including multi-byte escape sequences)
* Enhanced for PHPStorm terminal compatibility
*/
private function readKey(): string
{
$key = fgetc(STDIN);
if ($key === false) {
return '';
}
// Handle escape sequences (arrow keys, etc.)
if ($key === "\033") {
$sequence = $key;
// Use non-blocking read for better compatibility
$originalBlocking = stream_get_meta_data(STDIN)['blocked'] ?? true;
stream_set_blocking(STDIN, false);
// Read the next character with small timeout
$next = fgetc(STDIN);
if ($next === false) {
// Wait a bit and try again (PHPStorm sometimes needs this)
usleep(10000); // 10ms
$next = fgetc(STDIN);
}
if ($next !== false) {
$sequence .= $next;
// If it's a bracket, read more
if ($next === '[') {
$third = fgetc(STDIN);
if ($third === false) {
usleep(10000); // 10ms
$third = fgetc(STDIN);
}
if ($third !== false) {
$sequence .= $third;
// Some sequences have more characters (like Page Up/Down)
if (in_array($third, ['5', '6', '3', '1', '2', '4'])) {
$fourth = fgetc(STDIN);
if ($fourth === false) {
usleep(5000); // 5ms
$fourth = fgetc(STDIN);
}
if ($fourth !== false) {
$sequence .= $fourth;
}
}
}
}
}
// Restore blocking mode
stream_set_blocking(STDIN, $originalBlocking);
return $sequence;
}
return $key;
}
/**
* Show welcome screen and wait for user input
*/
private function showWelcomeScreen(): void
{
$this->output->write(ScreenControlCode::CLEAR_ALL->format());
$this->output->write(CursorControlCode::POSITION->format(1, 1));
$this->output->writeLine('');
$this->output->writeLine('🚀 Welcome to Interactive Console', ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine('═══════════════════════════════════', ConsoleColor::GRAY);
$this->output->writeLine('');
$this->output->writeLine('Navigate through categories and commands using:');
$this->output->writeLine(' ↑/↓ Arrow Keys - Navigate up and down');
$this->output->writeLine(' Enter - Select/Execute');
$this->output->writeLine(' / - Search commands');
$this->output->writeLine(' R - Command history');
$this->output->writeLine(' Q/Esc - Quit');
$this->output->writeLine('');
$this->output->writeLine('Press any key to continue...', ConsoleColor::YELLOW);
// Wait for user input
$this->readKey();
}
/**
* Set terminal raw mode with PHPStorm compatibility
*/
private function setRawMode(bool $enabled): void
{
if (! function_exists('shell_exec')) {
return;
}
// Detect PHPStorm/JetBrains terminal
$isPhpStorm = getenv('TERMINAL_EMULATOR') === 'JetBrains-JediTerm' ||
! empty(getenv('IDE_PROJECT_ROOTS')) ||
strpos(getenv('TERM') ?: '', 'jetbrains') !== false;
if ($enabled) {
if ($isPhpStorm) {
// PHPStorm-optimized settings
shell_exec('stty raw -echo min 1 time 0 2>/dev/null');
} else {
// Standard terminal settings
shell_exec('stty -icanon -echo 2>/dev/null');
}
} else {
if ($isPhpStorm) {
// PHPStorm-optimized restore
shell_exec('stty -raw echo 2>/dev/null');
} else {
// Standard terminal restore
shell_exec('stty icanon echo 2>/dev/null');
}
}
}
}

View File

@@ -0,0 +1,546 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\CommandHelp;
use App\Framework\Console\CommandHelpGenerator;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\CommandRegistry;
use App\Framework\Console\CommandValidator;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\InteractiveForm;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\ValidationResult;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
/**
* Handles command execution, validation and help for the TUI
*/
final readonly class TuiCommandExecutor
{
public function __construct(
private ConsoleOutputInterface $output,
private CommandRegistry $commandRegistry,
private Container $container,
private DiscoveryRegistry $discoveryRegistry,
private CommandHistory $commandHistory,
private CommandValidator $commandValidator,
private CommandHelpGenerator $helpGenerator,
private string $scriptName = 'console'
) {
}
/**
* Execute the currently selected command
*/
public function executeSelectedCommand(object $command): void
{
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->executeCommand($attribute->name);
}
} else {
// Fallback for legacy objects
$this->executeCommand($command->name ?? '');
}
}
/**
* Execute a command by name
*/
public function executeCommand(string $commandName): void
{
$this->commandHistory->addToHistory($commandName);
$this->exitInteractiveMode();
$this->output->writeLine('');
$this->output->writeLine("Executing: $commandName", ConsoleColor::CYAN);
// Validate command before execution
$this->validateBeforeExecution($commandName);
$this->output->writeLine(str_repeat('=', 50));
try {
$args = [$commandName]; // Remove script name, just pass command arguments
$exitCode = $this->commandRegistry->executeCommand($commandName, $args, $this->output);
if ($exitCode->value === 0) {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('✅ Command completed successfully', ConsoleColor::GREEN);
} else {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('❌ Command failed with exit code: ' . $exitCode->value, ConsoleColor::RED);
}
} catch (\Exception $e) {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('❌ Error: ' . $e->getMessage(), ConsoleColor::RED);
}
$this->waitForKeyPress();
}
/**
* Execute command with parameters from interactive form
*/
public function executeCommandWithParameters(string $commandName, array $parameters): void
{
$this->commandHistory->addToHistory($commandName);
$this->exitInteractiveMode();
$this->output->writeLine('');
$this->output->writeLine("Executing: $commandName", ConsoleColor::CYAN);
// Validate command before execution
$this->validateBeforeExecution($commandName);
if (! empty($parameters)) {
$this->output->writeLine('Parameters:', ConsoleColor::GRAY);
foreach ($parameters as $name => $value) {
$displayValue = is_bool($value) ? ($value ? 'true' : 'false') : $value;
$this->output->writeLine("{$name}: {$displayValue}", ConsoleColor::WHITE);
}
}
$this->output->writeLine(str_repeat('=', 50));
try {
// Create argument array from parameters
$args = [$this->scriptName, $commandName];
foreach ($parameters as $value) {
$args[] = (string) $value;
}
$exitCode = $this->commandRegistry->execute($args);
if ($exitCode->value === 0) {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('✅ Command completed successfully', ConsoleColor::GREEN);
} else {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('❌ Command failed with exit code: ' . $exitCode->value, ConsoleColor::RED);
}
} catch (\Exception $e) {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('❌ Error: ' . $e->getMessage(), ConsoleColor::RED);
}
$this->waitForKeyPress();
}
/**
* Validate the currently selected command
*/
public function validateSelectedCommand(object $command): void
{
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->validateCommand($attribute->name);
}
} else {
// Fallback for legacy objects
$this->validateCommand($command->name ?? '');
}
}
/**
* Validate a command by name
*/
public function validateCommand(string $commandName): void
{
$this->exitInteractiveMode();
$this->output->writeLine('');
$this->output->writeLine("🔍 Validating Command: {$commandName}", ConsoleColor::CYAN);
$this->output->writeLine(str_repeat('=', 50));
try {
$commandObject = $this->findCommandObject($commandName);
if (! $commandObject) {
$this->output->writeLine('❌ Command object not found for validation', ConsoleColor::RED);
} else {
$validationResult = $this->commandValidator->validateCommand($commandObject);
$this->displayValidationResult($validationResult);
// Show additional statistics
$this->output->writeLine('Validation Summary:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" • Total Issues: {$validationResult->getTotalIssueCount()}", ConsoleColor::WHITE);
$this->output->writeLine(" • Errors: " . count($validationResult->getErrors()), ConsoleColor::RED);
$this->output->writeLine(" • Warnings: " . count($validationResult->getWarnings()), ConsoleColor::YELLOW);
$this->output->writeLine(
" • Critical Issues: " . ($validationResult->hasCriticalErrors() ? 'Yes' : 'No'),
$validationResult->hasCriticalErrors() ? ConsoleColor::RED : ConsoleColor::GREEN
);
}
} catch (\Exception $e) {
$this->output->writeLine("❌ Validation failed: {$e->getMessage()}", ConsoleColor::RED);
}
$this->output->writeLine('');
$this->waitForKeyPress();
}
/**
* Show detailed help for the currently selected command
*/
public function showSelectedCommandHelp(object $command): void
{
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->showCommandHelp($attribute->name);
}
} else {
// Fallback for legacy objects
$this->showCommandHelp($command->name ?? '');
}
}
/**
* Show help for a command by name
*/
public function showCommandHelp(string $commandName): void
{
$this->exitInteractiveMode();
$this->output->writeLine('');
$this->output->writeLine("📖 Command Help: {$commandName}", ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine(str_repeat('=', 70));
try {
$commandObject = $this->findCommandObject($commandName);
if (! $commandObject) {
$this->output->writeLine('❌ Command object not found for help generation', ConsoleColor::RED);
$this->output->writeLine('');
$this->output->writeLine('Basic Information:', ConsoleColor::YELLOW);
$this->output->writeLine(" Name: {$commandName}", ConsoleColor::WHITE);
} else {
$help = $this->helpGenerator->generateHelp($commandObject);
$this->displayFormattedHelp($help);
}
} catch (\Exception $e) {
$this->output->writeLine("❌ Help generation failed: {$e->getMessage()}", ConsoleColor::RED);
}
$this->output->writeLine('');
$this->waitForKeyPress();
}
/**
* Show quick help overview for all commands
*/
public function showAllCommandsHelp(): void
{
$this->exitInteractiveMode();
$this->output->writeLine('');
$this->output->writeLine('📚 All Commands Help Overview', ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine(str_repeat('=', 70));
try {
// Get all commands from discovery registry
$commands = $this->discoveryRegistry->getResults(ConsoleCommand::class);
$helpData = [];
foreach ($commands as $commandData) {
$commandObject = $this->container->get($commandData['class']);
$quickHelp = $this->helpGenerator->generateQuickHelp($commandObject);
$helpData[] = [
'name' => $commandData['attribute']->name,
'category' => $this->inferCategory($commandData['attribute']->name),
'summary' => $quickHelp,
];
}
// Group by category and display
$groupedHelp = $this->groupByCategory($helpData);
foreach ($groupedHelp as $category => $commands) {
$this->output->writeLine('');
$this->output->writeLine("📁 {$category}", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(str_repeat('-', 30), ConsoleColor::GRAY);
foreach ($commands as $command) {
$this->output->writeLine(" {$command['name']}", ConsoleColor::BRIGHT_WHITE);
$this->output->writeLine(" {$command['summary']}", ConsoleColor::GRAY);
}
}
$this->output->writeLine('');
$this->output->writeLine('💡 Navigation Tips:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(' • Use H key on any command to see detailed help', ConsoleColor::GRAY);
$this->output->writeLine(' • Use V key to validate command signatures', ConsoleColor::GRAY);
$this->output->writeLine(' • Use Space to open interactive parameter forms', ConsoleColor::GRAY);
$this->output->writeLine(' • Press F1 anytime to see this overview', ConsoleColor::GRAY);
} catch (\Exception $e) {
$this->output->writeLine("❌ Failed to generate help overview: {$e->getMessage()}", ConsoleColor::RED);
}
$this->output->writeLine('');
$this->waitForKeyPress();
}
/**
* Start interactive form for command parameters
*/
public function startInteractiveForm(object $command, TuiState $state): void
{
try {
$form = InteractiveForm::forCommand($command, $this->output);
$parameters = $form->run();
if ($form->isCompleted() && ! empty($parameters)) {
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->executeCommandWithParameters($attribute->name, $parameters);
}
} else {
// Fallback for legacy objects
$this->executeCommandWithParameters($command->name ?? '', $parameters);
}
} else {
$this->output->writeLine('');
$this->output->writeLine('Form cancelled.', ConsoleColor::YELLOW);
$this->waitForKeyPress();
}
} catch (\Exception $e) {
$this->output->writeLine('');
$this->output->writeLine('❌ Error creating form: ' . $e->getMessage(), ConsoleColor::RED);
$this->waitForKeyPress();
}
$state->resetFormState();
}
/**
* Find command object by name
*/
public function findCommandObject(string $commandName): ?object
{
try {
$commands = $this->discoveryRegistry->attributes()->get(ConsoleCommand::class);
foreach ($commands as $commandData) {
// Handle both array and DiscoveredAttribute object formats
$attribute = null;
$className = null;
if (is_array($commandData) && isset($commandData['attribute'])) {
$attribute = $commandData['attribute'];
$className = $commandData['class'] ?? null;
} elseif ($commandData instanceof \App\Framework\Discovery\ValueObjects\DiscoveredAttribute) {
$attribute = $commandData->createAttributeInstance();
$className = $commandData->className;
}
if ($attribute && $attribute->name === $commandName && $className) {
return $this->container->get($className);
}
}
} catch (\Exception) {
return null;
}
return null;
}
/**
* Validate command before execution
*/
private function validateBeforeExecution(string $commandName): void
{
try {
$commandObject = $this->findCommandObject($commandName);
if ($commandObject) {
$validationResult = $this->commandValidator->validateCommand($commandObject);
$this->displayValidationResult($validationResult);
if (! $validationResult->isValid()) {
$this->output->writeLine('❌ Command validation failed. Execution aborted.', ConsoleColor::RED);
$this->waitForKeyPress();
return;
}
}
} catch (\Exception $e) {
$this->output->writeLine("⚠️ Could not validate command: {$e->getMessage()}", ConsoleColor::YELLOW);
}
}
/**
* Display validation result with colored output
*/
private function displayValidationResult(ValidationResult $result): void
{
if ($result->isValid() && empty($result->getWarnings())) {
$this->output->writeLine('✅ Command validation passed', ConsoleColor::GREEN);
return;
}
// Show validation status
if ($result->isValid()) {
$this->output->writeLine('✅ Command validation passed with warnings', ConsoleColor::YELLOW);
} else {
$this->output->writeLine('❌ Command validation failed', ConsoleColor::RED);
}
// Show errors
foreach ($result->getErrors() as $error) {
$severity = strtoupper($error['severity']);
$this->output->writeLine(" ERROR [{$severity}]: {$error['message']}", ConsoleColor::RED);
if (isset($error['suggestion'])) {
$this->output->writeLine(" 💡 {$error['suggestion']}", ConsoleColor::GRAY);
}
}
// Show warnings
foreach ($result->getWarnings() as $warning) {
$severity = strtoupper($warning['severity']);
$this->output->writeLine(" WARN [{$severity}]: {$warning['message']}", ConsoleColor::YELLOW);
if (isset($warning['suggestion'])) {
$this->output->writeLine(" 💡 {$warning['suggestion']}", ConsoleColor::GRAY);
}
}
$this->output->writeLine('');
}
/**
* Display formatted help with colors
*/
private function displayFormattedHelp(CommandHelp $help): void
{
$sections = $help->formatAsColoredText();
foreach ($sections as $section) {
$color = match ($section['color']) {
'BRIGHT_CYAN' => ConsoleColor::BRIGHT_CYAN,
'BRIGHT_YELLOW' => ConsoleColor::BRIGHT_YELLOW,
'BRIGHT_WHITE' => ConsoleColor::BRIGHT_WHITE,
'BRIGHT_GREEN' => ConsoleColor::BRIGHT_GREEN,
'WHITE' => ConsoleColor::WHITE,
'GRAY' => ConsoleColor::GRAY,
'YELLOW' => ConsoleColor::YELLOW,
'RED' => ConsoleColor::RED,
default => ConsoleColor::WHITE
};
$this->output->writeLine($section['text'], $color);
}
// Add interactive elements info
$this->output->writeLine('');
$this->output->writeLine('💡 Interactive Options:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(' • Press Space to open parameter form', ConsoleColor::GRAY);
$this->output->writeLine(' • Press V to validate command signature', ConsoleColor::GRAY);
$this->output->writeLine(' • Press Enter to execute with defaults', ConsoleColor::GRAY);
}
/**
* Exit interactive mode
*/
private function exitInteractiveMode(): void
{
$this->setRawMode(false);
$this->output->write(CursorControlCode::SHOW->format());
$this->output->screen->setInteractiveMode(false);
}
/**
* Wait for key press and return to TUI
*/
private function waitForKeyPress(): void
{
$this->output->writeLine('Press any key to continue...', ConsoleColor::GRAY);
$this->readKey();
$this->enterInteractiveMode();
}
/**
* Enter interactive mode
*/
private function enterInteractiveMode(): void
{
$this->output->screen->setInteractiveMode(true);
$this->setRawMode(true);
$this->output->write(CursorControlCode::HIDE->format());
}
/**
* Read a single key
*/
private function readKey(): string
{
return fgetc(STDIN) ?: '';
}
/**
* Set terminal raw mode
*/
private function setRawMode(bool $enabled): void
{
if (! function_exists('shell_exec')) {
return;
}
if ($enabled) {
shell_exec('stty -icanon -echo');
} else {
shell_exec('stty icanon echo');
}
}
/**
* Infer category from command name
*/
private function inferCategory(string $commandName): string
{
if (str_contains($commandName, ':')) {
return ucfirst(explode(':', $commandName)[0]);
}
return match (true) {
str_starts_with($commandName, 'test') => 'Testing',
str_starts_with($commandName, 'demo') => 'Demo',
str_starts_with($commandName, 'make') => 'Generator',
str_starts_with($commandName, 'db') => 'Database',
str_starts_with($commandName, 'mcp') => 'MCP',
default => 'General'
};
}
/**
* Group help data by category
*/
private function groupByCategory(array $helpData): array
{
$grouped = [];
foreach ($helpData as $item) {
$category = $item['category'];
if (! isset($grouped[$category])) {
$grouped[$category] = [];
}
$grouped[$category][] = $item;
}
return $grouped;
}
}

View File

@@ -0,0 +1,492 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\HistoryTab;
use App\Framework\Console\TuiKeyCode;
use App\Framework\Console\TuiView;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
/**
* Handles all keyboard input and navigation for the TUI
*/
final readonly class TuiInputHandler
{
public function __construct(
private TuiCommandExecutor $commandExecutor
) {
}
/**
* Handle keyboard input based on current view
*/
public function handleInput(string $key, TuiState $state, CommandHistory $history): void
{
// Global shortcuts first
if ($this->handleGlobalShortcuts($key, $state)) {
return;
}
// View-specific handling
match ($state->getCurrentView()) {
TuiView::CATEGORIES => $this->handleCategoriesInput($key, $state),
TuiView::COMMANDS => $this->handleCommandsInput($key, $state),
TuiView::SEARCH => $this->handleSearchInput($key, $state),
TuiView::HISTORY => $this->handleHistoryInput($key, $state, $history),
TuiView::FORM => $this->handleFormInput($key, $state),
TuiView::DASHBOARD => $this->handleDashboardInput($key, $state),
TuiView::HELP => $this->handleHelpInput($key, $state),
};
}
/**
* Handle global shortcuts available in all views
*/
private function handleGlobalShortcuts(string $key, TuiState $state): bool
{
switch ($key) {
case 'q':
case 'Q':
case TuiKeyCode::ESCAPE->value:
$state->setRunning(false);
return true;
case 'd':
case 'D':
$state->setCurrentView(TuiView::DASHBOARD);
return true;
case 'h':
case 'H':
$state->setCurrentView(TuiView::HELP);
return true;
case '/':
case 's':
case 'S':
$state->setCurrentView(TuiView::SEARCH);
$state->resetSearchState();
return true;
case 'r':
case 'R':
$state->setCurrentView(TuiView::HISTORY);
$state->setSelectedHistoryItem(0);
return true;
case TuiKeyCode::F1->value:
$this->commandExecutor->showAllCommandsHelp();
return true;
case 'c':
case 'C':
if ($state->getCurrentView() !== TuiView::CATEGORIES) {
$state->setCurrentView(TuiView::CATEGORIES);
return true;
}
break;
}
return false;
}
/**
* Handle input in categories view
*/
private function handleCategoriesInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
break;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
break;
case TuiKeyCode::ENTER->value:
$state->setCurrentView(TuiView::COMMANDS);
$state->setSelectedCommand(0);
break;
}
}
/**
* Handle input in commands view
*/
private function handleCommandsInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
break;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
break;
case TuiKeyCode::ENTER->value:
$command = $state->getCurrentCommand();
if ($command) {
$this->commandExecutor->executeSelectedCommand($command);
}
break;
case TuiKeyCode::SPACE->value:
$command = $state->getCurrentCommand();
if ($command) {
$state->setSelectedCommandForForm($command);
$state->setCurrentView(TuiView::FORM);
}
break;
case 'v':
case 'V':
$command = $state->getCurrentCommand();
if ($command) {
$this->commandExecutor->validateSelectedCommand($command);
}
break;
case 'h':
case 'H':
$command = $state->getCurrentCommand();
if ($command) {
$this->commandExecutor->showSelectedCommandHelp($command);
}
break;
case TuiKeyCode::ARROW_LEFT->value:
case TuiKeyCode::BACKSPACE->value:
$state->setCurrentView(TuiView::CATEGORIES);
break;
}
}
/**
* Handle input in search view
*/
private function handleSearchInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->setCurrentView(TuiView::CATEGORIES);
$state->resetSearchState();
break;
case TuiKeyCode::ENTER->value:
$command = $state->getCurrentSearchResult();
if ($command) {
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->commandExecutor->executeCommand($attribute->name);
}
} else {
$this->commandExecutor->executeCommand($command->name);
}
}
break;
case TuiKeyCode::SPACE->value:
$command = $state->getCurrentSearchResult();
if ($command) {
$state->setSelectedCommandForForm($command);
$state->setCurrentView(TuiView::FORM);
}
break;
case 'h':
case 'H':
$command = $state->getCurrentSearchResult();
if ($command) {
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->commandExecutor->showCommandHelp($attribute->name);
}
} else {
$this->commandExecutor->showCommandHelp($command->name);
}
}
break;
case 'v':
case 'V':
$command = $state->getCurrentSearchResult();
if ($command) {
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->commandExecutor->validateCommand($attribute->name);
}
} else {
$this->commandExecutor->validateCommand($command->name);
}
}
break;
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
break;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
break;
case TuiKeyCode::BACKSPACE->value:
case "\x7f": // DEL key
$query = $state->getSearchQuery();
if (strlen($query) > 0) {
$newQuery = substr($query, 0, -1);
$state->setSearchQuery($newQuery);
$this->updateSearchResults($state);
}
break;
default:
// Add character to search query
if (strlen($key) === 1 && ctype_print($key)) {
$newQuery = $state->getSearchQuery() . $key;
$state->setSearchQuery($newQuery);
$this->updateSearchResults($state);
}
break;
}
}
/**
* Handle input in history view
*/
private function handleHistoryInput(string $key, TuiState $state, CommandHistory $history): void
{
switch ($key) {
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
break;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
break;
case TuiKeyCode::ARROW_LEFT->value:
$this->switchHistoryTab($state, -1);
break;
case TuiKeyCode::ARROW_RIGHT->value:
$this->switchHistoryTab($state, 1);
break;
case TuiKeyCode::ENTER->value:
$items = $this->getHistoryItems($state, $history);
if (! empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$command = $items[$state->getSelectedHistoryItem()]['command'];
$history->addToHistory($command);
$this->commandExecutor->executeCommand($command);
}
break;
case TuiKeyCode::SPACE->value:
$items = $this->getHistoryItems($state, $history);
if (! empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$commandName = $items[$state->getSelectedHistoryItem()]['command'];
$commandObject = $this->commandExecutor->findCommandObject($commandName);
if ($commandObject) {
$state->setSelectedCommandForForm($commandObject);
$state->setCurrentView(TuiView::FORM);
}
}
break;
case 'h':
case 'H':
$items = $this->getHistoryItems($state, $history);
if (! empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$commandName = $items[$state->getSelectedHistoryItem()]['command'];
$this->commandExecutor->showCommandHelp($commandName);
}
break;
case 'v':
case 'V':
$items = $this->getHistoryItems($state, $history);
if (! empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$commandName = $items[$state->getSelectedHistoryItem()]['command'];
$this->commandExecutor->validateCommand($commandName);
}
break;
case 'f':
case 'F':
$items = $this->getHistoryItems($state, $history);
if (! empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$command = $items[$state->getSelectedHistoryItem()]['command'];
$history->toggleFavorite($command);
}
break;
case 'c':
case 'C':
if ($state->getHistoryTab() === HistoryTab::FAVORITES) {
$history->clearFavorites();
} else {
$history->clearHistory();
}
break;
case TuiKeyCode::ESCAPE->value:
case TuiKeyCode::BACKSPACE->value:
$state->setCurrentView(TuiView::CATEGORIES);
break;
}
}
/**
* Handle input in form view
*/
private function handleFormInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->resetFormState();
$state->setCurrentView(TuiView::CATEGORIES);
break;
}
}
/**
* Handle input in dashboard view
*/
private function handleDashboardInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->setCurrentView(TuiView::CATEGORIES);
break;
}
}
/**
* Handle input in help view
*/
private function handleHelpInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->setCurrentView(TuiView::CATEGORIES);
break;
}
}
/**
* Update search results based on current query
*/
private function updateSearchResults(TuiState $state): void
{
$query = strtolower($state->getSearchQuery());
if (empty($query)) {
$state->setSearchResults([]);
return;
}
$results = [];
$categories = $state->getCategories();
foreach ($categories as $category) {
foreach ($category['commands'] as $command) {
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute === null) {
continue;
}
$commandName = strtolower($attribute->name);
$commandDescription = strtolower($attribute->description ?? '');
} else {
// Fallback for legacy objects
$commandName = strtolower($command->name ?? '');
$commandDescription = strtolower($command->description ?? '');
}
if (str_contains($commandName, $query) || str_contains($commandDescription, $query)) {
$results[] = $command;
}
}
}
$state->setSearchResults($results);
}
/**
* Switch between history tabs
*/
private function switchHistoryTab(TuiState $state, int $direction): void
{
$tabs = [HistoryTab::RECENT, HistoryTab::FREQUENT, HistoryTab::FAVORITES];
$currentIndex = array_search($state->getHistoryTab(), $tabs);
$newIndex = ($currentIndex + $direction + count($tabs)) % count($tabs);
$state->setHistoryTab($tabs[$newIndex]);
}
/**
* Get history items based on current tab
*/
private function getHistoryItems(TuiState $state, CommandHistory $history): array
{
return match ($state->getHistoryTab()) {
HistoryTab::RECENT => $history->getRecentHistory(10),
HistoryTab::FREQUENT => $history->getFrequentCommands(10),
HistoryTab::FAVORITES => $history->getFavorites(),
};
}
}

View File

@@ -0,0 +1,446 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\HistoryTab;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\Screen\ScreenControlCode;
use App\Framework\Console\TuiView;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
/**
* Handles all rendering and display logic for the TUI
*/
final readonly class TuiRenderer
{
public function __construct(
private ConsoleOutputInterface $output
) {
}
/**
* Render the current view based on state
*/
public function render(TuiState $state, CommandHistory $history): void
{
$this->clearScreen();
$this->renderHeader();
match ($state->getCurrentView()) {
TuiView::CATEGORIES => $this->renderCategories($state),
TuiView::COMMANDS => $this->renderCommands($state),
TuiView::SEARCH => $this->renderSearch($state),
TuiView::HISTORY => $this->renderHistory($state, $history),
TuiView::FORM => $this->renderForm($state),
TuiView::DASHBOARD => $this->renderDashboard($state),
TuiView::HELP => $this->renderHelp($state),
};
}
/**
* Clear screen and reset cursor
*/
private function clearScreen(): void
{
$this->output->write(ScreenControlCode::CLEAR_ALL->format());
$this->output->write(CursorControlCode::POSITION->format(1, 1));
}
/**
* Render header with title
*/
private function renderHeader(): void
{
$this->output->writeLine('');
$this->output->writeLine('🚀 Interactive Console - Modern TUI', ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine(str_repeat('═', 60), ConsoleColor::GRAY);
$this->output->writeLine('');
}
/**
* Render categories view
*/
private function renderCategories(TuiState $state): void
{
$this->output->writeLine('📂 Select Category:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
$categories = $state->getCategories();
foreach ($categories as $index => $category) {
$isSelected = $index === $state->getSelectedCategory();
$this->renderCategoryItem($category, $isSelected);
}
$this->output->newLine();
$this->renderNavigationBar([
"↑/↓: Navigate",
"Enter: Select",
"/: Search",
"R: History",
"D: Dashboard",
"F1: Help",
"Q: Quit",
]);
}
/**
* Render a category item
*/
private function renderCategoryItem(array $category, bool $isSelected): void
{
$icon = $category['icon'] ?? '📁';
$name = $category['name'];
$count = count($category['commands']);
$prefix = $isSelected ? '▶ ' : ' ';
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
$this->output->writeLine(
"{$prefix}{$icon} {$name} ({$count} commands)",
$color
);
}
/**
* Render commands view
*/
private function renderCommands(TuiState $state): void
{
$category = $state->getCurrentCategory();
if (! $category) {
return;
}
$icon = $category['icon'] ?? '📁';
$this->output->writeLine("📂 {$icon} {$category['name']} Commands:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
foreach ($category['commands'] as $index => $command) {
$isSelected = $index === $state->getSelectedCommand();
$this->renderCommandItem($command, $isSelected);
}
$this->output->newLine();
$this->renderNavigationBar([
"↑/↓: Navigate",
"Enter: Execute",
"Space: Parameters",
"H: Help",
"V: Validate",
"/: Search",
"R: History",
"←: Back",
"Q: Quit",
]);
}
/**
* Render a command item
*/
private function renderCommandItem(object $command, bool $isSelected): void
{
$prefix = $isSelected ? '▶ ' : ' ';
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute === null) {
return;
}
$commandName = $attribute->name;
$commandDescription = $attribute->description ?? '';
} else {
// Fallback for legacy objects
$commandName = $command->name ?? 'Unknown Command';
$commandDescription = $command->description ?? '';
}
$this->output->writeLine("{$prefix}{$commandName}", $color);
if (! empty($commandDescription)) {
$descColor = ConsoleColor::GRAY;
$this->output->writeLine(" {$commandDescription}", $descColor);
}
}
/**
* Render search view
*/
private function renderSearch(TuiState $state): void
{
$this->output->writeLine('🔍 Search Commands:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
// Search box
$query = $state->getSearchQuery();
$this->output->writeLine("Search: {$query}_", ConsoleColor::BRIGHT_WHITE);
$this->output->writeLine('');
// Results
$results = $state->getSearchResults();
if (empty($results)) {
if (! empty($query)) {
$this->output->writeLine('No commands found matching your search.', ConsoleColor::YELLOW);
} else {
$this->output->writeLine('Start typing to search commands...', ConsoleColor::GRAY);
}
} else {
$resultCount = count($results);
$this->output->writeLine("Found {$resultCount} result(s):", ConsoleColor::WHITE);
$this->output->writeLine('');
foreach ($results as $index => $command) {
$isSelected = $index === $state->getSelectedSearchResult();
$this->renderCommandItem($command, $isSelected);
}
$current = $state->getSelectedSearchResult() + 1;
$total = count($results);
if ($total > 1) {
$this->output->writeLine('');
$this->output->writeLine(" Showing result $current of $total", ConsoleColor::GRAY);
}
}
// Navigation shortcuts
$shortcuts = [
'Type: Search',
'Enter: Execute',
'Space: Parameters',
'H: Help',
'V: Validate',
'R: History',
'Esc: Back',
'D: Dashboard',
'Q: Exit',
];
$this->renderNavigationBar($shortcuts);
}
/**
* Render history view
*/
private function renderHistory(TuiState $state, CommandHistory $history): void
{
$this->output->writeLine('📚 Command History:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
// Tab navigation
$this->renderHistoryTabs($state->getHistoryTab());
// History items
$items = $this->getHistoryItems($state->getHistoryTab(), $history);
if (empty($items)) {
$this->output->writeLine('No history available.', ConsoleColor::YELLOW);
} else {
foreach ($items as $index => $item) {
$isSelected = $index === $state->getSelectedHistoryItem();
$this->renderHistoryItem($item, $isSelected, $history);
}
}
$this->output->writeLine('');
$this->renderNavigationHelp([
"↑/↓: Navigate",
"←/→: Switch tabs",
"Enter: Execute command",
"Space: Parameters",
"H: Help",
"V: Validate",
"F: Toggle favorite",
"C: Clear history",
"/: Search",
"Q: Quit",
]);
}
/**
* Render history tabs
*/
private function renderHistoryTabs(HistoryTab $activeTab): void
{
$tabs = [
HistoryTab::RECENT,
HistoryTab::FREQUENT,
HistoryTab::FAVORITES,
];
$tabDisplay = [];
foreach ($tabs as $tab) {
$isActive = $tab === $activeTab;
$color = $isActive ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::GRAY;
$prefix = $isActive ? '[' : ' ';
$suffix = $isActive ? ']' : ' ';
$tabDisplay[] = [
'text' => "{$prefix}{$tab->getIcon()} {$tab->getTitle()}{$suffix}",
'color' => $color,
];
}
foreach ($tabDisplay as $tab) {
$this->output->write($tab['text'], $tab['color']);
}
$this->output->writeLine('');
$this->output->writeLine('');
}
/**
* Render history item
*/
private function renderHistoryItem(array $item, bool $isSelected, CommandHistory $history): void
{
$prefix = $isSelected ? '▶ ' : ' ';
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
$command = $item['command'];
$isFavorite = $history->isFavorite($command);
$favoriteIcon = $isFavorite ? '⭐' : ' ';
$this->output->writeLine("{$prefix}{$favoriteIcon}{$command}", $color);
// Additional info based on type
$infoColor = ConsoleColor::GRAY;
if (isset($item['count'])) {
$this->output->writeLine(" Used {$item['count']} times", $infoColor);
}
if (isset($item['timestamp'])) {
$timeAgo = $this->timeAgo($item['timestamp']);
$this->output->writeLine(" {$timeAgo}", $infoColor);
}
}
/**
* Render form view placeholder
*/
private function renderForm(TuiState $state): void
{
$this->output->writeLine('📝 Interactive Form', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('Form functionality handled by InteractiveForm class', ConsoleColor::GRAY);
}
/**
* Render dashboard view
*/
private function renderDashboard(TuiState $state): void
{
$this->output->writeLine('📊 System Dashboard', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
$this->output->writeLine('Console system status and metrics', ConsoleColor::WHITE);
$this->output->writeLine('');
$this->renderNavigationBar([
"Categories: C",
"Search: /",
"History: R",
"Help: F1",
"Back: Esc",
"Quit: Q",
]);
}
/**
* Render help view
*/
private function renderHelp(TuiState $state): void
{
$this->output->writeLine('❓ Help & Shortcuts', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
$helpSections = [
'Navigation' => [
'↑/↓ Arrow Keys' => 'Navigate up and down in lists',
'←/→ Arrow Keys' => 'Navigate left/right, switch tabs',
'Enter' => 'Execute selected command',
'Esc' => 'Go back to previous view',
],
'Commands' => [
'Space' => 'Open interactive parameter form',
'H' => 'Show detailed command help',
'V' => 'Validate command signature',
'F1' => 'Show all commands help overview',
],
'Views' => [
'/' => 'Search commands',
'R' => 'Command history',
'D' => 'Dashboard',
'C' => 'Categories (from other views)',
],
'History' => [
'F' => 'Toggle command as favorite',
'C' => 'Clear command history',
],
];
foreach ($helpSections as $section => $items) {
$this->output->writeLine($section . ':', ConsoleColor::BRIGHT_WHITE);
foreach ($items as $key => $description) {
$this->output->writeLine(" {$key} - {$description}", ConsoleColor::WHITE);
}
$this->output->writeLine('');
}
$this->renderNavigationBar([
"Back: Esc",
"Categories: C",
"Quit: Q",
]);
}
/**
* Render navigation bar
*/
private function renderNavigationBar(array $items): void
{
$this->output->writeLine(str_repeat('─', 60), ConsoleColor::GRAY);
$this->output->writeLine(implode(' | ', $items), ConsoleColor::BRIGHT_BLUE);
}
/**
* Render navigation help
*/
private function renderNavigationHelp(array $items): void
{
$this->output->writeLine(str_repeat('─', 60), ConsoleColor::GRAY);
$this->output->writeLine(implode(' | ', $items), ConsoleColor::BRIGHT_BLUE);
}
/**
* Get history items based on tab
*/
private function getHistoryItems(HistoryTab $tab, CommandHistory $history): array
{
return match ($tab) {
HistoryTab::RECENT => $history->getRecentHistory(10),
HistoryTab::FREQUENT => $history->getFrequentCommands(10),
HistoryTab::FAVORITES => $history->getFavorites(),
};
}
/**
* Calculate time ago
*/
private function timeAgo(int $timestamp): string
{
$diff = time() - $timestamp;
if ($diff < 60) {
return "{$diff}s ago";
}
if ($diff < 3600) {
return floor($diff / 60) . "m ago";
}
if ($diff < 86400) {
return floor($diff / 3600) . "h ago";
}
return floor($diff / 86400) . "d ago";
}
}

View File

@@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\HistoryTab;
use App\Framework\Console\TuiView;
/**
* Manages the state of the Terminal User Interface
*/
final class TuiState
{
private array $categories = [];
private int $selectedCategory = 0;
private int $selectedCommand = 0;
private TuiView $currentView = TuiView::CATEGORIES;
private bool $running = false;
// Search state
private string $searchQuery = '';
private array $searchResults = [];
private int $selectedSearchResult = 0;
// History state
private int $selectedHistoryItem = 0;
private HistoryTab $historyTab = HistoryTab::RECENT;
// Form state
private ?object $selectedCommandForForm = null;
private bool $formMode = false;
public function __construct()
{
}
// Categories
public function setCategories(array $categories): void
{
$this->categories = $categories;
}
public function getCategories(): array
{
return $this->categories;
}
public function getSelectedCategory(): int
{
return $this->selectedCategory;
}
public function setSelectedCategory(int $index): void
{
$this->selectedCategory = max(0, min($index, count($this->categories) - 1));
}
public function getCurrentCategory(): ?array
{
return $this->categories[$this->selectedCategory] ?? null;
}
// Commands
public function getSelectedCommand(): int
{
return $this->selectedCommand;
}
public function setSelectedCommand(int $index): void
{
$category = $this->getCurrentCategory();
if ($category) {
$maxIndex = count($category['commands']) - 1;
$this->selectedCommand = max(0, min($index, $maxIndex));
}
}
public function getCurrentCommand(): ?object
{
$category = $this->getCurrentCategory();
return $category['commands'][$this->selectedCommand] ?? null;
}
// View management
public function getCurrentView(): TuiView
{
return $this->currentView;
}
public function setCurrentView(TuiView $view): void
{
$this->currentView = $view;
}
public function isRunning(): bool
{
return $this->running;
}
public function setRunning(bool $running): void
{
$this->running = $running;
}
// Search
public function getSearchQuery(): string
{
return $this->searchQuery;
}
public function setSearchQuery(string $query): void
{
$this->searchQuery = $query;
}
public function getSearchResults(): array
{
return $this->searchResults;
}
public function setSearchResults(array $results): void
{
$this->searchResults = $results;
$this->selectedSearchResult = 0;
}
public function getSelectedSearchResult(): int
{
return $this->selectedSearchResult;
}
public function setSelectedSearchResult(int $index): void
{
$this->selectedSearchResult = max(0, min($index, count($this->searchResults) - 1));
}
public function getCurrentSearchResult(): ?object
{
return $this->searchResults[$this->selectedSearchResult] ?? null;
}
// History
public function getSelectedHistoryItem(): int
{
return $this->selectedHistoryItem;
}
public function setSelectedHistoryItem(int $index): void
{
$this->selectedHistoryItem = max(0, $index);
}
public function getHistoryTab(): HistoryTab
{
return $this->historyTab;
}
public function setHistoryTab(HistoryTab $tab): void
{
$this->historyTab = $tab;
$this->selectedHistoryItem = 0;
}
// Form
public function getSelectedCommandForForm(): ?object
{
return $this->selectedCommandForForm;
}
public function setSelectedCommandForForm(?object $command): void
{
$this->selectedCommandForForm = $command;
}
public function isFormMode(): bool
{
return $this->formMode;
}
public function setFormMode(bool $formMode): void
{
$this->formMode = $formMode;
}
// Navigation helpers
public function navigateUp(): void
{
match ($this->currentView) {
TuiView::CATEGORIES => $this->setSelectedCategory($this->selectedCategory - 1),
TuiView::COMMANDS => $this->setSelectedCommand($this->selectedCommand - 1),
TuiView::SEARCH => $this->setSelectedSearchResult($this->selectedSearchResult - 1),
TuiView::HISTORY => $this->setSelectedHistoryItem($this->selectedHistoryItem - 1),
default => null
};
}
public function navigateDown(): void
{
match ($this->currentView) {
TuiView::CATEGORIES => $this->setSelectedCategory($this->selectedCategory + 1),
TuiView::COMMANDS => $this->setSelectedCommand($this->selectedCommand + 1),
TuiView::SEARCH => $this->setSelectedSearchResult($this->selectedSearchResult + 1),
TuiView::HISTORY => $this->setSelectedHistoryItem($this->selectedHistoryItem + 1),
default => null
};
}
public function resetSearchState(): void
{
$this->searchQuery = '';
$this->searchResults = [];
$this->selectedSearchResult = 0;
}
public function resetFormState(): void
{
$this->selectedCommandForForm = null;
$this->formMode = false;
}
// Workflow-related state
private array $workflows = [];
private int $selectedWorkflow = 0;
private bool $showWorkflows = false;
private array $workflowProgress = [];
/**
* Set available workflows
*/
public function setWorkflows(array $workflows): void
{
$this->workflows = $workflows;
}
/**
* Get available workflows
*/
public function getWorkflows(): array
{
return $this->workflows;
}
/**
* Set selected workflow
*/
public function setSelectedWorkflow(int $index): void
{
$this->selectedWorkflow = max(0, min($index, count($this->workflows) - 1));
}
/**
* Get selected workflow
*/
public function getSelectedWorkflow(): int
{
return $this->selectedWorkflow;
}
/**
* Get current workflow
*/
public function getCurrentWorkflow(): ?array
{
return $this->workflows[$this->selectedWorkflow] ?? null;
}
/**
* Set workflow visibility
*/
public function setShowWorkflows(bool $show): void
{
$this->showWorkflows = $show;
}
/**
* Check if workflows should be shown
*/
public function shouldShowWorkflows(): bool
{
return $this->showWorkflows;
}
/**
* Set workflow progress
*/
public function setWorkflowProgress(string $workflowName, array $progress): void
{
$this->workflowProgress[$workflowName] = $progress;
}
/**
* Get workflow progress
*/
public function getWorkflowProgress(string $workflowName): array
{
return $this->workflowProgress[$workflowName] ?? [];
}
/**
* Reset workflow state
*/
public function resetWorkflowState(): void
{
$this->selectedWorkflow = 0;
$this->showWorkflows = false;
$this->workflowProgress = [];
}
}

View File

@@ -5,12 +5,21 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Config\AppConfig;
use App\Framework\Console\Components\InteractiveMenu;
use App\Framework\Console\Components\ConsoleTUI;
use App\Framework\Console\Components\TuiCommandExecutor;
use App\Framework\Console\Components\TuiInputHandler;
use App\Framework\Console\Components\TuiRenderer;
use App\Framework\Console\Components\TuiState;
use App\Framework\Console\ErrorRecovery\CommandSuggestionEngine;
use App\Framework\Console\ErrorRecovery\ConsoleErrorHandler;
use App\Framework\Console\ErrorRecovery\ErrorRecoveryService;
use App\Framework\Console\Exceptions\CommandNotFoundException;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use Throwable;
final class ConsoleApplication
@@ -21,6 +30,8 @@ final class ConsoleApplication
private CommandRegistry $commandRegistry;
private ConsoleErrorHandler $errorHandler;
public function __construct(
private readonly Container $container,
private readonly string $scriptName = 'console',
@@ -37,6 +48,7 @@ final class ConsoleApplication
try {
$this->initializeCommandRegistry();
$this->initializeErrorHandler();
} catch (Throwable $e) {
// Log the original error for debugging
error_log("Console initialization failed: " . $e->getMessage());
@@ -49,6 +61,22 @@ final class ConsoleApplication
}
}
private function initializeErrorHandler(): void
{
// Create error recovery components
$suggestionEngine = new CommandSuggestionEngine($this->commandRegistry->getCommandList());
$recoveryService = new ErrorRecoveryService(
$suggestionEngine,
$this->commandRegistry->getCommandList(),
$this->commandRegistry
);
// Get logger if available
$logger = $this->container->has(Logger::class) ? $this->container->get(Logger::class) : null;
$this->errorHandler = new ConsoleErrorHandler($recoveryService, $logger);
}
private function setupSignalHandlers(): void
{
if (function_exists('pcntl_signal')) {
@@ -83,7 +111,12 @@ final class ConsoleApplication
// Fallback: Force fresh discovery if no commands found
if ($this->commandRegistry->getCommandList()->count() === 0) {
error_log("ConsoleApplication: No commands found, forcing fresh discovery...");
if ($this->container->has(Logger::class)) {
$logger = $this->container->get(Logger::class);
$logger->warning('No commands found, forcing fresh discovery...', LogContext::withData([
'component' => 'ConsoleApplication',
]));
}
// Force fresh discovery
$bootstrapper = new \App\Framework\Discovery\DiscoveryServiceBootstrapper(
@@ -103,8 +136,14 @@ final class ConsoleApplication
// Re-initialize command registry with fresh discovery
$this->commandRegistry = new CommandRegistry($this->container, $freshRegistry);
error_log("ConsoleApplication: Fresh discovery completed, commands found: " .
count($freshRegistry->attributes->get(\App\Framework\Console\ConsoleCommand::class)));
if ($this->container->has(Logger::class)) {
$logger = $this->container->get(Logger::class);
$commandCount = count($freshRegistry->attributes->get(\App\Framework\Console\ConsoleCommand::class));
$logger->info('Fresh discovery completed', LogContext::withData([
'commands_found' => $commandCount,
'component' => 'ConsoleApplication',
]));
}
}
}
@@ -128,32 +167,47 @@ final class ConsoleApplication
}
if (count($argv) < 2) {
$this->showHelp();
return ExitCode::SUCCESS->value;
// Launch interactive TUI when no arguments provided
return $this->launchInteractiveTUI();
}
$commandName = $argv[1];
$arguments = array_slice($argv, 2);
// Handle TUI launch flags
if (in_array($commandName, ['--interactive', '--tui', '-i'])) {
return $this->launchInteractiveTUI();
}
// Handle built-in commands
if (in_array($commandName, ['help', '--help', '-h'])) {
$this->showHelp();
// Spezifische Command-Hilfe
if (! empty($arguments) && isset($arguments[0])) {
$this->showCommandHelp($arguments[0]);
} else {
$this->showHelp();
}
return ExitCode::SUCCESS->value;
}
$commandList = $this->commandRegistry->getCommandList();
if (! $commandList->has($commandName)) {
$this->output->writeError("Kommando '{$commandName}' nicht gefunden.");
$this->suggestSimilarCommands($commandName);
$this->showHelp();
return ExitCode::COMMAND_NOT_FOUND->value;
// Prüfe ob es ein direktes Kommando ist
if ($commandList->has($commandName)) {
return $this->executeCommand($commandName, $arguments)->value;
}
return $this->executeCommand($commandName, $arguments)->value;
// Prüfe ob es eine Kategorie ist
$categories = $this->categorizeCommands($commandList);
if (isset($categories[$commandName])) {
$this->showCategoryCommands($commandName, $categories[$commandName]);
return ExitCode::SUCCESS->value;
}
// Command/Kategorie nicht gefunden
return $this->errorHandler->handleCommandNotFound($commandName, $this->output)->value;
} catch (Throwable $e) {
$this->output->writeError("Critical error: " . $e->getMessage());
@@ -196,20 +250,6 @@ final class ConsoleApplication
}, $argv);
}
private function suggestSimilarCommands(string $commandName): void
{
$commandList = $this->commandRegistry->getCommandList();
$suggestions = $commandList->findSimilar($commandName);
if (! empty($suggestions)) {
$this->output->writeLine("Meinten Sie vielleicht:", ConsoleColor::CYAN);
foreach ($suggestions as $suggestion) {
$this->output->writeLine(" {$suggestion}");
}
$this->output->newLine();
}
}
/**
* @param array<int, string> $arguments
*/
@@ -232,45 +272,19 @@ final class ConsoleApplication
return $this->commandRegistry->executeCommand($commandName, $arguments, $this->output);
} catch (CommandNotFoundException $e) {
$this->output->writeError("Kommando nicht gefunden: " . $e->getMessage());
return ExitCode::COMMAND_NOT_FOUND;
return $this->errorHandler->handleCommandNotFound($commandName, $this->output);
} catch (FrameworkException $e) {
$this->output->writeError("Framework error: " . $e->getMessage());
// Handle specific framework error codes
return match($e->getErrorCode()) {
ErrorCode::VAL_INVALID_INPUT => ExitCode::INVALID_INPUT,
ErrorCode::AUTH_UNAUTHORIZED => ExitCode::PERMISSION_DENIED,
ErrorCode::CON_INVALID_COMMAND_STRUCTURE => ExitCode::SOFTWARE_ERROR,
ErrorCode::CON_COMMAND_EXECUTION_FAILED => ExitCode::SOFTWARE_ERROR,
default => ExitCode::GENERAL_ERROR
};
return $this->errorHandler->handleCommandExecutionError($commandName, $e, $this->output);
} catch (\InvalidArgumentException $e) {
$this->output->writeError("Invalid arguments: " . $e->getMessage());
$this->showCommandUsage($commandName);
return ExitCode::INVALID_INPUT;
return $this->errorHandler->handleValidationError($commandName, $e->getMessage(), $this->output);
} catch (\RuntimeException $e) {
$this->output->writeError("Runtime error: " . $e->getMessage());
return ExitCode::SOFTWARE_ERROR;
return $this->errorHandler->handleCommandExecutionError($commandName, $e, $this->output);
} catch (Throwable $e) {
$this->output->writeError("Unexpected error: " . $e->getMessage());
$config = $this->container->get(AppConfig::class);
// In development, show stack trace
if ($config->isDevelopment()) {
$this->output->writeLine("Stack trace:", ConsoleColor::RED);
$this->output->writeLine($e->getTraceAsString());
}
return ExitCode::GENERAL_ERROR;
return $this->errorHandler->handleUnexpectedError($commandName, $e, $this->output);
} finally {
// Reset window title after command execution
$this->output->writeWindowTitle($this->title);
@@ -302,30 +316,285 @@ final class ConsoleApplication
private function showHelp(): void
{
$this->output->writeLine("Verfügbare Kommandos:", ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine("Console Commands", ConsoleColor::BRIGHT_CYAN);
$this->output->newLine();
$commandList = $this->commandRegistry->getCommandList();
if ($commandList->isEmpty()) {
$this->output->writeLine(" Keine Kommandos verfügbar.", ConsoleColor::YELLOW);
} else {
$menu = new InteractiveMenu($this->output);
$menu->setTitle("Kommandos");
foreach ($commandList as $command) {
$description = $command->description ?: 'Keine Beschreibung verfügbar';
return;
}
$menu->addItem($command->name, function () use ($command) {
return $this->executeCommand($command->name, [])->value;
}, $description);
// Kategorisiere Commands
$categories = $this->categorizeCommands($commandList);
// Zeige Kategorien-Übersicht
$this->showCategoryOverview($categories);
$this->output->newLine();
$this->output->writeLine("Verwendung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" php {$this->scriptName} # Interaktive TUI starten");
$this->output->writeLine(" php {$this->scriptName} --interactive # Interaktive TUI explizit starten");
$this->output->writeLine(" php {$this->scriptName} <kategorie> # Commands einer Kategorie anzeigen");
$this->output->writeLine(" php {$this->scriptName} <kommando> [argumente] # Kommando direkt ausführen");
$this->output->writeLine(" php {$this->scriptName} help <kommando> # Hilfe für spezifisches Kommando");
$this->output->newLine();
$this->output->writeLine("Hinweis:", ConsoleColor::CYAN);
$this->output->writeLine(" Ohne Argumente wird automatisch die interaktive TUI gestartet.");
$this->output->writeLine(" Die TUI bietet eine grafische Navigation durch alle verfügbaren Commands.");
}
/**
* Kategorisiert Commands basierend auf ihrem Präfix
*/
private function categorizeCommands(CommandList $commandList): array
{
$categories = [];
foreach ($commandList as $command) {
$parts = explode(':', $command->name);
$category = $parts[0];
if (! isset($categories[$category])) {
$categories[$category] = [];
}
$this->output->writeLine(" " . $menu->showInteractive());
$categories[$category][] = $command;
}
// Sortiere Kategorien
ksort($categories);
return $categories;
}
/**
* Zeigt eine übersichtliche Kategorien-Übersicht
*/
private function showCategoryOverview(array $categories): void
{
$this->output->writeLine("Verfügbare Kategorien:", ConsoleColor::BRIGHT_YELLOW);
$this->output->newLine();
$categoryInfo = [
'db' => 'Database operations (migrations, health checks)',
'errors' => 'Error management and analytics',
'backup' => 'Backup and restore operations',
'secrets' => 'Secret management and encryption',
'cache' => 'Cache management operations',
'demo' => 'Demo and example commands',
'logs' => 'Log management and rotation',
'alerts' => 'Alert system management',
'mcp' => 'Model Context Protocol server',
'make' => 'Code generation commands',
'docs' => 'Documentation generation',
'openapi' => 'OpenAPI specification generation',
'static' => 'Static file generation',
'redis' => 'Redis cache operations',
'routes' => 'Route management',
'discovery' => 'Framework discovery system',
];
foreach ($categories as $category => $commands) {
$count = count($commands);
$description = $categoryInfo[$category] ?? 'Various commands';
$categoryName = str_pad($category, 12);
$commandCount = str_pad("({$count} commands)", 15);
$this->output->writeLine(
" {$categoryName} {$commandCount} {$description}",
ConsoleColor::WHITE
);
}
$this->output->newLine();
$this->output->writeLine("Für Commands einer Kategorie: php {$this->scriptName} <kategorie>", ConsoleColor::CYAN);
}
/**
* Zeigt Commands einer spezifischen Kategorie
*/
private function showCategoryCommands(string $category, array $commands): void
{
$this->output->writeLine("Commands der Kategorie '{$category}':", ConsoleColor::BRIGHT_CYAN);
$this->output->newLine();
foreach ($commands as $command) {
$description = $command->description ?: 'Keine Beschreibung verfügbar';
$commandName = str_pad($command->name, 25);
$this->output->writeLine(" {$commandName} {$description}", ConsoleColor::WHITE);
}
$this->output->newLine();
$this->output->writeLine("Verwendung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" php {$this->scriptName} <kommando> [argumente]");
$this->output->writeLine(" php {$this->scriptName} help <kommando> # Für detaillierte Hilfe");
}
/**
* Zeigt detaillierte Hilfe für ein spezifisches Kommando
*/
private function showCommandHelp(string $commandName): void
{
$commandList = $this->commandRegistry->getCommandList();
if (! $commandList->has($commandName)) {
$this->errorHandler->handleCommandNotFound($commandName, $this->output);
return;
}
$command = $commandList->get($commandName);
$this->output->writeLine("Kommando: {$command->name}", ConsoleColor::BRIGHT_CYAN);
$this->output->newLine();
if ($command->description) {
$this->output->writeLine("Beschreibung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" {$command->description}");
$this->output->newLine();
}
// Versuche Parameter-Informationen anzuzeigen
try {
$this->showCommandParameters($command);
} catch (Throwable $e) {
// Fallback zu Standard-Verwendung
$this->output->writeLine("Verwendung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" php {$this->scriptName} {$command->name} [argumente]");
}
}
/**
* Zeigt Parameter-Informationen für ein Kommando
*/
private function showCommandParameters(ConsoleCommand $command): void
{
try {
// Hole die DiscoveredAttribute für das Command
$discoveredAttribute = $this->commandRegistry->getDiscoveredAttribute($command->name);
// Hole Reflection Information
$reflection = new \ReflectionMethod($discoveredAttribute->className->toString(), $discoveredAttribute->methodName->toString());
// Prüfe ob es moderne Parameter-Parsing verwendet
if ($this->commandRegistry->usesReflectionParameters($reflection)) {
// Nutze den CommandParameterResolver für moderne Parameter-Hilfe
$resolver = $this->container->resolve(CommandParameterResolver::class);
$help = $resolver->generateMethodHelp($reflection, $command->name);
$this->output->writeLine($help);
} else {
// Fallback für Legacy-Commands
$this->output->writeLine("Verwendung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" php {$this->scriptName} {$command->name} [argumente]");
}
} catch (\Throwable $e) {
// Fallback bei Fehlern
$this->output->writeLine("Verwendung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" php {$this->scriptName} {$command->name} [argumente]");
}
}
/**
* Startet die interaktive TUI
*/
private function launchInteractiveTUI(): int
{
try {
// Prüfe ob Terminal kompatibel ist
if (! $this->isTerminalCompatible()) {
$this->output->writeError("Interactive TUI requires a compatible terminal.");
$this->output->writeLine("Use 'php {$this->scriptName} help' for command-line help.");
return ExitCode::SOFTWARE_ERROR->value;
}
// Get DiscoveryRegistry for TUI components
$discoveryRegistry = $this->container->get(DiscoveryRegistry::class);
// Create CommandHistory
$commandHistory = new CommandHistory();
// Create new services
$groupRegistry = new CommandGroupRegistry($discoveryRegistry);
$workflowExecutor = new SimpleWorkflowExecutor($this->commandRegistry, $groupRegistry, $this->output);
// Create TUI components
$state = new TuiState();
$renderer = new TuiRenderer($this->output);
$commandExecutor = new TuiCommandExecutor(
$this->output,
$this->commandRegistry,
$this->container,
$discoveryRegistry,
$commandHistory,
new CommandValidator(),
new CommandHelpGenerator(new ParameterInspector()),
$this->scriptName
);
$inputHandler = new TuiInputHandler($commandExecutor);
// Erstelle TUI Instanz
$tui = new ConsoleTUI(
$this->output,
$this->container,
$discoveryRegistry,
$state,
$renderer,
$inputHandler,
$commandExecutor,
$commandHistory,
$groupRegistry,
$workflowExecutor
);
// Starte TUI
return $tui->run()->value;
} catch (Throwable $e) {
$this->output->writeError("Failed to launch interactive TUI: " . $e->getMessage());
$config = $this->container->get(AppConfig::class);
if ($config->isDevelopment()) {
$this->output->writeLine("Stack trace:", ConsoleColor::RED);
$this->output->writeLine($e->getTraceAsString());
}
// Fallback to help
$this->output->newLine();
$this->output->writeLine("Falling back to command-line help:", ConsoleColor::YELLOW);
$this->showHelp();
return ExitCode::SOFTWARE_ERROR->value;
}
}
/**
* Prüft ob das Terminal für TUI kompatibel ist
*/
private function isTerminalCompatible(): bool
{
// Prüfe ob wir in einem Terminal sind
if (! function_exists('posix_isatty') || ! posix_isatty(STDOUT)) {
return false;
}
// Prüfe TERM environment variable
$term = $_SERVER['TERM'] ?? '';
if (empty($term) || $term === 'dumb') {
return false;
}
// Prüfe ob das Terminal interaktiv ist
if (! stream_isatty(STDIN) || ! stream_isatty(STDOUT)) {
return false;
}
return true;
}
}

View File

@@ -11,7 +11,41 @@ final readonly class ConsoleCommand
{
public function __construct(
public string $name,
public string $description = ''
public string $description = '',
public array $aliases = [],
public bool $hidden = false
) {
}
/**
* Get command name
*/
public function getName(): string
{
return $this->name;
}
/**
* Get command description
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Get command aliases
*/
public function getAliases(): array
{
return $this->aliases;
}
/**
* Check if command is hidden
*/
public function isHidden(): bool
{
return $this->hidden;
}
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Framework\Console;
namespace App\Framework\Console;
use App\Framework\Console\Components\InteractiveMenu;
use App\Framework\Console\ValueObjects\ChoiceOptions;
use App\Framework\Console\ValueObjects\MenuOptions;
final class ConsoleInput implements ConsoleInputInterface
{
@@ -16,10 +16,20 @@ final class ConsoleInput implements ConsoleInputInterface
private ?ConsoleOutputInterface $output = null;
public function __construct(array $arguments, ?ConsoleOutputInterface $output = null)
private ?ArgumentParser $parser = null;
private ?ParsedArguments $parsedArguments = null;
public function __construct(array $arguments, ?ConsoleOutputInterface $output = null, ?ArgumentParser $parser = null)
{
$this->parseArguments($arguments);
$this->output = $output ?? new ConsoleOutput();
$this->parser = $parser;
// Use enhanced parser if available
if ($this->parser) {
$this->parsedArguments = $this->parser->parse($arguments);
}
}
private function parseArguments(array $arguments): void
@@ -102,13 +112,30 @@ final class ConsoleInput implements ConsoleInputInterface
/**
* Zeigt ein einfaches Auswahlmenü.
*/
/**
* @deprecated Use choiceFromOptions() with MenuOptions instead for better type safety
*/
public function choice(string $question, array $choices, mixed $default = null): mixed
{
$menuOptions = MenuOptions::fromArray($choices);
return $this->choiceFromOptions($question, $menuOptions, $default);
}
/**
* Present choices using MenuOptions Value Object
*/
public function choiceFromOptions(string $question, MenuOptions $options, mixed $default = null): mixed
{
$menu = new InteractiveMenu($this->output);
$menu->setTitle($question);
foreach ($choices as $key => $choice) {
$menu->addItem($choice, null, $key);
foreach ($options as $option) {
if ($option->isSeparator()) {
$menu->addSeparator();
} else {
$menu->addItem($option->label, null, $option->key);
}
}
return $menu->showSimple();
@@ -117,16 +144,29 @@ final class ConsoleInput implements ConsoleInputInterface
/**
* Zeigt ein interaktives Menü mit Pfeiltasten-Navigation.
*/
/**
* @deprecated Use menuFromOptions() with MenuOptions instead for better type safety
*/
public function menu(string $title, array $items): mixed
{
$menuOptions = MenuOptions::fromArray($items);
return $this->menuFromOptions($title, $menuOptions);
}
/**
* Show interactive menu using MenuOptions Value Object
*/
public function menuFromOptions(string $title, MenuOptions $options): mixed
{
$menu = new InteractiveMenu($this->output);
$menu->setTitle($title);
foreach ($items as $key => $item) {
if ($item === '---') {
foreach ($options as $option) {
if ($option->isSeparator()) {
$menu->addSeparator();
} else {
$menu->addItem($item, null, $key);
$menu->addItem($option->label, null, $option->key);
}
}
@@ -136,28 +176,175 @@ final class ConsoleInput implements ConsoleInputInterface
/**
* Ermöglicht die Mehrfachauswahl aus einer Liste von Optionen.
*/
/**
* @deprecated Use multiSelectFromOptions() with ChoiceOptions instead for better type safety
*/
public function multiSelect(string $question, array $options): array
{
$choiceOptions = ChoiceOptions::fromArray($options);
$selected = $this->multiSelectFromOptions($question, $choiceOptions);
return $selected->getSelectedValues();
}
/**
* Multi-select using ChoiceOptions Value Object
*/
public function multiSelectFromOptions(string $question, ChoiceOptions $options): ChoiceOptions
{
$this->output->writeLine($question, ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine("Mehrfachauswahl mit Komma getrennt (z.B. 1,3,5):");
foreach ($options as $key => $option) {
$this->output->writeLine(" " . ($key + 1) . ": {$option}");
$displayOptions = $options->toArray();
foreach ($displayOptions as $key => $option) {
$this->output->writeLine(" " . ($key + 1) . ": {$option->label}");
}
$this->output->write("Ihre Auswahl: ", ConsoleColor::BRIGHT_CYAN);
$input = trim(fgets(STDIN));
$selected = [];
$selectedValues = [];
$indices = explode(',', $input);
foreach ($indices as $index) {
$index = (int)trim($index) - 1;
if (isset($options[$index])) {
$selected[] = $options[$index];
if (isset($displayOptions[$index])) {
$selectedValues[] = $displayOptions[$index]->value;
}
}
return $selected;
return $options->selectByValues($selectedValues);
}
// ==========================================
// Enhanced Argument Parsing Methods
// ==========================================
/**
* Get parsed arguments (enhanced parser required)
*/
public function getParsedArguments(): ParsedArguments
{
if (! $this->parsedArguments) {
throw new \RuntimeException('Enhanced parsing not available. Provide ArgumentParser in constructor.');
}
return $this->parsedArguments;
}
/**
* Check if enhanced parsing is available
*/
public function hasEnhancedParsing(): bool
{
return $this->parsedArguments !== null;
}
/**
* Get typed argument value (enhanced parser required)
*/
public function getTyped(string $name): mixed
{
return $this->getParsedArguments()->getTyped($name);
}
/**
* Get required argument (enhanced parser required)
*/
public function require(string $name): mixed
{
return $this->getParsedArguments()->require($name);
}
/**
* Get string argument with type safety
*/
public function getString(string $name): string
{
if ($this->hasEnhancedParsing()) {
return $this->getParsedArguments()->getString($name);
}
// Fallback to legacy parsing
return (string) $this->getOption($name, '');
}
/**
* Get integer argument with type safety
*/
public function getInt(string $name): int
{
if ($this->hasEnhancedParsing()) {
return $this->getParsedArguments()->getInt($name);
}
// Fallback to legacy parsing
return (int) $this->getOption($name, 0);
}
/**
* Get boolean argument with type safety
*/
public function getBool(string $name): bool
{
if ($this->hasEnhancedParsing()) {
return $this->getParsedArguments()->getBool($name);
}
// Fallback to legacy parsing
return (bool) $this->getOption($name, false);
}
/**
* Get array argument with type safety
*/
public function getArray(string $name): array
{
if ($this->hasEnhancedParsing()) {
return $this->getParsedArguments()->getArray($name);
}
// Fallback to legacy parsing
$value = $this->getOption($name);
if (is_string($value)) {
return array_map('trim', explode(',', $value));
}
return is_array($value) ? $value : [];
}
/**
* Check if argument has a value
*/
public function hasValue(string $name): bool
{
if ($this->hasEnhancedParsing()) {
return $this->getParsedArguments()->hasValue($name);
}
// Fallback to legacy parsing
return $this->hasOption($name) && $this->getOption($name) !== null;
}
/**
* Validate all required arguments are present (enhanced parser only)
*/
public function validate(): void
{
if ($this->hasEnhancedParsing()) {
$this->getParsedArguments()->validate();
}
}
/**
* Set argument parser (for late binding)
*/
public function setArgumentParser(ArgumentParser $parser): void
{
$this->parser = $parser;
// Re-parse arguments with enhanced parser
$rawArguments = array_merge($this->arguments, array_keys($this->options));
$this->parsedArguments = $parser->parse($rawArguments);
}
}

View File

@@ -9,7 +9,7 @@ use App\Framework\Console\Components\InteractiveMenu;
final readonly class DemoCommand
{
#[ConsoleCommand('demo:hello', 'Zeigt eine einfache Hallo-Welt-Nachricht')]
public function hello(ConsoleInput $input, ConsoleOutputInterface $output): int
public function hello(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeWindowTitle('Help Title', 2);
@@ -21,11 +21,11 @@ final readonly class DemoCommand
$output->writeInfo('Aktuelle Zeit: ' . date('Y-m-d H:i:s'));
}
return 0;
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:colors', 'Zeigt alle verfügbaren Farben')]
public function colors(ConsoleInput $input, ConsoleOutputInterface $output): int
public function colors(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('Verfügbare Farben:', ConsoleColor::BRIGHT_WHITE);
$output->newLine();
@@ -53,11 +53,11 @@ final readonly class DemoCommand
$output->writeWarning('Warnung-Nachricht');
$output->writeInfo('Info-Nachricht');
return 0;
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:interactive', 'Interaktive Demo mit Benutzereingaben')]
public function interactive(ConsoleInput $input, ConsoleOutputInterface $output): int
public function interactive(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('Interaktive Demo', ConsoleColor::BRIGHT_CYAN);
$output->newLine();
@@ -73,11 +73,11 @@ final readonly class DemoCommand
$output->writeWarning('Vorgang abgebrochen.');
}
return 0;
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:menu', 'Zeigt ein interaktives Menü')]
public function menu(ConsoleInput $input, ConsoleOutputInterface $output): int
public function menu(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$menu = new InteractiveMenu($output);
@@ -108,11 +108,11 @@ final readonly class DemoCommand
$output->writeInfo("Menü-Rückgabe: {$result}");
}
return 0;
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:simple-menu', 'Zeigt ein einfaches Nummern-Menü')]
public function simpleMenu(ConsoleInput $input, ConsoleOutputInterface $output): int
public function simpleMenu(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$menu = new InteractiveMenu($output);
@@ -125,11 +125,11 @@ final readonly class DemoCommand
$output->writeSuccess("Sie haben gewählt: {$result}");
return 0;
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:wizard', 'Zeigt einen Setup-Wizard')]
public function wizard(ConsoleInput $input, ConsoleOutputInterface $output): int
public function wizard(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeInfo('🧙 Setup-Wizard gestartet');
$output->newLine();
@@ -175,7 +175,7 @@ final readonly class DemoCommand
$output->writeWarning('❌ Konfiguration wurde nicht gespeichert.');
}
return 0;
return ExitCode::SUCCESS;
}
/**

View File

@@ -8,6 +8,7 @@ use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Screen\ScreenType;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\Timer;
@@ -20,7 +21,7 @@ final class ScreenDemoCommand
}
#[ConsoleCommand('demo:screen', 'Zeigt die verschiedenen Screen-Management-Funktionen')]
public function __invoke(ConsoleInput $input, ConsoleOutput $output): int
public function __invoke(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
// Aktiviere interaktiven Modus
$output->screen->setInteractiveMode(true);
@@ -58,7 +59,7 @@ final class ScreenDemoCommand
$output->writeError('Ungültige Auswahl!');
}
return 0;
return ExitCode::SUCCESS;
}
private function demoCursor(ConsoleInput $input, ConsoleOutput $output): void

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
enum CommandRelation: string
{
case SAME_CATEGORY = 'category';
case SIMILAR_FUNCTION = 'function';
case RELATED = 'related';
public function getDisplayText(): string
{
return match ($this) {
self::SAME_CATEGORY => 'same category',
self::SIMILAR_FUNCTION => 'similar function',
self::RELATED => 'related command'
};
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
use App\Framework\Console\ConsoleCommand;
final readonly class CommandSuggestion
{
public function __construct(
public ConsoleCommand $command,
public float $similarity,
public SuggestionReason $reason
) {
}
public function getFormattedSuggestion(): string
{
$percentage = round($this->similarity * 100);
$reasonText = $this->reason->getDisplayText();
return sprintf(
'%s (%d%% match - %s)',
$this->command->name,
$percentage,
$reasonText
);
}
public function getDetailedSuggestion(): string
{
return sprintf(
"Command: %s\nDescription: %s\nMatch: %.1f%% (%s)",
$this->command->name,
$this->command->description,
$this->similarity * 100,
$this->reason->getDisplayText()
);
}
public function isHighConfidence(): bool
{
return $this->similarity >= 0.8;
}
public function isMediumConfidence(): bool
{
return $this->similarity >= 0.5 && $this->similarity < 0.8;
}
public function isLowConfidence(): bool
{
return $this->similarity < 0.5;
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
use App\Framework\Console\CommandList;
use App\Framework\Console\ConsoleCommand;
final readonly class CommandSuggestionEngine
{
public function __construct(
private CommandList $commandList,
private int $maxSuggestions = 5,
private float $minSimilarity = 0.3
) {
}
public function suggestCommand(string $inputCommand): CommandSuggestions
{
$suggestions = [];
$commands = $this->commandList->getAllCommands();
foreach ($commands as $command) {
$similarity = $this->calculateSimilarity($inputCommand, $command->name);
if ($similarity >= $this->minSimilarity) {
$suggestions[] = new CommandSuggestion(
command: $command,
similarity: $similarity,
reason: $this->determineSuggestionReason($inputCommand, $command->name, $similarity)
);
}
}
// Sort by similarity (highest first)
usort($suggestions, fn ($a, $b) => $b->similarity <=> $a->similarity);
// Limit to max suggestions
$suggestions = array_slice($suggestions, 0, $this->maxSuggestions);
return new CommandSuggestions($inputCommand, $suggestions);
}
public function suggestCommandsByCategory(string $category): array
{
$commands = $this->commandList->getAllCommands();
$categoryCommands = [];
foreach ($commands as $command) {
if ($this->matchesCategory($command, $category)) {
$categoryCommands[] = $command;
}
}
return $categoryCommands;
}
public function suggestSimilarCommands(ConsoleCommand $baseCommand, int $limit = 3): array
{
$commands = $this->commandList->getAllCommands();
$suggestions = [];
foreach ($commands as $command) {
if ($command->name === $baseCommand->name) {
continue;
}
$similarity = $this->calculateCommandSimilarity($baseCommand, $command);
if ($similarity > 0.4) {
$suggestions[] = [
'command' => $command,
'similarity' => $similarity,
'relation' => $this->determineCommandRelation($baseCommand, $command),
];
}
}
usort($suggestions, fn ($a, $b) => $b['similarity'] <=> $a['similarity']);
return array_slice($suggestions, 0, $limit);
}
private function calculateSimilarity(string $input, string $command): float
{
// Exact match
if (strtolower($input) === strtolower($command)) {
return 1.0;
}
// Prefix match
if (str_starts_with(strtolower($command), strtolower($input))) {
return 0.9;
}
// Contains match
if (str_contains(strtolower($command), strtolower($input))) {
return 0.7;
}
// Levenshtein distance similarity
$levenshtein = levenshtein(strtolower($input), strtolower($command));
$maxLength = max(strlen($input), strlen($command));
if ($maxLength === 0) {
return 0.0;
}
$levenshteinSimilarity = 1 - ($levenshtein / $maxLength);
// Word-based similarity for commands with colons
$wordSimilarity = $this->calculateWordSimilarity($input, $command);
return max($levenshteinSimilarity, $wordSimilarity);
}
private function calculateWordSimilarity(string $input, string $command): float
{
$inputWords = preg_split('/[:\-_\s]+/', strtolower($input));
$commandWords = preg_split('/[:\-_\s]+/', strtolower($command));
if (empty($inputWords) || empty($commandWords)) {
return 0.0;
}
$matches = 0;
$total = max(count($inputWords), count($commandWords));
foreach ($inputWords as $inputWord) {
foreach ($commandWords as $commandWord) {
if ($inputWord === $commandWord) {
$matches++;
break;
} elseif (str_starts_with($commandWord, $inputWord) || str_starts_with($inputWord, $commandWord)) {
$matches += 0.7;
break;
}
}
}
return $matches / $total;
}
private function calculateCommandSimilarity(ConsoleCommand $base, ConsoleCommand $other): float
{
$namesimilarity = $this->calculateSimilarity($base->name, $other->name);
$descriptionSimilarity = $this->calculateDescriptionSimilarity($base->description, $other->description);
return ($namesimilarity * 0.7) + ($descriptionSimilarity * 0.3);
}
private function calculateDescriptionSimilarity(string $desc1, string $desc2): float
{
$words1 = array_filter(preg_split('/\s+/', strtolower($desc1)));
$words2 = array_filter(preg_split('/\s+/', strtolower($desc2)));
if (empty($words1) || empty($words2)) {
return 0.0;
}
$commonWords = array_intersect($words1, $words2);
$totalWords = array_unique(array_merge($words1, $words2));
return count($commonWords) / count($totalWords);
}
private function determineSuggestionReason(string $input, string $command, float $similarity): SuggestionReason
{
if ($similarity >= 0.9) {
return SuggestionReason::EXACT_MATCH;
}
if (str_starts_with(strtolower($command), strtolower($input))) {
return SuggestionReason::PREFIX_MATCH;
}
if (str_contains(strtolower($command), strtolower($input))) {
return SuggestionReason::PARTIAL_MATCH;
}
if ($this->calculateWordSimilarity($input, $command) > 0.5) {
return SuggestionReason::WORD_SIMILARITY;
}
return SuggestionReason::FUZZY_MATCH;
}
private function determineCommandRelation(ConsoleCommand $base, ConsoleCommand $other): CommandRelation
{
$baseNamespace = $this->extractNamespace($base->name);
$otherNamespace = $this->extractNamespace($other->name);
if ($baseNamespace === $otherNamespace && ! empty($baseNamespace)) {
return CommandRelation::SAME_CATEGORY;
}
if ($this->calculateDescriptionSimilarity($base->description, $other->description) > 0.3) {
return CommandRelation::SIMILAR_FUNCTION;
}
return CommandRelation::RELATED;
}
private function matchesCategory(ConsoleCommand $command, string $category): bool
{
$namespace = $this->extractNamespace($command->name);
return strtolower($namespace) === strtolower($category) ||
str_contains(strtolower($command->description), strtolower($category));
}
private function extractNamespace(string $commandName): string
{
$parts = explode(':', $commandName);
return count($parts) > 1 ? $parts[0] : '';
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
final readonly class CommandSuggestions
{
/**
* @param string $originalInput
* @param CommandSuggestion[] $suggestions
*/
public function __construct(
public string $originalInput,
public array $suggestions
) {
}
public function hasSuggestions(): bool
{
return ! empty($this->suggestions);
}
public function getBestSuggestion(): ?CommandSuggestion
{
return $this->suggestions[0] ?? null;
}
public function getHighConfidenceSuggestions(): array
{
return array_filter($this->suggestions, fn ($suggestion) => $suggestion->isHighConfidence());
}
public function getMediumConfidenceSuggestions(): array
{
return array_filter($this->suggestions, fn ($suggestion) => $suggestion->isMediumConfidence());
}
public function getLowConfidenceSuggestions(): array
{
return array_filter($this->suggestions, fn ($suggestion) => $suggestion->isLowConfidence());
}
public function formatForDisplay(): string
{
if (! $this->hasSuggestions()) {
return "No similar commands found for '{$this->originalInput}'.";
}
$output = "Did you mean one of these commands?\n\n";
$highConfidence = $this->getHighConfidenceSuggestions();
$mediumConfidence = $this->getMediumConfidenceSuggestions();
$lowConfidence = $this->getLowConfidenceSuggestions();
if (! empty($highConfidence)) {
$output .= "Strong matches:\n";
foreach ($highConfidence as $suggestion) {
$output .= "{$suggestion->getFormattedSuggestion()}\n";
}
$output .= "\n";
}
if (! empty($mediumConfidence)) {
$output .= "Possible matches:\n";
foreach ($mediumConfidence as $suggestion) {
$output .= "{$suggestion->getFormattedSuggestion()}\n";
}
$output .= "\n";
}
if (! empty($lowConfidence) && empty($highConfidence) && empty($mediumConfidence)) {
$output .= "Potential matches:\n";
foreach ($lowConfidence as $suggestion) {
$output .= "{$suggestion->getFormattedSuggestion()}\n";
}
$output .= "\n";
}
return rtrim($output);
}
public function count(): int
{
return count($this->suggestions);
}
public function isEmpty(): bool
{
return empty($this->suggestions);
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
final readonly class ConsoleErrorHandler
{
public function __construct(
private ErrorRecoveryService $recoveryService,
private ?Logger $logger = null
) {
}
public function handleCommandNotFound(string $command, ConsoleOutputInterface $output): ExitCode
{
$this->logError("Command not found: {$command}");
$this->recoveryService->handleCommandNotFound($command, $output);
return ExitCode::COMMAND_NOT_FOUND;
}
public function handleCommandExecutionError(
string $command,
\Throwable $error,
ConsoleOutputInterface $output
): ExitCode {
$this->logError("Command execution error in '{$command}': " . $error->getMessage(), [
'command' => $command,
'error_type' => get_class($error),
'trace' => $error->getTraceAsString(),
]);
if ($error instanceof FrameworkException) {
return $this->handleFrameworkException($command, $error, $output);
}
$this->recoveryService->handleCommandExecutionError($command, $error, $output);
return $this->determineExitCode($error);
}
public function handleValidationError(
string $command,
string $validationError,
ConsoleOutputInterface $output
): ExitCode {
$this->logError("Validation error in '{$command}': {$validationError}");
$this->recoveryService->handleArgumentError($command, $validationError, $output);
return ExitCode::INVALID_INPUT;
}
public function handlePermissionError(
string $command,
ConsoleOutputInterface $output
): ExitCode {
$this->logError("Permission denied for command: {$command}");
$this->recoveryService->handlePermissionError($command, $output);
return ExitCode::PERMISSION_DENIED;
}
public function handleUnexpectedError(
string $command,
\Throwable $error,
ConsoleOutputInterface $output
): ExitCode {
$this->logError("Unexpected error in '{$command}': " . $error->getMessage(), [
'command' => $command,
'error_type' => get_class($error),
'file' => $error->getFile(),
'line' => $error->getLine(),
'trace' => $error->getTraceAsString(),
]);
$output->writeLine("💥 An unexpected error occurred:", ConsoleColor::RED);
$output->writeLine(" {$error->getMessage()}", ConsoleColor::RED);
$output->newLine();
$output->writeLine("🔍 Debug information:", ConsoleColor::GRAY);
$output->writeLine(" Error: " . get_class($error), ConsoleColor::GRAY);
$output->writeLine(" File: {$error->getFile()}:{$error->getLine()}", ConsoleColor::GRAY);
$output->newLine();
$this->recoveryService->handleGeneralError($command, $error, $output);
return ExitCode::GENERAL_ERROR;
}
public function handleGracefulShutdown(string $reason, ConsoleOutputInterface $output): ExitCode
{
$this->logInfo("Graceful shutdown: {$reason}");
$output->writeLine("🛑 Operation interrupted: {$reason}", ConsoleColor::YELLOW);
$output->writeLine(" The command was safely terminated.", ConsoleColor::GRAY);
$output->newLine();
return ExitCode::INTERRUPTED;
}
private function handleFrameworkException(
string $command,
FrameworkException $exception,
ConsoleOutputInterface $output
): ExitCode {
$errorCode = $exception->getErrorCode();
return match ($errorCode) {
ErrorCode::CON_COMMAND_NOT_FOUND => $this->handleCommandNotFound($command, $output),
ErrorCode::CON_INVALID_ARGUMENTS => $this->handleValidationError(
$command,
$exception->getMessage(),
$output
),
ErrorCode::AUTH_UNAUTHORIZED,
ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES => $this->handlePermissionError($command, $output),
ErrorCode::DB_CONNECTION_FAILED,
ErrorCode::DB_QUERY_FAILED => $this->handleDatabaseError($command, $exception, $output),
ErrorCode::HTTP_RATE_LIMIT_EXCEEDED => $this->handleRateLimitError($command, $exception, $output),
default => $this->handleGeneralFrameworkError($command, $exception, $output)
};
}
private function handleDatabaseError(
string $command,
FrameworkException $exception,
ConsoleOutputInterface $output
): ExitCode {
$output->writeLine("🗄️ Database error in command '{$command}':", ConsoleColor::RED);
$output->writeLine(" {$exception->getMessage()}", ConsoleColor::RED);
$output->newLine();
$output->writeLine("💡 Database troubleshooting:", ConsoleColor::CYAN);
$output->writeLine(" • Check database connection settings", ConsoleColor::WHITE);
$output->writeLine(" • Verify database server is running", ConsoleColor::WHITE);
$output->writeLine(" • Check network connectivity", ConsoleColor::WHITE);
$output->writeLine(" • Verify database credentials", ConsoleColor::WHITE);
$output->newLine();
return ExitCode::DATABASE_ERROR;
}
private function handleRateLimitError(
string $command,
FrameworkException $exception,
ConsoleOutputInterface $output
): ExitCode {
$output->writeLine("⏳ Rate limit exceeded for command '{$command}':", ConsoleColor::YELLOW);
$output->writeLine(" {$exception->getMessage()}", ConsoleColor::YELLOW);
$output->newLine();
if ($exception->hasRetryAfter()) {
$retryAfter = $exception->getRetryAfter();
$output->writeLine("🕐 You can retry this command in {$retryAfter} seconds.", ConsoleColor::CYAN);
} else {
$output->writeLine("🕐 Please wait before retrying this command.", ConsoleColor::CYAN);
}
$output->newLine();
return ExitCode::RATE_LIMITED;
}
private function handleGeneralFrameworkError(
string $command,
FrameworkException $exception,
ConsoleOutputInterface $output
): ExitCode {
$this->recoveryService->handleCommandExecutionError($command, $exception, $output);
return $this->determineExitCode($exception);
}
private function determineExitCode(\Throwable $error): ExitCode
{
if ($error instanceof FrameworkException) {
return match ($error->getErrorCode()) {
ErrorCode::CON_COMMAND_NOT_FOUND => ExitCode::COMMAND_NOT_FOUND,
ErrorCode::CON_INVALID_ARGUMENTS => ExitCode::INVALID_INPUT,
ErrorCode::AUTH_UNAUTHORIZED,
ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES => ExitCode::PERMISSION_DENIED,
ErrorCode::DB_CONNECTION_FAILED,
ErrorCode::DB_QUERY_FAILED => ExitCode::DATABASE_ERROR,
ErrorCode::HTTP_RATE_LIMIT_EXCEEDED => ExitCode::RATE_LIMITED,
default => ExitCode::GENERAL_ERROR
};
}
return match (true) {
$error instanceof \ArgumentCountError,
$error instanceof \TypeError => ExitCode::INVALID_INPUT,
$error instanceof \Error => ExitCode::FATAL_ERROR,
default => ExitCode::GENERAL_ERROR
};
}
private function logError(string $message, array $context = []): void
{
$this->logger?->error($message, LogContext::withData($context));
}
private function logInfo(string $message, array $context = []): void
{
$this->logger?->info($message, LogContext::withData($context));
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
final readonly class ErrorRecoveryAnalysis
{
/**
* @param \Throwable $error
* @param string[] $recoveryOptions
*/
public function __construct(
public \Throwable $error,
public array $recoveryOptions
) {
}
public function hasRecoveryOptions(): bool
{
return ! empty($this->recoveryOptions);
}
public function getRecoveryOptions(): array
{
return $this->recoveryOptions;
}
public function getErrorType(): string
{
return get_class($this->error);
}
public function getErrorMessage(): string
{
return $this->error->getMessage();
}
public function isRecoverable(): bool
{
return $this->hasRecoveryOptions() &&
! $this->isCriticalError();
}
private function isCriticalError(): bool
{
return $this->error instanceof \Error ||
str_contains($this->error->getMessage(), 'Fatal') ||
str_contains($this->error->getMessage(), 'Critical');
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
use App\Framework\Console\CommandList;
use App\Framework\Console\CommandRegistry;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
final readonly class ErrorRecoveryService
{
public function __construct(
private CommandSuggestionEngine $suggestionEngine,
private CommandList $commandList,
private CommandRegistry $commandRegistry
) {
}
public function handleCommandNotFound(string $command, ConsoleOutputInterface $output): void
{
$output->writeLine("❌ Command '{$command}' not found.", ConsoleColor::RED);
$output->newLine();
$suggestions = $this->suggestionEngine->suggestCommand($command);
if ($suggestions->hasSuggestions()) {
$output->writeLine($suggestions->formatForDisplay(), ConsoleColor::YELLOW);
$this->showAdditionalHelp($command, $output);
} else {
$this->showGeneralHelp($output);
}
}
public function handleCommandExecutionError(
string $command,
\Throwable $error,
ConsoleOutputInterface $output
): void {
$output->writeLine("💥 Error executing command '{$command}':", ConsoleColor::RED);
$output->writeLine(" {$error->getMessage()}", ConsoleColor::RED);
$output->newLine();
$recovery = $this->analyzeError($error);
if ($recovery->hasRecoveryOptions()) {
$output->writeLine("🔧 Possible solutions:", ConsoleColor::CYAN);
foreach ($recovery->getRecoveryOptions() as $option) {
$output->writeLine("{$option}", ConsoleColor::WHITE);
}
$output->newLine();
}
$this->suggestRelatedCommands($command, $output);
}
public function handleArgumentError(
string $command,
string $argumentError,
ConsoleOutputInterface $output
): void {
$output->writeLine("⚠️ Argument error in command '{$command}':", ConsoleColor::YELLOW);
$output->writeLine(" {$argumentError}", ConsoleColor::YELLOW);
$output->newLine();
try {
$help = $this->commandRegistry->generateCommandHelp($command);
$output->writeLine("💡 Command usage:", ConsoleColor::CYAN);
$output->writeLine($help, ConsoleColor::WHITE);
} catch (\Exception $e) {
$output->writeLine("Unable to generate help for this command.", ConsoleColor::GRAY);
}
}
public function handlePermissionError(
string $command,
ConsoleOutputInterface $output
): void {
$output->writeLine("🔒 Permission denied for command '{$command}'.", ConsoleColor::RED);
$output->newLine();
$output->writeLine("💡 Try one of these solutions:", ConsoleColor::CYAN);
$output->writeLine(" • Check if you have the required permissions", ConsoleColor::WHITE);
$output->writeLine(" • Run the command with appropriate privileges", ConsoleColor::WHITE);
$output->writeLine(" • Contact your administrator if permissions are required", ConsoleColor::WHITE);
$output->newLine();
$this->suggestAlternativeCommands($command, $output);
}
public function handleGeneralError(
string $command,
\Throwable $error,
ConsoleOutputInterface $output
): void {
$output->writeLine("❌ Unexpected error in command '{$command}':", ConsoleColor::RED);
$output->writeLine(" {$error->getMessage()}", ConsoleColor::RED);
$output->newLine();
$output->writeLine("🔍 Debugging steps:", ConsoleColor::CYAN);
$output->writeLine(" • Check the command syntax and arguments", ConsoleColor::WHITE);
$output->writeLine(" • Verify all required dependencies are available", ConsoleColor::WHITE);
$output->writeLine(" • Check system resources and permissions", ConsoleColor::WHITE);
$output->writeLine(" • Try running with verbose output for more details", ConsoleColor::WHITE);
$output->newLine();
if ($error instanceof FrameworkException) {
$this->handleFrameworkError($error, $output);
}
}
private function analyzeError(\Throwable $error): ErrorRecoveryAnalysis
{
$options = [];
if ($error instanceof FrameworkException) {
$options = match ($error->getErrorCode()) {
ErrorCode::CON_COMMAND_NOT_FOUND => [
'Check if the command name is spelled correctly',
'Use "help" to see all available commands',
'Check if the command is properly registered',
],
ErrorCode::CON_INVALID_ARGUMENTS => [
'Check the command usage with "help {command}"',
'Verify all required arguments are provided',
'Check argument types and formats',
],
ErrorCode::DB_CONNECTION_FAILED => [
'Check database connection settings',
'Verify database server is running',
'Check network connectivity to database',
],
ErrorCode::SYSTEM_DEPENDENCY_MISSING => [
'Check if all required dependencies are installed',
'Verify service configuration',
'Check for missing environment variables',
],
default => [
'Check the error message for specific details',
'Verify system configuration',
'Try the command with different parameters',
]
};
} else {
$options = match (true) {
str_contains($error->getMessage(), 'Permission denied') => [
'Check file and directory permissions',
'Run with appropriate user privileges',
'Verify write access to required directories',
],
str_contains($error->getMessage(), 'Connection') => [
'Check network connectivity',
'Verify service endpoints are accessible',
'Check firewall and proxy settings',
],
str_contains($error->getMessage(), 'Memory') => [
'Increase PHP memory limit',
'Optimize command for large datasets',
'Process data in smaller batches',
],
default => [
'Check the command syntax and arguments',
'Verify system configuration',
'Try running the command again',
]
};
}
return new ErrorRecoveryAnalysis($error, $options);
}
private function handleFrameworkError(FrameworkException $error, ConsoleOutputInterface $output): void
{
if ($error->hasRetryAfter()) {
$retryAfter = $error->getRetryAfter();
$output->writeLine("⏳ This operation can be retried after {$retryAfter} seconds.", ConsoleColor::YELLOW);
$output->newLine();
}
$context = $error->getContext();
if (! empty($context)) {
$output->writeLine("🔍 Error context:", ConsoleColor::GRAY);
foreach ($context as $key => $value) {
if (is_string($value) || is_numeric($value)) {
$output->writeLine(" {$key}: {$value}", ConsoleColor::GRAY);
}
}
$output->newLine();
}
}
private function showAdditionalHelp(string $command, ConsoleOutputInterface $output): void
{
$output->newLine();
$output->writeLine("💡 Additional help:", ConsoleColor::CYAN);
$output->writeLine(" • Use 'help' to see all available commands", ConsoleColor::WHITE);
$output->writeLine(" • Use 'help {command}' to get detailed help for a specific command", ConsoleColor::WHITE);
// Suggest commands from same category
$namespace = $this->extractNamespace($command);
if (! empty($namespace)) {
$categoryCommands = $this->suggestionEngine->suggestCommandsByCategory($namespace);
if (! empty($categoryCommands)) {
$output->writeLine(
" • Other '{$namespace}' commands: " .
implode(', ', array_slice(array_map(fn ($cmd) => $cmd->name, $categoryCommands), 0, 3)),
ConsoleColor::WHITE
);
}
}
}
private function showGeneralHelp(ConsoleOutputInterface $output): void
{
$output->writeLine("💡 Available help:", ConsoleColor::CYAN);
$output->writeLine(" • Use 'help' to see all available commands", ConsoleColor::WHITE);
$output->writeLine(" • Use 'list' to browse commands by category", ConsoleColor::WHITE);
$output->writeLine(" • Check command spelling and try again", ConsoleColor::WHITE);
}
private function suggestRelatedCommands(string $command, ConsoleOutputInterface $output): void
{
try {
$commandObj = $this->commandList->get($command);
$related = $this->suggestionEngine->suggestSimilarCommands($commandObj, 3);
if (! empty($related)) {
$output->writeLine("🔗 Related commands:", ConsoleColor::CYAN);
foreach ($related as $rel) {
$output->writeLine(
"{$rel['command']->name} - {$rel['relation']->getDisplayText()}",
ConsoleColor::WHITE
);
}
$output->newLine();
}
} catch (\Exception $e) {
// Command not found, skip related suggestions
}
}
private function suggestAlternativeCommands(string $command, ConsoleOutputInterface $output): void
{
$namespace = $this->extractNamespace($command);
if (! empty($namespace)) {
$alternatives = $this->suggestionEngine->suggestCommandsByCategory($namespace);
if (! empty($alternatives)) {
$output->writeLine("🔀 Alternative commands in '{$namespace}' category:", ConsoleColor::CYAN);
foreach (array_slice($alternatives, 0, 5) as $alt) {
$output->writeLine("{$alt->name} - {$alt->description}", ConsoleColor::WHITE);
}
$output->newLine();
}
}
}
private function extractNamespace(string $commandName): string
{
$parts = explode(':', $commandName);
return count($parts) > 1 ? $parts[0] : '';
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
enum SuggestionReason: string
{
case EXACT_MATCH = 'exact';
case PREFIX_MATCH = 'prefix';
case PARTIAL_MATCH = 'partial';
case WORD_SIMILARITY = 'words';
case FUZZY_MATCH = 'fuzzy';
public function getDisplayText(): string
{
return match ($this) {
self::EXACT_MATCH => 'exact match',
self::PREFIX_MATCH => 'starts with input',
self::PARTIAL_MATCH => 'contains input',
self::WORD_SIMILARITY => 'similar words',
self::FUZZY_MATCH => 'similar spelling'
};
}
public function getPriority(): int
{
return match ($this) {
self::EXACT_MATCH => 100,
self::PREFIX_MATCH => 90,
self::PARTIAL_MATCH => 70,
self::WORD_SIMILARITY => 60,
self::FUZZY_MATCH => 40
};
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
use App\Framework\Console\ArgumentParser;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\HelpGenerator;
use App\Framework\Core\ValueObjects\Email;
/**
* Example command demonstrating the enhanced parameter parsing system
*/
final readonly class CreateUserCommand
{
private ArgumentParser $parser;
public function __construct()
{
// Define command arguments using the fluent builder
$this->parser = ArgumentParser::create()
->requiredString('email', 'User email address')
->requiredString('name', 'Full name of the user')
->choice('role', ['user', 'admin', 'moderator'], false, 'user', 'User role')
->flag('force', 'f', 'Skip confirmation prompts')
->flag('notify', null, 'Send welcome email to user')
->integer('quota', false, 100, 'Storage quota in MB')
->build();
}
#[ConsoleCommand('user:create', 'Create a new user account')]
public function execute(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
try {
// Set the enhanced parser for this command
$input->setArgumentParser($this->parser);
// Validate all required arguments
$input->validate();
// Get typed arguments with automatic validation
$email = new Email($input->getString('email'));
$name = $input->getString('name');
$role = $input->getString('role');
$force = $input->getBool('force');
$notify = $input->getBool('notify');
$quota = $input->getInt('quota');
// Display what we're about to do
$output->writeLine("Creating user with the following details:");
$output->writeLine(" Email: {$email->toString()}");
$output->writeLine(" Name: {$name}");
$output->writeLine(" Role: {$role}");
$output->writeLine(" Quota: {$quota} MB");
$output->writeLine(" Notify: " . ($notify ? 'Yes' : 'No'));
$output->newLine();
// Confirm unless forced
if (! $force && ! $input->confirm('Create this user?', false)) {
$output->writeInfo('User creation cancelled.');
return ExitCode::SUCCESS;
}
// Simulate user creation
$output->writeSuccess("User '{$name}' created successfully!");
if ($notify) {
$output->writeInfo("Welcome email sent to {$email->toString()}");
}
return ExitCode::SUCCESS;
} catch (\InvalidArgumentException $e) {
$output->writeError($e->getMessage());
$this->showHelp($output);
return ExitCode::INVALID_INPUT;
} catch (\Exception $e) {
$output->writeError("Failed to create user: " . $e->getMessage());
return ExitCode::GENERAL_ERROR;
}
}
#[ConsoleCommand('user:create:help', 'Show help for user:create command')]
public function showHelp(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$helpGenerator = new HelpGenerator($output);
$helpGenerator->display(
'user:create',
$this->parser->getDefinitions(),
'Create a new user account with email, name, and optional settings.'
);
return ExitCode::SUCCESS;
}
/**
* Example usage methods to demonstrate all features
*/
public function getExampleUsages(): array
{
return [
// Basic usage
'php console user:create john@example.com "John Doe"',
// With role
'php console user:create john@example.com "John Doe" --role=admin',
// With short flags
'php console user:create john@example.com "John Doe" -f --notify',
// With quota
'php console user:create john@example.com "John Doe" --quota=500 --role=moderator',
// All options
'php console user:create john@example.com "John Doe" --role=admin --force --notify --quota=1000',
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
/**
* User role enumeration for console commands
*/
enum UserRole: string
{
case USER = 'user';
case ADMIN = 'admin';
case MODERATOR = 'moderator';
/**
* Get human-readable description
*/
public function getDescription(): string
{
return match($this) {
self::USER => 'Regular user with basic permissions',
self::ADMIN => 'Administrator with full system access',
self::MODERATOR => 'Moderator with content management permissions',
};
}
/**
* Get all role values as array
*/
public static function getValues(): array
{
return array_column(self::cases(), 'value');
}
/**
* Check if role has admin privileges
*/
public function hasAdminPrivileges(): bool
{
return $this === self::ADMIN;
}
/**
* Check if role can moderate content
*/
public function canModerate(): bool
{
return in_array($this, [self::ADMIN, self::MODERATOR], true);
}
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\CommandWorkflow;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
/**
* Demo commands to showcase the new command groups and workflow system
*/
#[CommandGroup(
name: 'Workflow Demo',
description: 'Demo commands showcasing workflow and group features',
icon: '🎭',
priority: 100,
tags: ['demo', 'workflow', 'showcase']
)]
final readonly class WorkflowDemoCommand
{
#[ConsoleCommand('workflow:setup', 'Set up a complete development environment')]
#[CommandWorkflow(
name: 'Development Setup',
description: 'Complete development environment setup workflow',
steps: [
[
'command' => 'workflow:check-system',
'description' => 'Check system requirements',
'optional' => false,
],
[
'command' => 'workflow:install-deps',
'description' => 'Install dependencies',
'optional' => false,
'retryCount' => 2,
],
[
'command' => 'workflow:setup-db',
'description' => 'Set up database',
'optional' => false,
'environment' => ['DB_SETUP' => 'true'],
],
[
'command' => 'workflow:seed-data',
'description' => 'Seed initial data',
'optional' => true,
'condition' => 'development',
],
],
prerequisites: ['database'],
stopOnError: true,
rollbackSteps: [
[
'command' => 'workflow:cleanup-db',
'description' => 'Clean up database on failure',
],
]
)]
public function setupWorkflow(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🚀 Development setup workflow started!', ConsoleColor::BRIGHT_GREEN);
$output->writeLine('This workflow will be handled by the WorkflowExecutor.', ConsoleColor::GRAY);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:check-system', 'Check system requirements')]
public function checkSystem(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🔍 Checking system requirements...', ConsoleColor::CYAN);
$checks = [
'PHP Version' => PHP_VERSION_ID >= 80200,
'Extensions' => extension_loaded('pdo'),
'Memory Limit' => (int)ini_get('memory_limit') >= 128,
'Disk Space' => disk_free_space('.') > 1024 * 1024 * 100, // 100MB
];
foreach ($checks as $check => $result) {
$status = $result ? '✅' : '❌';
$color = $result ? ConsoleColor::GREEN : ConsoleColor::RED;
$output->writeLine(" {$status} {$check}", $color);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:install-deps', 'Install project dependencies')]
public function installDependencies(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('📦 Installing dependencies...', ConsoleColor::CYAN);
$steps = [
'Composer dependencies',
'NPM packages',
'Framework assets',
];
foreach ($steps as $step) {
$output->writeLine(" Installing {$step}...", ConsoleColor::WHITE);
usleep(500000); // Simulate installation time
$output->writeLine("{$step} installed", ConsoleColor::GREEN);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:setup-db', 'Set up database and run migrations')]
public function setupDatabase(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🗄️ Setting up database...', ConsoleColor::CYAN);
$steps = [
'Create database',
'Run migrations',
'Set up indexes',
];
foreach ($steps as $step) {
$output->writeLine(" {$step}...", ConsoleColor::WHITE);
usleep(300000); // Simulate operation time
$output->writeLine("{$step} completed", ConsoleColor::GREEN);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:seed-data', 'Seed initial development data')]
public function seedData(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🌱 Seeding development data...', ConsoleColor::CYAN);
$datasets = [
'Test users',
'Sample content',
'Configuration data',
];
foreach ($datasets as $dataset) {
$output->writeLine(" Seeding {$dataset}...", ConsoleColor::WHITE);
usleep(200000); // Simulate seeding time
$output->writeLine("{$dataset} seeded", ConsoleColor::GREEN);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:cleanup-db', 'Clean up database (rollback operation)')]
public function cleanupDatabase(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🧹 Cleaning up database...', ConsoleColor::YELLOW);
$steps = [
'Drop test tables',
'Clear cache',
'Reset connections',
];
foreach ($steps as $step) {
$output->writeLine(" {$step}...", ConsoleColor::WHITE);
usleep(100000); // Simulate cleanup time
$output->writeLine("{$step} completed", ConsoleColor::GREEN);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:status', 'Show workflow and group information')]
public function showStatus(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('📋 Workflow System Status', ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$output->writeLine('Command Groups:', ConsoleColor::BRIGHT_WHITE);
$output->writeLine(' • This command belongs to: "Workflow Demo" group', ConsoleColor::WHITE);
$output->writeLine(' • Group icon: 🎭', ConsoleColor::WHITE);
$output->writeLine(' • Group priority: 100', ConsoleColor::WHITE);
$output->writeLine(' • Group tags: demo, workflow, showcase', ConsoleColor::WHITE);
$output->newLine();
$output->writeLine('Available Workflows:', ConsoleColor::BRIGHT_WHITE);
$output->writeLine(' • Development Setup (workflow:setup)', ConsoleColor::WHITE);
$output->writeLine(' - 4 steps with prerequisites and rollback', ConsoleColor::GRAY);
$output->writeLine(' - Stops on error with automatic cleanup', ConsoleColor::GRAY);
$output->newLine();
$output->writeLine('💡 Try running "workflow:setup" to see the workflow in action!', ConsoleColor::BRIGHT_YELLOW);
return ExitCode::SUCCESS;
}
}

Some files were not shown because too many files have changed in this diff Show More