Files
breadcrumb-the-shire/core/Service/Import/CsvReaderService.php
fs 0e86925464 refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root
immediately communicates which code is core platform and which lives
in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the
Composer PSR-4 path mapping moves from lib/ to core/.

Module-internal lib/ directories (modules/*/lib/) are untouched.

Updated across the full stack:
- composer.json PSR-4 mapping
- bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php)
- tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer)
- 26 architecture contract tests
- enforcement-policy, guard-catalog, quality-gates
- all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/)
- bin/qa-extended.sh search paths
- .claude/settings.local.json permission paths

Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner →
Executor → Code Review (4 findings fixed) → Security Review →
Acceptance Test → Finalizer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:20:42 +02:00

258 lines
7.5 KiB
PHP

<?php
namespace MintyPHP\Service\Import;
class CsvReaderService
{
private const DELIMITERS = [',', ';'];
/**
* @return array{ok:bool, delimiter?:string, headers?:array<int,string>, rows_total?:int, code?:string}
*/
public function analyze(string $path, int $maxRows): array
{
if (!is_file($path) || !is_readable($path)) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
$delimiter = $this->detectDelimiter($path);
$headerMeta = $this->readHeader($path, $delimiter);
if (!$headerMeta['ok']) {
$code = isset($headerMeta['code']) ? (string) $headerMeta['code'] : 'invalid_csv_header';
return ['ok' => false, 'code' => $code];
}
$headers = $headerMeta['headers'] ?? [];
$headerLine = (int) ($headerMeta['header_line'] ?? 1);
if (!$headers) {
return ['ok' => false, 'code' => 'invalid_csv_header'];
}
$rowsTotal = $this->countRows($path, $delimiter, $headers, $headerLine);
if ($rowsTotal <= 0) {
return ['ok' => false, 'code' => 'empty_csv'];
}
if ($rowsTotal > $maxRows) {
return ['ok' => false, 'code' => 'max_rows_exceeded'];
}
return [
'ok' => true,
'delimiter' => $delimiter,
'headers' => $headers,
'rows_total' => $rowsTotal,
];
}
/**
* @param array<int, string> $headers
*/
public function streamRows(string $path, string $delimiter, array $headers, callable $callback): void
{
$headerMeta = $this->readHeader($path, $delimiter);
if (!$headerMeta['ok']) {
return;
}
$headerLine = (int) ($headerMeta['header_line'] ?? 1);
$handle = @fopen($path, 'rb');
if (!$handle) {
return;
}
$lineNumber = 0;
$rowIndex = 0;
while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) {
$lineNumber++;
if ($lineNumber <= $headerLine) {
continue;
}
$row = $this->normalizeRowColumns($row, count($headers));
if ($this->isEmptyRow($row)) {
continue;
}
$rowIndex++;
$assoc = [];
foreach ($headers as $index => $header) {
$assoc[$header] = $this->cleanCell($row[$index] ?? '');
}
$callback($assoc, $lineNumber, $rowIndex);
}
fclose($handle);
}
// Sample-based detection — reading 5 lines is enough to identify the delimiter without scanning the full file.
private function detectDelimiter(string $path): string
{
$sample = '';
$handle = @fopen($path, 'rb');
if ($handle) {
for ($i = 0; $i < 5; $i++) {
$line = fgets($handle);
if ($line === false) {
break;
}
$sample .= $line;
}
fclose($handle);
}
$bestDelimiter = ',';
$bestScore = -1;
foreach (self::DELIMITERS as $delimiter) {
$score = substr_count($sample, $delimiter);
if ($score > $bestScore) {
$bestScore = $score;
$bestDelimiter = $delimiter;
}
}
return $bestDelimiter;
}
/**
* @return array{ok:bool, headers?:array<int,string>, header_line?:int, code?:string}
*/
private function readHeader(string $path, string $delimiter): array
{
$handle = @fopen($path, 'rb');
if (!$handle) {
return ['ok' => false, 'code' => 'invalid_csv_header'];
}
$lineNumber = 0;
while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) {
$lineNumber++;
if (!is_array($row)) {
continue;
}
$row = $this->normalizeRowColumns($row, count($row));
if ($this->isEmptyRow($row)) {
continue;
}
$headers = [];
foreach ($row as $index => $cell) {
$value = $this->cleanCell((string) $cell);
if ($index === 0) {
$value = $this->stripBom($value);
}
$headers[] = trim($value);
}
fclose($handle);
if (!$headers) {
return ['ok' => false, 'code' => 'invalid_csv_header'];
}
foreach ($headers as $headerValue) {
if ($headerValue === '') {
return ['ok' => false, 'code' => 'invalid_csv_header'];
}
}
// Case-insensitive duplicate check — "Email" and "email" in the same header row are ambiguous.
$normalizedKeys = array_map([$this, 'lower'], $headers);
if (count(array_unique($normalizedKeys)) !== count($normalizedKeys)) {
return ['ok' => false, 'code' => 'invalid_csv_header'];
}
return ['ok' => true, 'headers' => $headers, 'header_line' => $lineNumber];
}
fclose($handle);
return ['ok' => false, 'code' => 'empty_csv'];
}
/**
* @param array<int, mixed> $row
* @return array<int, string>
*/
private function normalizeRowColumns(array $row, int $size): array
{
$normalized = [];
foreach ($row as $cell) {
$normalized[] = $this->cleanCell((string) $cell);
}
// Pad short rows and trim over-long rows so every row has exactly as many cells as headers.
$current = count($normalized);
if ($current < $size) {
$normalized = array_pad($normalized, $size, '');
} elseif ($current > $size) {
$normalized = array_slice($normalized, 0, $size);
}
return $normalized;
}
/**
* @param array<int, string> $headers
*/
private function countRows(string $path, string $delimiter, array $headers, int $headerLine): int
{
$count = 0;
$handle = @fopen($path, 'rb');
if (!$handle) {
return 0;
}
$lineNumber = 0;
while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) {
$lineNumber++;
if ($lineNumber <= $headerLine) {
continue;
}
if (!is_array($row)) {
continue;
}
$row = $this->normalizeRowColumns($row, count($headers));
if ($this->isEmptyRow($row)) {
continue;
}
$count++;
}
fclose($handle);
return $count;
}
private function isEmptyRow(array $row): bool
{
foreach ($row as $cell) {
if (trim((string) $cell) !== '') {
return false;
}
}
return true;
}
private function stripBom(string $value): string
{
if (str_starts_with($value, "\xEF\xBB\xBF")) {
return substr($value, 3);
}
return $value;
}
// Remove null bytes (defense against null byte injection) and UTF-8 BOM from each cell.
private function cleanCell(string $value): string
{
$value = str_replace("\0", '', $value);
$value = $this->stripBom($value);
return trim($value);
}
private function lower(string $value): string
{
if (function_exists('mb_strtolower')) {
return mb_strtolower($value, 'UTF-8');
}
return strtolower($value);
}
}