Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/rtk.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ Every call returns an immutable `OptimisationResult`:
| `savingsPercent()` | saving as a percentage |
| `strategy` | `rtk:pipe`, `rtk:pipe:<filter>`, or `rtk:command` |

## Failures

A command RTK cannot handle throws a `RuntimeException` carrying its exit code and stderr.

## Requirements

The `rtk` binary must be on `PATH` at runtime (`brew install rtk`). It is **not** a Composer
Expand Down
42 changes: 37 additions & 5 deletions src/RtkProxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public function optimise(string $content, array $options = []): OptimisationResu
}

$optimised = $this->execute(array_merge([$this->binary], $args), $content);
$this->assertNotSwallowed($content, $optimised, 'rtk pipe');
$strategy = isset($options['filter']) ? 'rtk:pipe:' . $options['filter'] : 'rtk:pipe';

return new OptimisationResult(
Expand All @@ -85,12 +86,15 @@ public function optimise(string $content, array $options = []): OptimisationResu
*/
public function optimiseCommand(string $command, array $options = []): OptimisationResult
{
$tokensBefore = ($options['measure'] ?? true)
? $this->estimateTokens($this->execute(['sh', '-c', $command]))
: null;
$raw = ($options['measure'] ?? true) ? $this->execute(['sh', '-c', $command]) : null;
$tokensBefore = $raw === null ? null : $this->estimateTokens($raw);

$optimised = $this->execute(['sh', '-c', $this->binary . ' ' . $this->withUltraCompact($command, $options)]);

if ($raw !== null) {
$this->assertNotSwallowed($raw, $optimised, $command);
}

return new OptimisationResult(
$optimised,
$tokensBefore,
Expand All @@ -105,6 +109,24 @@ public function estimateTokens(string $content): int
return $this->estimator->estimateTokens($content);
}

/**
* Empty output for non-empty input is a failure, not a saving.
*
* @throws RuntimeException When RTK produced no output for input that had some
*/
private function assertNotSwallowed(string $input, string $optimised, string $what): void
{
if ($optimised !== '' || trim($input) === '') {
return;
}

throw new RuntimeException(sprintf(
'RTK returned no output for "%s" although the input had %d tokens.',
$what,
$this->estimateTokens($input),
));
}

/**
* Run a process, optionally feeding it stdin, and return its stdout.
*
Expand All @@ -116,7 +138,7 @@ public function estimateTokens(string $content): int
*
* @return string The process stdout (empty string on no output)
*
* @throws RuntimeException When the process cannot be started
* @throws RuntimeException When the process cannot be started or exits non-zero
*/
protected function execute(array $argv, ?string $stdin = null): string
{
Expand All @@ -139,9 +161,19 @@ protected function execute(array $argv, ?string $stdin = null): string
fclose($pipes[0]);

$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
$exitCode = proc_close($process);

if ($exitCode !== 0) {
throw new RuntimeException(sprintf(
'"%s" exited with code %d: %s',
implode(' ', $argv),
$exitCode,
trim((string) $stderr) !== '' ? trim((string) $stderr) : 'no error output',
));
}

return $stdout === false ? '' : $stdout;
}
Expand Down
50 changes: 50 additions & 0 deletions tests/Unit/RtkProxyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use PapiAI\Core\HeuristicTokenEstimator;
use PapiAI\Core\OptimisationResult;
use PapiAI\Rtk\RtkProxy;
use RuntimeException;

/**
* Test subclass that stubs process execution so no real `rtk` binary runs.
Expand All @@ -26,10 +27,18 @@ class TestableRtkProxy extends RtkProxy
/** @var array<int, array{argv: array<int, string>, stdin: string|null}> */
public array $calls = [];

public bool $rtkReturnsNothing = false;

protected function execute(array $argv, ?string $stdin = null): string
{
$this->calls[] = ['argv' => $argv, 'stdin' => $stdin];

$viaRtk = in_array('pipe', $argv, true) || str_starts_with($argv[2] ?? '', 'rtk ');

if ($this->rtkReturnsNothing && $viaRtk) {
return '';
}

// `rtk pipe` (argv contains 'pipe')
if (in_array('pipe', $argv, true)) {
return 'PIPED';
Expand Down Expand Up @@ -190,4 +199,45 @@ public function run(array $argv, ?string $stdin = null): string
expect($proxy->estimateTokens('abcd'))->toBe(2);
});
});
describe('when RTK returns nothing', function () {
it('throws for a command that produced output', function () {
$this->proxy->rtkReturnsNothing = true;

expect(fn () => $this->proxy->optimiseCommand('find src -exec wc -l {} +'))
->toThrow(RuntimeException::class, 'returned no output');
});

it('throws for piped content', function () {
$this->proxy->rtkReturnsNothing = true;

expect(fn () => $this->proxy->optimise('some content worth keeping'))
->toThrow(RuntimeException::class, 'returned no output');
});

it('accepts empty output for empty input', function () {
$this->proxy->rtkReturnsNothing = true;

$result = $this->proxy->optimise('');

expect($result->optimised)->toBe('');
expect($result->tokensSaved())->toBe(0);
});

it('reports no saving for an unmeasured command', function () {
$this->proxy->rtkReturnsNothing = true;

$result = $this->proxy->optimiseCommand('find src', ['measure' => false]);

expect($result->savingsPercent())->toBeNull();
});
});

describe('process failures', function () {
it('throws with the exit code and stderr when the process fails', function () {
$proxy = new RtkProxy('sh');

expect(fn () => $proxy->optimiseCommand('echo boom >&2; exit 3', ['measure' => false]))
->toThrow(RuntimeException::class, 'exited with code 3');
});
});
});
Loading