Selkie.git | t/ | 98-best-effort-shell.rakutest


use Test;
use lib 'lib';

# `try shell '...'` does NOT make a shell-out best-effort.
#
# Proc throws from its `sink` method, and in `try shell '…';` the value
# of `try` is sunk at STATEMENT level — outside the try. So the exception
# escapes, from a line whose entire purpose was to be ignorable.
#
# Selkie shells out in one place: the centralized terminal platform
# adapter's best-effort flow-control setup. The adapter gates Windows and
# /dev/tty before invoking the fixed stty command. App and Snapshot never
# issue platform-specific shell commands themselves.
#
# This file pins both halves: the language behaviour that makes the naive
# form wrong, and the absence of that form from the source.

plan 4;

subtest 'the trap itself' => {
	plan 2;

	# The naive form: the Proc is sunk by the statement, outside the try.
	# NB the trailing statement — without one, `try shell` is the block's
	# own value and the sink moves out past this CATCH as well, which is
	# the same trap one level up.
	my $escaped = False;
	{
		CATCH { default { $escaped = True } }
		try shell 'exit 3';
		$escaped = False;
	}
	ok $escaped, '`try shell` alone does NOT catch a failing command';

	# The fix: assign, so there is no sink to throw from.
	my $survived = False;
	{
		CATCH { default { $survived = False } }
		my $ = try shell 'exit 3';
		$survived = True;
	}
	ok $survived, 'assigning the Proc instead of sinking it does';
}

# The call site, by inspection. There is no way to require a live controlling
# terminal portably in this test, so the guard pins the safe spelling and the
# consumer/adapter boundary in source.
my sub source(Str:D $path --> Str:D) { $path.IO.slurp }

my sub unsafe-lines(Str:D $path --> List) {
	source($path).lines.grep({
		# A `try shell` / `try run` whose result is not being assigned.
		.trim.starts-with('try shell') || .trim.starts-with('try run')
	}).List;
}

subtest 'the terminal adapter does not sink its best-effort shell-out' => {
	plan 2;
	my $path = 'lib/Selkie/App/Internal/TerminalPlatform.rakumod';
	is unsafe-lines($path).elems, 0,
		'no bare `try shell` — it would escape the adapter';
	ok source($path).contains('my $ = try shell'),
		'the centralized stty call is assigned rather than sunk';
}

subtest 'App and Snapshot contain no platform shell-outs' => {
	plan 2;
	my @paths = <lib/Selkie/App.rakumod lib/Selkie/Test/Snapshot.rakumod>;
	is @paths.map({ unsafe-lines($_).elems }).sum, 0, 'neither consumer has an unsafe shell-out';
	my $consumer-source = @paths.map({ source($_) }).join("\n");
	nok $consumer-source.contains('try shell') || $consumer-source.contains('qx{'),
		'platform shell behavior exists only in the adapter';
}

subtest 'and nowhere else in the library' => {
	plan 1;
	# Walk the whole tree rather than a fixed list: the point is that the
	# idiom does not come back, wherever somebody puts it next.
	my @found;
	my sub walk(IO::Path $dir --> Nil) {
		for $dir.dir -> $entry {
			if $entry.d {
				walk($entry);
			}
			elsif $entry.extension eq 'rakumod' {
				@found.push($entry.Str) if unsafe-lines($entry.Str).elems;
			}
		}
		Nil;
	}
	walk('lib'.IO);

	is-deeply @found.sort.List, ().List,
		'no module sinks a Proc it meant to ignore';
}

# vim: ft=raku