refactor(cli): make headless the explicit default launch mode

Pholio only opens its window with --ui; without it, it runs the given
command or prints its usage. That was already the behavior, but the help
text, UiModeOptions' explicitChoice() and the LaunchOptionsTest names all
claimed the opposite. UiModeOptions now exposes uiRequested(), LaunchOptions
drops the unreachable "no flag means UI" branch, the help says "Runs
headless by default; pass --ui", and the tests are renamed to what they
assert, plus one checking that framework arguments next to --ui still open
the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
This commit is contained in:
2026-09-30 14:12:55 -04:00
co-authored by Claude Opus 5.5
parent 844177638a
commit ce318af010
4 changed files with 38 additions and 48 deletions
@@ -19,8 +19,9 @@ import java.util.List;
* everything else into {@link #unmatched}, and the second pass (with the Spring-backed factory) does the real
* parsing and validation.
*
* <p>Anything left unmatched is taken to be a subcommand and implies headless operation: {@code pholio scan}
* should do the scan, not open a window. Framework switches are stripped before parsing, so
* <p>Headless is the default: only {@code --ui} opens the window, and it refuses to be combined with a
* command. Anything left unmatched is taken to be a subcommand: {@code pholio scan} does the scan, and a
* bare {@code pholio} prints the usage. Framework switches are stripped before parsing, so
* {@code --spring.profiles.active=dev} does not read as a subcommand.
*/
@Command(name = "pholio")
@@ -51,28 +52,15 @@ public class LaunchOptions {
}
private LaunchMode mode(CommandLine commandLine) {
boolean hasCommand = !unmatched.isEmpty();
Boolean explicit;
try {
explicit = uiMode.explicitChoice().orElse(null);
}
catch (IllegalStateException e) {
throw new ParameterException(commandLine, e.getMessage());
}
if (Boolean.FALSE.equals(explicit)) {
if (!uiMode.uiRequested()) {
return LaunchMode.HEADLESS;
}
if (Boolean.TRUE.equals(explicit)) {
// --ui alongside a command is a contradiction worth reporting rather than silently resolving:
// either the window opens and the command is ignored, or the reverse. Neither is what was asked.
if (hasCommand) {
throw new ParameterException(commandLine,
"--ui cannot be combined with a command: " + String.join(" ", unmatched));
}
return LaunchMode.UI;
// --ui alongside a command is a contradiction worth reporting rather than silently resolving:
// either the window opens and the command is ignored, or the reverse. Neither is what was asked.
if (!unmatched.isEmpty()) {
throw new ParameterException(commandLine,
"--ui cannot be combined with a command: " + String.join(" ", unmatched));
}
return hasCommand ? LaunchMode.HEADLESS : LaunchMode.UI;
return LaunchMode.UI;
}
}
@@ -20,8 +20,8 @@ import java.util.concurrent.Callable;
@Command(name = "pholio",
mixinStandardHelpOptions = true,
versionProvider = PholioVersionProvider.class,
description = "Pholio — high-volume photo library manager. Launch without a command to open the "
+ "desktop interface.",
description = "Pholio — high-volume photo library manager. Runs headless by default; pass --ui to "
+ "open the desktop interface.",
subcommands = { ScanCommand.class })
public class PholioCommand implements Callable<Integer> {
@@ -2,29 +2,23 @@ package org.icroco.pholio.cli;
import picocli.CommandLine.Option;
import java.util.Optional;
/**
* The {@code --ui} / {@code --no-ui} switch, shared between the launcher's mode decision and the root
* command's help output so the two can never describe different flags.
*
* <p>Two plain options rather than one {@code negatable = true} option: PicoCLI's negatable booleans derive
* their value from the field's initial value in a way that inverts the meaning of both forms when the default
* is {@code true} — {@code --no-ui} ends up enabling the UI. Two independent flags with an explicit conflict
* check cannot misbehave, at the cost of one extra line in the help text.
* <p>Pholio is headless unless asked otherwise: without {@code --ui} it runs a command, or prints its usage
* when given none. Every launcher meant to open a window — IDE run configurations, the jDeploy descriptor,
* the jpackage installers ({@code packaging/common.sh} in the {@code distrib} worktree) — passes {@code --ui}
* explicitly. {@code --no-ui} is accepted, and is simply the default spelled out.
*/
public class UiModeOptions {
@Option(names = "--ui", description = "Open the desktop interface. This is the default when no command "
+ "is given.", defaultValue = "false", negatable = true)
@Option(names = "--ui", description = "Open the desktop interface. Without it Pholio runs headless: the "
+ "given command, or this usage when there is none.", defaultValue = "false", negatable = true)
private boolean ui;
/**
* The user's explicit choice, or empty when they expressed none and the launcher should infer it.
*
* @throws IllegalStateException if both flags were given
*/
public Optional<Boolean> explicitChoice() {
return Optional.of(ui);
/** Whether {@code --ui} was given (and not overridden by {@code --no-ui}). */
public boolean uiRequested() {
return ui;
}
}
@@ -14,9 +14,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Launch-mode resolution.
*
* <p>Both directions of getting this wrong are user-visible: a false headless reading means double-clicking the
* application prints usage instead of opening a window, and a false UI reading means a server with no display
* tries to start a graphics stack.
* <p>Headless is the default: only {@code --ui} opens the window, which is why every launcher meant to show one
* (IDE run configuration, jDeploy descriptor, jpackage installers) passes it. Both directions of getting this
* wrong are user-visible: a missed {@code --ui} prints usage instead of opening a window, and a false UI
* reading means a server with no display tries to start a graphics stack.
*/
@ExtendWith(SoftAssertionsExtension.class)
class LaunchOptionsTest {
@@ -26,17 +27,17 @@ class LaunchOptionsTest {
}
@Test
void noArgumentsOpensTheDesktopShell() {
void noArgumentsRunsHeadless() {
assertThat(resolve()).isEqualTo(LaunchMode.HEADLESS);
}
@Test
void uiIsTheDefaultAndCanBeStatedExplicitly() {
void uiOpensTheDesktopShell() {
assertThat(resolve("--ui")).isEqualTo(LaunchMode.UI);
}
@Test
void noUiForcesHeadless() {
void noUiIsTheDefaultSpelledOut() {
assertThat(resolve("--no-ui")).isEqualTo(LaunchMode.HEADLESS);
}
@@ -63,11 +64,11 @@ class LaunchOptionsTest {
}
/**
* Maven, IDE run configurations and packaged launchers all inject framework switches. Treating them as a
* command would silently break the desktop launch.
* Maven, IDE run configurations and packaged launchers all inject framework switches. They are neither a
* command nor --ui, so on their own they leave the default headless mode alone...
*/
@Test
void frameworkArgumentsAloneStillOpenTheShell(SoftAssertions softly) {
void frameworkArgumentsAloneStayHeadless(SoftAssertions softly) {
softly.assertThat(resolve("--spring.profiles.active=dev")).isEqualTo(LaunchMode.HEADLESS);
softly.assertThat(resolve("--logging.level.org.icroco.pholio=TRACE")).isEqualTo(LaunchMode.HEADLESS);
softly.assertThat(resolve("--pholio.pools.thumbnail=4")).isEqualTo(LaunchMode.HEADLESS);
@@ -77,6 +78,13 @@ class LaunchOptionsTest {
softly.assertThat(resolve("")).isEqualTo(LaunchMode.HEADLESS);
}
/** ...and next to --ui they do not read as a command, which would make --ui fail as "combined with a command". */
@Test
void frameworkArgumentsDoNotBlockTheDesktopShell() {
assertThat(resolve("--ui", "--spring.profiles.active=dev", "-Dfoo=bar", "--pholio.dev-tools.enabled=true"))
.isEqualTo(LaunchMode.UI);
}
@Test
void frameworkArgumentsMixedWithACommandStillRunHeadless() {
assertThat(resolve("--no-ui", "--spring.profiles.active=dev", "scan")).isEqualTo(LaunchMode.HEADLESS);