diff --git a/CLAUDE.md b/CLAUDE.md index 8d5efa7..b46b21f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,24 @@ Each variant carries a lazy `getValue: Task[T]` so callers can branch on `isUnch - **JobScheduler:** Polling daemon (configurable interval) that checks enabled `JobSchedule` entries and submits due jobs to the `JobRunner`. - **ServerTables:** Ensures both analysis and server database tables exist on startup. +### CLI + +`ccas` (`ccas.cli.Main`, a `ZIOAppDefault`) is a zio-cli binary. `CliCommand` defines the tree, `Dispatcher` turns each parsed command into HTTP calls against a running server, and `CompletionSpec`/`CompletionEmitter` generate the shell completions. Exit codes: 0 success/help, 1 job failure, 2 usage error. + +**Tree:** 5 groups (`server`, `blacklist`, `schedule`, `club`, `config`) and 9 leaves (`use-club`, `membership`, `history`, `recruit`, `stats`, `jobs`, `logs`, `cancel`, `completion`). + +**The locality invariant.** Every command is either a `CliCommand.ServerCommand` — carrying `--server`, dispatched by `Dispatcher` through `CcasApiClient` against a user-specified server — or a local command handled in-process by `Main.execute`, with no `--server` and no `Dispatcher` round-trip. (Local does not mean network-free: `server status`/`down`, and `up --detach`'s readiness wait, all probe fixed loopback `/health` endpoints via `HealthProbe`; `use-club` on a *set* makes a short best-effort `/api/managed-clubs` fetch to refresh the completion cache and sharpen its advisory — its guaranteed effect, the local pointer write, still lands offline. `config` genuinely touches nothing.) **Every group today is locality-homogeneous:** `server` + `config` are wholly local, `blacklist` + `schedule` + `club` wholly server-backed. Preserve this when adding commands. It is why `use-club` is a top-level leaf and not `ccas club use`: grouping it would make `club` the first mixed group and create an availability trap, where `ccas club add` fails with the server down but `ccas club use` succeeds, with nothing in the UI explaining the difference (the `git switch` / `kubectl config use-context` shape, decided in #123 / #86; the `club` group's help text points at `use-club` so discoverability doesn't depend on tree position). + +**`current_club` vs `managed_club` are orthogonal, not hierarchical.** `current_club` is a *per-device local pointer* in the CLI config; `managed_club` is a *shared DB row* naming the clubs this deployment runs CCAS for. `ClubResolver.single` never consults the managed set — only `--all` expands from it (`ClubResolver.multi`) — so a `current_club` naming an unmanaged club still resolves and still submits jobs. Job submission likewise gates on the `club` row, not managed status (#177 tracks closing that gap). Because unmanaging leaves the `club` row intact, `ccas club remove` clears `current_club` when it names the removed club, so the next bare command fails loudly on `ClubResolver.NoClubError` rather than silently running against a disowned club. + +**Two config files, deliberately separate.** `${XDG_CONFIG_HOME:-~/.config}/ccas/config.conf` holds CLI *client* settings (`api_url`, `default_clubs`, `log_dir`, `current_club`) — read by `CliConfig` via zio-config, written by `ConfigWriter` (a surgical line edit; zio-config has no HOCON writer). `${XDG_CONFIG_HOME:-~/.config}/ccas/ccas.env` holds what the *server* needs to boot (`CCAS_CONTACT_EMAIL`, `DATABASE_URL`, …) and is what `ccas config get|set|unset|list|path` manages, applied at boot by `ServerEnvOverlay`. `ccas config` does **not** see `current_club`; that is by contract, not a bug. Both paths resolve through `XdgPaths`, which reads environment variables — so any code a test must exercise against a temp path needs an explicit-path form (see `CompletionCache`'s `…In` variants) rather than relying on rebinding the environment. + +**Completion cache.** `${XDG_CACHE_HOME:-~/.cache}/ccas/{clubs.txt,recent-jobs.txt}`, read by the generated scripts with a bare `cat` — no JVM, no network. `Dispatcher.refreshClubsCache` repopulates it after any successful server-touching command, gated on a 6h TTL. It is sourced from `/api/managed-clubs` (falling back to `/api/clubs` only when nothing is managed yet): `/api/clubs` is every club ever ingested, which a few history crawls push into the thousands. `CompletionCache.invalidate` bypasses the TTL and is called when the managed set changes (`club add`/`remove`) and on a `Club not found` submit failure, which is the shape a Chess.com club-slug rename takes — the server gates on an exact-slug `Club.selectBySlug` *before* any rename recovery can run, so `ClubSlugRenameResolver` is unreachable from that path (#176). + +**Argument parsing gotchas.** zio-cli swallows an option written *after* a positional as another positional value (documented at the top of `CliCommand`), and `Args.atMost(n)` silently *truncates* extra values rather than rejecting them. Together those turn `ccas use-club team-alpha --clear` into a silent set of the club the user asked to clear, so `use-club` captures every positional with `.repeat` and validates the arity in `UseClub`, pointing at the working order. Prefer that shape over `atMost` wherever a dropped argument would change what the command does. + +`completions/ccas.bash` is committed and asserted byte-equal to `CompletionEmitter.bash` by `TestCcasCompletion` — regenerate with `sbt -batch -error 'runMain ccas.cli.Main completion bash' > completions/ccas.bash` after any tree change. `CompletionSpec.summaries` is *intended* to mirror the `withHelp` strings in `CliCommand` but this is not asserted, and some have drifted. + ### Database Uses Magnum (`com.augustnagro.magnum`) for SQL access with PostgreSQL. `PostgresClient` (`ccas.utils.sql`) wraps a Magnum `Transactor` backed by HikariCP and adds connection-pool hardening (keepalive probes, validation queries, lazy initialization) and transient-error retry (exponential backoff on SQLState `08xxx`). `PostgresClient.live` reads config from `application.conf` under the `database` prefix and provides a `PostgresClient` ZLayer; all app and server code depends on `PostgresClient` rather than `Transactor` directly. Custom `DbCodec` instances handle `Instant` (via `TIMESTAMPTZ`), `URL`, and `List[String]` (PostgreSQL arrays). Table names are derived from case class names via `CamelToSnakeCase` naming strategy. Server tables (`JobRun`, `JobSchedule`) reference clubs by `club_id` FK; route handlers resolve the slug from HTTP requests to a `ClubId` before submitting jobs. Analysis run tables (`MembershipRun`, `RecruitmentRun`, `HistoryRun`) have an optional `job_run_id` column linking back to the server-level job. Schema migrations for existing databases are managed via manual SQL scripts in the `sql/` directory. diff --git a/README.md b/README.md index cbdeb99..0ea23e1 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,22 @@ ccas --help # full command tree ccas --help # per-command flags ``` -Commands: `server {up|down|status}`, `use`, `membership`, `history`, `recruit`, `stats`, `jobs`, `logs`, `cancel`, `blacklist {add|list|remove}`, `schedule {list|add|remove}`, `club {add|remove|list}`. +Commands: `server {up|down|status}`, `use-club`, `membership`, `history`, `recruit`, `stats`, `jobs`, `logs`, `cancel`, `blacklist {add|list|remove}`, `schedule {list|add|remove}`, `club {add|remove|list}`. **Cancelling a job.** `ccas cancel ` requests cancellation of a running job — it interrupts the job's fiber on the server, which records the run as `Cancelled`. Cancellation is best-effort: an in-flight blocking database statement runs to completion first, so the job stops at its next interruptible point rather than instantly. A cancelled recruitment run leaves the candidates it found so far as **deferred** (nothing invited), so you can review and confirm them afterwards. Cancel is single-server-scoped — it reaches jobs running on the server it is sent to. -**Club targeting.** Slug-requiring commands take the club via `--club ` rather than a positional argument; `membership`/`history` accept a comma-separated `--club a,b` or `--all` (every managed club) — but not both at once (`--all` with `--club` is rejected as a likely mistake). When neither is given, the command falls back to the **current club** set with `ccas use-club ` (stored as `current_club` in the [config file](#cli-config-file)); an explicit `--club`/`--all` always wins. `ccas use-club` is a local config write — no server call — so it works offline and only warns (does not reject) if the slug isn't among the cached clubs. +**Club targeting.** Slug-requiring commands take the club via `--club ` rather than a positional argument; `membership`/`history` accept a comma-separated `--club a,b` or `--all` (every managed club) — but not both at once (`--all` with `--club` is rejected as a likely mistake). When neither is given, the command falls back to the **current club** set with `ccas use-club ` (stored as `current_club` in the [config file](#cli-config-file)); an explicit `--club`/`--all` always wins. `ccas use-club` is a local config write that always succeeds — even with the server down — so it works offline. When a server *is* reachable it additionally makes a short best-effort check: it refreshes the completion cache and, if the slug isn't one of your managed clubs, notes so (it never rejects — an unmanaged club is a valid target). If the server can't be reached in a second or two it falls back to a cache-based hint and sets the club anyway. + +```bash +ccas use-club # print the current club (exit 2 if none is set) +ccas use-club team-alpha # set it +ccas use-club --clear # unset it +ccas club list # the managed set; '*' marks the current club +``` + +The current club and the managed set are separate things: `ccas club {add,remove,list}` edits the server-side set of clubs you run CCAS for (shared by every client of that server), while `ccas use-club` is a local per-machine pointer at which of them your commands target by default. Removing the club you're currently using clears the pointer, so the next bare command asks you to pick one instead of silently running against a club you no longer manage. + +**Flags go before positional arguments.** `ccas use-club --clear` works; `ccas use-club team-alpha --clear` does not — the underlying CLI library swallows an option written after a positional and treats it as another argument. `use-club` detects this and fails with the working order rather than acting on a misread command, but elsewhere in the tree a trailing flag is silently ignored, so keep options first: `ccas blacklist add --club team-alpha alice bob`. The server URL resolves in order: a global `--server ` flag, else `api_url` from the [config file](#cli-config-file), else the built-in default `http://127.0.0.1:8080`. @@ -108,7 +119,7 @@ eval "$(ccas completion zsh)" ccas completion fish > ~/.config/fish/completions/ccas.fish ``` -Dynamic candidates come from `${XDG_CACHE_HOME:-~/.cache}/ccas/clubs.txt` and `recent-jobs.txt`, refreshed automatically as you run normal commands (the club list is fetched from `GET /api/clubs` at most every few hours; each submitted job id is recorded). On a fresh install these are empty, so only subcommands and flags complete until the first command populates them — unless `default_clubs` is set in the [config file](#cli-config-file), which seeds the club list so completion works immediately. +Dynamic candidates come from `${XDG_CACHE_HOME:-~/.cache}/ccas/clubs.txt` and `recent-jobs.txt`, refreshed automatically as you run normal commands (the club list is fetched from `GET /api/managed-clubs` at most every few hours — falling back to `GET /api/clubs` only while you manage no clubs yet; each submitted job id is recorded). On a fresh install these are empty, so only subcommands and flags complete until the first command populates them — unless `default_clubs` is set in the [config file](#cli-config-file), which seeds the club list so completion works immediately. The committed `completions/ccas.bash` is the generated output of `ccas completion bash`; `TestCcasCompletion` fails if it drifts from the emitter or if a new subcommand/flag isn't covered. Regenerate it with `ccas completion bash > completions/ccas.bash`. @@ -234,7 +245,7 @@ current_club = "team-alpha" # default club when a command omi ``` - **Server URL** resolves `--server ` flag → `api_url` → built-in `http://127.0.0.1:8080`. -- **`default_clubs`** seeds the completion club list on a fresh install (before any `GET /api/clubs` round-trip); real slugs replace it on the next command. +- **`default_clubs`** seeds the completion club list on a fresh install (before any `GET /api/managed-clubs` round-trip); real slugs replace it on the next command. - **`current_club`** is the club a slug-requiring command targets when neither `--club` nor `--all` is given. Set it with `ccas use-club ` (which rewrites just this key, preserving your other keys and comments) rather than editing by hand. - A missing file is fine — the CLI falls back to built-in defaults. A malformed file fails fast with `error: invalid config file : …` (exit 2). diff --git a/completions/ccas.bash b/completions/ccas.bash index 0412be2..cb958ac 100644 --- a/completions/ccas.bash +++ b/completions/ccas.bash @@ -61,7 +61,7 @@ _ccas() { status) ;; *) COMPREPLY=(); return ;; esac ;; - use-club) pos="slug" ;; + use-club) opts="--clear"; pos="slug" ;; membership) opts="--server --trust-usernames --no-trust-usernames --club --all --no-progress --detach" ;; history) opts="--server --full --include-finished --refresh --refresh-min-hours --club --all --no-progress --detach" ;; recruit) opts="--server --alias --target --cumulative --source-clubs --time-limit-minutes --explore --no-explore --club --stdout --report --no-progress" ;; diff --git a/src/main/scala/ccas/analysis/apps/ManagedClubApp.scala b/src/main/scala/ccas/analysis/apps/ManagedClubApp.scala index 8b7a406..30f8925 100644 --- a/src/main/scala/ccas/analysis/apps/ManagedClubApp.scala +++ b/src/main/scala/ccas/analysis/apps/ManagedClubApp.scala @@ -8,7 +8,7 @@ import ccas.utils.errors.NotFoundException import ccas.utils.sql.PostgresClient /** Synchronous CRUD for the [[ManagedClub]] marker — the explicit "I manage this club" act. Invoked from - * `ManagedClubRoutes` (and so `ccas manage|unmanage|managed`). No `ChessComClient`: the club must already exist + * `ManagedClubRoutes` (and so `ccas club add|remove|list`). No `ChessComClient`: the club must already exist * locally; this never fetches Chess.com. */ object ManagedClubApp { diff --git a/src/main/scala/ccas/cli/CliCommand.scala b/src/main/scala/ccas/cli/CliCommand.scala index 8495077..58c6b96 100644 --- a/src/main/scala/ccas/cli/CliCommand.scala +++ b/src/main/scala/ccas/cli/CliCommand.scala @@ -10,8 +10,9 @@ import zio.cli.* * * Club targeting is via the `--club ` option (comma-separated on `membership`/`history`), with `--all` on those * two to mean every managed club; when neither is given the command falls back to the config's `current_club` (set - * with `ccas use-club`). There are no positional club slugs — the remaining positionals are `...` on the - * blacklist commands, the `` of `ccas use-club`, and the optional `[run-id]` on `ccas recruit --report`. + * with `ccas use-club`). There are no positional club slugs on the operation commands — the remaining positionals are + * `...` on the blacklist commands, the `` of `ccas club add`/`remove`, the optional `[slug]` of + * `ccas use-club` (absent = print the current club), and the optional `[run-id]` on `ccas recruit --report`. * * IMPORTANT — option/argument ordering: zio-cli expects all options BEFORE positional arguments, e.g. * `ccas blacklist add --club team-alpha alice bob`. Options placed AFTER a positional are NOT errors — they are @@ -39,7 +40,7 @@ object CliCommand { case object Stop extends CliCommand case object ServerStatus extends CliCommand final case class Completion(shell: String) extends CliCommand - final case class Use(slug: String) extends CliCommand + final case class Use(slugs: List[String], clear: Boolean) extends CliCommand final case class ConfigGet(key: String) extends CliCommand final case class ConfigSet(key: String, value: String) extends CliCommand final case class ConfigUnset(key: String) extends CliCommand @@ -156,11 +157,11 @@ object CliCommand { // Single-club target. Absent → Dispatcher falls back to the config's `current_club`. private val clubOpt: Options[Option[String]] = - Options.text("club").optional ?? "Club slug (URL name); falls back to the current club (set with 'ccas use')" + Options.text("club").optional ?? "Club slug (URL name); falls back to the current club (set with 'ccas use-club')" // Multi-club target: comma-separated slugs. Absent (and without --all) → Dispatcher falls back to `current_club`. private val clubsOpt: Options[List[String]] = - (Options.text("club").optional ?? "Comma-separated club slugs; falls back to the current club (set with 'ccas use')") + (Options.text("club").optional ?? "Comma-separated club slugs; falls back to the current club (set with 'ccas use-club')") .map(_.fold(List.empty[String])(_.split(",").toList.map(_.trim).filter(_.nonEmpty))) private val allOpt: Options[Boolean] = @@ -192,10 +193,22 @@ object CliCommand { .withHelp("Run and manage the ccas backend HTTP server") .subcommands(serverUp, serverDown, serverStatus) + // The slug is optional so a bare `ccas use-club` PRINTS the current club rather than erroring — the only read path + // for a value that is otherwise write-only (`git branch` / `kubectl config current-context` shape). `--clear` drops + // it; passing both is rejected as conflicting intent, mirroring `--all` vs `--club`. + // + // `repeat` rather than `atMost(1)` because `atMost(1)` silently TRUNCATES extra positionals instead of rejecting + // them. Combined with the zio-cli ordering bug documented at the top of this file — an option written after a + // positional is swallowed as another positional — `ccas use-club team-alpha --clear` would parse as a plain set and + // silently set the very club the user asked to clear. Capturing every positional lets `UseClub` reject the arity and + // point at the right ordering. private val useClub: Command[CliCommand] = - Command("use-club", Args.text("slug") ?? "Club slug (URL name) to set as the current club") - .withHelp("Set the current club used by commands that omit --club") - .map(Use.apply) + Command( + "use-club", + Options.boolean("clear") ?? "Clear the current club instead of setting one", + (Args.text("slug") ?? "Club slug (URL name) to set as the current club; omit to print the current one").repeat + ).withHelp("Show, set, or clear the current club used by commands that omit --club") + .map { case (clear, slugs) => Use(slugs, clear) } private def membership(default: String): Command[CliCommand] = Command("membership", serverOpt(default) ++ trustOpt ++ clubsOpt ++ allOpt ++ noProgressOpt ++ detachOpt) @@ -354,9 +367,11 @@ object CliCommand { .withHelp("List the clubs you manage with CCAS") .map(ClubsList.apply) + // The group help names `use-club` because the two are easy to confuse and live on opposite sides of the tree: this + // group edits the server-side managed set, while `use-club` is a local pointer at which of them commands target. private def clubs(default: String): Command[CliCommand] = Command("club") - .withHelp("Manage the set of clubs you run CCAS for") + .withHelp("Manage the set of clubs you run CCAS for (pick the one commands target with 'ccas use-club')") .subcommands(clubsAdd(default), clubsRemove(default), clubsList(default)) private val completion: Command[CliCommand] = diff --git a/src/main/scala/ccas/cli/CompletionCache.scala b/src/main/scala/ccas/cli/CompletionCache.scala index 52bcd33..18f3534 100644 --- a/src/main/scala/ccas/cli/CompletionCache.scala +++ b/src/main/scala/ccas/cli/CompletionCache.scala @@ -1,22 +1,26 @@ package ccas.cli -import java.nio.file.Files +import java.nio.file.{Files, NoSuchFileException, Path} import java.nio.file.attribute.FileTime -import java.time.Instant import scala.jdk.CollectionConverters.* -import zio.{UIO, ZIO} +import zio.{Clock, UIO, ZIO} import zio.json.{DeriveJsonDecoder, JsonDecoder} /** Maintains the cache files the generated shell completions read — club slugs and recent job ids. Every operation is * best-effort: IO errors are swallowed and never change a command's exit code (completion is a convenience, not * correctness). Files live under [[XdgPaths.cacheDir]], matching the paths the emitted scripts read. + * + * Each operation has a package-private `…In` variant taking the target file explicitly. [[XdgPaths]] resolves its + * directories from environment variables, which a JVM can't rebind at runtime, so the public no-arg entry points would + * otherwise only be exercisable against the developer's real `~/.cache/ccas` — the parameterised forms are what the + * tests drive. */ object CompletionCache { - // Refresh the clubs cache only when missing or older than this, so a /api/clubs round-trip isn't added to every - // command — just an occasional one. + // Refresh the clubs cache only when missing or older than this, so a /api/managed-clubs round-trip isn't added to + // every command — just an occasional one. private val ClubsTtlMillis: Long = 6L * 60 * 60 * 1000 // Cap on retained recent job ids (newest first). @@ -34,55 +38,102 @@ object CompletionCache { } /** True when the clubs cache is absent or older than the TTL (any IO error is treated as "stale" → refresh). */ - def clubsStale: UIO[Boolean] = - ZIO.attemptBlocking { - val f = XdgPaths.clubsFile - !Files.exists(f) || { - val ageMillis = Instant.now().toEpochMilli - Files.getLastModifiedTime(f).toInstant.toEpochMilli - ageMillis > ClubsTtlMillis - } - }.orElseSucceed(true) + def clubsStale: UIO[Boolean] = clubsStaleIn(XdgPaths.clubsFile) - /** Read the cached club slugs (one per line), best-effort: any IO error or a missing file yields `Nil`. Used by - * `ccas use-club` for an offline "unknown slug" warning — never a source of truth, just a typo hint. + /** Read the cached club slugs (one per line). Used by `ccas use-club` for an offline "unknown slug" hint — never a + * source of truth. See [[readClubsIn]] for why this is an `Option` rather than a bare list. */ - def readClubs: UIO[List[String]] = - ZIO.attemptBlocking { - val f = XdgPaths.clubsFile - if (Files.exists(f)) { Files.readAllLines(f).asScala.toList.map(_.trim).filter(_.nonEmpty) } - else { Nil } - }.orElseSucceed(Nil) + def readClubs: UIO[Option[List[String]]] = readClubsIn(XdgPaths.clubsFile) - /** Overwrite the clubs cache with one slug per line (the endpoint already sorts them). */ - def writeClubs(slugs: List[String]): UIO[Unit] = - ZIO.attemptBlocking { - Files.createDirectories(XdgPaths.cacheDir) - Files.writeString(XdgPaths.clubsFile, slugs.mkString("", "\n", if (slugs.isEmpty) "" else "\n")) - }.ignore + /** Overwrite the clubs cache with one slug per line (the endpoint already sorts them). `false` means the write failed + * and the cache is unchanged, so completion won't reflect this list — an unwritable cache directory otherwise leaves + * completion permanently dead with nothing said. Callers for whom the refresh is incidental should discard the + * result; a caller acting on an explicit request about club setup should report it. Never fails. + */ + def writeClubs(slugs: List[String]): UIO[Boolean] = writeClubsIn(XdgPaths.clubsFile, slugs) /** Seed the clubs cache from the config's `default_clubs` so completion has suggestions before any server round-trip. - * No-op when the list is empty or the cache already exists (an authoritative `/api/clubs` refresh must win). The - * seed file's mtime is stamped to the epoch so [[clubsStale]] still treats it as stale — the next server-touching - * command replaces it with real slugs rather than trusting the seed for the full TTL. */ - def seedClubs(clubs: List[String]): UIO[Unit] = + def seedClubs(clubs: List[String]): UIO[Unit] = seedClubsIn(XdgPaths.clubsFile, clubs) + + /** Delete the clubs cache so the next [[clubsStale]] check refreshes it, bypassing the TTL. Called when the managed + * set changes under us (`club add`/`remove`) and when a submit fails with "Club not found" — the cached slug the + * user just tab-completed is very likely the stale one, and waiting out the 6h TTL would re-suggest it on retry. + */ + def invalidate: UIO[Unit] = invalidateIn(XdgPaths.clubsFile) + + /** Prepend a job id (newest first), dropping any earlier duplicate and capping the list at [[MaxRecentJobs]]. */ + def appendJob(jobId: String): UIO[Unit] = appendJobIn(XdgPaths.recentJobsFile, jobId) + + // --- Path-explicit forms (see the object scaladoc) --- + + /** Reads the time via `Clock.instant` rather than `Instant.now()` so the TTL boundary is testable under `TestClock`. + */ + private[cli] def clubsStaleIn(file: Path): UIO[Boolean] = + Clock.instant.flatMap(now => + ZIO.attemptBlocking { + !Files.exists(file) || { + val ageMillis = now.toEpochMilli - Files.getLastModifiedTime(file).toInstant.toEpochMilli + ageMillis > ClubsTtlMillis + } + }.orElseSucceed(true) + ) + + /** `Some(slugs)` when the cache was read — an empty list then genuinely means "no clubs cached". `None` when the file + * is there but unreadable (bad permissions, corrupt bytes, not a regular file). + * + * The distinction matters: "I know of no clubs" and "I could not find out" are different answers, and collapsing + * them to `Nil` makes the second silently indistinguishable from the first — so the *least* certain state produces + * the *least* output. An unreadable cache is also a persistent, user-fixable condition that breaks shell completion + * too (the generated scripts `cat` this same file), so callers should say something rather than shrug. Still never + * fails: a broken cache must not change any command's exit code. A file that vanishes mid-read counts as absent, not + * unreadable — that is a benign race with `invalidate`, not a misconfiguration. + */ + private[cli] def readClubsIn(file: Path): UIO[Option[List[String]]] = + ZIO + .attemptBlocking { + if (Files.exists(file)) { Files.readAllLines(file).asScala.toList.map(_.trim).filter(_.nonEmpty) } + else { Nil } + } + .map(Some(_)) + .catchSome { case _: NoSuchFileException => ZIO.some(Nil) } + .orElseSucceed(None) + + private[cli] def writeClubsIn(file: Path, slugs: List[String]): UIO[Boolean] = + ZIO.attemptBlocking { + createParent(file) + val trailing = if (slugs.isEmpty) { "" } else { "\n" } + Files.writeString(file, slugs.mkString("", "\n", trailing)) + }.isSuccess + + /** No-op when the list is empty or the cache already exists (an authoritative refresh must win). The seed file's + * mtime is stamped to the epoch so [[clubsStaleIn]] still treats it as stale — the next server-touching command + * replaces it with real slugs rather than trusting the seed for the full TTL. + */ + private[cli] def seedClubsIn(file: Path, clubs: List[String]): UIO[Unit] = ZIO.attemptBlocking { - val f = XdgPaths.clubsFile - if (clubs.nonEmpty && !Files.exists(f)) { - Files.createDirectories(XdgPaths.cacheDir) - Files.writeString(f, clubs.mkString("", "\n", "\n")) - Files.setLastModifiedTime(f, FileTime.fromMillis(0L)) + if (clubs.nonEmpty && !Files.exists(file)) { + createParent(file) + Files.writeString(file, clubs.mkString("", "\n", "\n")) + Files.setLastModifiedTime(file, FileTime.fromMillis(0L)) () } }.ignore - /** Prepend a job id (newest first), dropping any earlier duplicate and capping the list at [[MaxRecentJobs]]. */ - def appendJob(jobId: String): UIO[Unit] = + private[cli] def invalidateIn(file: Path): UIO[Unit] = + ZIO.attemptBlocking(Files.deleteIfExists(file)).unit.ignore + + private[cli] def appendJobIn(file: Path, jobId: String): UIO[Unit] = ZIO.attemptBlocking { - val f = XdgPaths.recentJobsFile - Files.createDirectories(XdgPaths.cacheDir) - val existing = if (Files.exists(f)) Files.readAllLines(f).asScala.toList.filter(_.nonEmpty) else Nil + createParent(file) + val existing = + if (Files.exists(file)) { Files.readAllLines(file).asScala.toList.filter(_.nonEmpty) } else { Nil } val updated = (jobId :: existing.filterNot(_ == jobId)).take(MaxRecentJobs) - Files.writeString(f, updated.mkString("", "\n", "\n")) + Files.writeString(file, updated.mkString("", "\n", "\n")) }.ignore + + private def createParent(file: Path): Unit = { + Option(file.getParent).foreach(Files.createDirectories(_)) + () + } } diff --git a/src/main/scala/ccas/cli/CompletionSpec.scala b/src/main/scala/ccas/cli/CompletionSpec.scala index 8d81af6..c1a89c2 100644 --- a/src/main/scala/ccas/cli/CompletionSpec.scala +++ b/src/main/scala/ccas/cli/CompletionSpec.scala @@ -47,7 +47,7 @@ object CompletionSpec { Leaf(List("server", "up"), List("--detach", "-d"), Nil, NoArgs), Leaf(List("server", "down"), Nil, Nil, NoArgs), Leaf(List("server", "status"), Nil, Nil, NoArgs), - Leaf(List("use-club"), Nil, Nil, Slug), + Leaf(List("use-club"), List("--clear"), Nil, Slug), Leaf( List("membership"), List(server, "--trust-usernames", "--no-trust-usernames", clubFlag, "--all", "--no-progress", "--detach"), @@ -115,7 +115,8 @@ object CompletionSpec { Group("server", List("up", "down", "status"), "Run and manage the ccas backend HTTP server"), Group("blacklist", List("add", "list", "remove"), "Manage a club's recruitment blacklist"), Group("schedule", List("list", "add", "remove"), "Manage scheduled jobs"), - Group("club", List("add", "remove", "list"), "Manage the set of clubs you run CCAS for"), + Group("club", List("add", "remove", "list"), + "Manage the set of clubs you run CCAS for (pick the one commands target with 'ccas use-club')"), Group("config", List("get", "set", "unset", "list", "show", "path", "init"), "Manage the local server-bootstrap config file (ccas.env)") ) @@ -149,7 +150,7 @@ object CompletionSpec { List("server", "up") -> "Run the ccas backend HTTP server (foreground by default; --detach/-d to background it)", List("server", "down") -> "Stop a detached ccas server (reads the pid file and sends SIGTERM)", List("server", "status") -> "Report whether the local ccas server is running and ready", - List("use-club") -> "Set the current club used by commands that omit --club", + List("use-club") -> "Show, set, or clear the current club used by commands that omit --club", List("membership") -> "Submit a membership-sync job (current club, --club a,b, or --all managed clubs)", List("history") -> "Submit a match-history crawl job (current club, --club a,b, or --all managed clubs)", List("recruit") -> "Submit a recruitment scouting job for a club", diff --git a/src/main/scala/ccas/cli/Dispatcher.scala b/src/main/scala/ccas/cli/Dispatcher.scala index 5d21434..f512657 100644 --- a/src/main/scala/ccas/cli/Dispatcher.scala +++ b/src/main/scala/ccas/cli/Dispatcher.scala @@ -8,8 +8,8 @@ import zio.http.Client import ccas.api.misc.subtypes.{ClubSlug, Username} import ccas.api.player.ApiPlayer +import ccas.cli.config.ConfigWriter import ccas.server.routes.BlacklistRoutes.{BlacklistEntryResponse, CreateBlacklistRequest} -import ccas.server.routes.ManagedClubRoutes.{ManagedClubResponse, MarkManagedRequest} import ccas.server.routes.JobRoutes.{ CancelResult, ClubJobResult, @@ -22,6 +22,7 @@ import ccas.server.routes.JobRoutes.{ RecruitmentRequest, StatsRequest } +import ccas.server.routes.ManagedClubRoutes.{ManagedClubResponse, MarkManagedRequest} import ccas.server.routes.ScheduleRoutes.{CreateScheduleRequest, ScheduleResponse} import ccas.server.scheduler.{MisfirePolicy, TriggerType} @@ -76,15 +77,26 @@ object Dispatcher { // Best-effort, staleness-gated refresh of the completion club-slug cache after a successful command. Fully ignored: // it never blocks the result or alters the exit code (the `.tap` runs only on the success channel). private def refreshClubsCache(api: CcasApiClient): UIO[Unit] = - CompletionCache.clubsStale.flatMap { stale => - ZIO.whenDiscard(stale) { - api - .getJson[CompletionCache.ClubsDto]("/api/clubs") - .map(_.clubs.map(_.slug)) - .flatMap(CompletionCache.writeClubs) - .ignore - } - } + CompletionCache.clubsStale.flatMap(stale => ZIO.whenDiscard(stale)(writeClubsCache(api))) + + // Prefer the managed set: those are the only clubs `--all` expands to and the only ones that keep scheduled jobs, so + // they're what `--club` completion should offer. `/api/clubs` is every club ever ingested — a few history crawls put + // it in the thousands, almost all opponents and scouted clubs — so it serves only as a fallback for a fresh install + // with nothing managed yet, leaving completion useful before the first `club add`. + private def writeClubsCache(api: CcasApiClient): UIO[Unit] = + managedSlugs(api) + .flatMap(slugs => + if (slugs.nonEmpty) { ZIO.succeed(slugs) } + else { allClubSlugs(api) } + ) + .flatMap(CompletionCache.writeClubs) + .ignore + + private def managedSlugs(api: CcasApiClient): Task[List[String]] = + api.getJson[List[ManagedClubResponse]]("/api/managed-clubs").map(_.map(_.slug)) + + private def allClubSlugs(api: CcasApiClient): Task[List[String]] = + api.getJson[CompletionCache.ClubsDto]("/api/clubs").map(_.clubs.map(_.slug)) private def runCommand( api: CcasApiClient, @@ -94,7 +106,7 @@ object Dispatcher { ): Task[Int] = cmd match { case CliCommand.Membership(_, clubs, all, trust, _, detach) => resolveClubs(api, clubs, all, currentClub).flatMap(slugs => - followEachClub(follower, slugs, detach)(slug => + followEachClub(follower, slugs, detach, currentClub)(slug => api.postJson[MembershipRequest, List[ClubJobResult]]( "/api/jobs/membership", MembershipRequest(NonEmptyChunk.single(slug), trust) @@ -104,15 +116,34 @@ object Dispatcher { case CliCommand.History(_, clubs, all, full, includeFinished, refresh, refreshMinHours, _, detach) => resolveClubs(api, clubs, all, currentClub).flatMap(slugs => - followEachClub(follower, slugs, detach)(slug => + followEachClub(follower, slugs, detach, currentClub)(slug => api.postJson[HistoryRequest, List[ClubJobResult]]( "/api/jobs/history", - HistoryRequest(NonEmptyChunk.single(slug), flag(full), flag(includeFinished), flag(refresh), refreshMinHours) + HistoryRequest( + NonEmptyChunk.single(slug), + flag(full), + flag(includeFinished), + flag(refresh), + refreshMinHours + ) ) ) ) - case CliCommand.Recruit(_, club, alias, target, cumulative, sourceClubs, timeLimitMinutes, explore, stdout, report, runId, _) => + case CliCommand.Recruit( + _, + club, + alias, + target, + cumulative, + sourceClubs, + timeLimitMinutes, + explore, + stdout, + report, + runId, + _ + ) => // Guard the read-only flag: a run-id argument without `--report` would otherwise be silently dropped and launch // a fresh scout — and with `--stdout` it would auto-confirm that scout's invites. Fail before submitting. if (runId.isDefined && !report) { ZIO.fail(CliError("a run id can only be given with --report", 2)) } @@ -136,9 +167,10 @@ object Dispatcher { explore, Option.unless(stdout)(false) ) - ).flatMap(result => - handleRecruitResult(follower, api, ClubSlug.unwrap(slug), result, stdout, interactiveConfirm) - ) + ).tap(result => noteMissingClubs(missingFor(slug, result), currentClub)) + .flatMap(result => + handleRecruitResult(follower, api, ClubSlug.unwrap(slug), result, stdout, interactiveConfirm) + ) ) } @@ -147,10 +179,11 @@ object Dispatcher { api.postJson[StatsRequest, ClubJobResult]( "/api/jobs/stats", StatsRequest(slug, since, until) - ).flatMap(result => - if (detach) { reportDetachedClub(result) } - else { follower.handleClubSingle(result) } - ) + ).tap(result => noteMissingClubs(missingFrom(List(result)), currentClub)) + .flatMap(result => + if (detach) { reportDetachedClub(result) } + else { follower.handleClubSingle(result) } + ) ) case CliCommand.Jobs(_, limit) => @@ -204,26 +237,83 @@ object Dispatcher { case CliCommand.ScheduleRemove(_, id) => api.delete(s"/api/schedules/$id") *> Console.printLine(s"deleted schedule $id").orDie.as(0) + // Both mutate the managed set, which IS the completion cache's source — drop it so the post-command refresh + // repopulates immediately instead of serving the pre-change list for up to the 6h TTL. case CliCommand.ClubsAdd(_, slug) => val add = ClubSlug(slug.trim) api.postUnit[MarkManagedRequest]("/api/managed-clubs", MarkManagedRequest(add)) *> - Console.printLine(s"now managing ${ClubSlug.unwrap(add)}").orDie.as(0) + Console.printLine(s"now managing ${ClubSlug.unwrap(add)}").orDie *> + CompletionCache.invalidate.as(0) case CliCommand.ClubsRemove(_, slug) => val remove = ClubSlug(slug.trim) - api.delete(s"/api/managed-clubs/${ClubSlug.unwrap(remove)}") *> - Console.printLine(s"stopped managing ${ClubSlug.unwrap(remove)}").orDie.as(0) + for { + _ <- api.delete(s"/api/managed-clubs/${ClubSlug.unwrap(remove)}") + _ <- Console.printLine(s"stopped managing ${ClubSlug.unwrap(remove)}").orDie + _ <- clearCurrentIfRemoved(remove, currentClub) + _ <- CompletionCache.invalidate + } yield 0 case CliCommand.ClubsList(_) => - api.getJson[List[ManagedClubResponse]]("/api/managed-clubs").flatMap(clubs => printManagedClubs(clubs).as(0)) + api + .getJson[List[ManagedClubResponse]]("/api/managed-clubs") + .flatMap(clubs => printManagedClubs(clubs, currentClub).as(0)) } - private def printManagedClubs(clubs: List[ManagedClubResponse]): UIO[Unit] = - if (clubs.isEmpty) { Console.printLine("no managed clubs").orDie } - else { - ZIO.foreachDiscard(clubs)(c => Console.printLine(s"${c.slug} ${c.name} marked=${c.markedAt}").orDie) + // Unmanaging leaves the `club` row intact and job submission gates on that row, not on managed status — so a + // `current_club` still pointing at the removed club would keep silently running real jobs against it (while `--all` + // correctly skips it). Clear the pointer so the next bare command fails loudly with `ClubResolver.NoClubError`. + // Compare case-insensitively on the trimmed slug: `ClubSlug.normalize` lowercases but does not trim. + private def clearCurrentIfRemoved(removed: ClubSlug, currentClub: Option[String]): UIO[Unit] = + ZIO.whenDiscard(currentClub.exists(sameSlug(_, ClubSlug.unwrap(removed)))) { + ConfigWriter + .clearCurrentClub(XdgPaths.configFile) + .foldZIO(clearFailed, _ => currentClubCleared) } + private def currentClubCleared: UIO[Unit] = + Console.printLine("that was your current club; cleared it — set a new one with 'ccas use-club '").orDie + + // Don't fail the command: the removal itself succeeded server-side. Say what's left dangling and how to fix it. + private def clearFailed(e: Throwable): UIO[Unit] = + Console + .printLineError( + s"warning: that was your current club, but clearing it failed (${rootMessage(e)}); " + + "clear it with 'ccas use-club --clear'" + ) + .orDie + + private def sameSlug(a: String, b: String): Boolean = a.trim.equalsIgnoreCase(b.trim) + + // The warning is deliberately outside the branch: an empty managed set is precisely when "your current club isn't + // managed" is *certainly* true, so suppressing it there hid the clearest case (and disagreed with `use-club`, which + // warns in that same server state). + private def printManagedClubs(clubs: List[ManagedClubResponse], currentClub: Option[String]): UIO[Unit] = { + val listing = + if (clubs.isEmpty) { Console.printLine("no managed clubs").orDie } + else { ZIO.foreachDiscard(clubs)(c => Console.printLine(clubLine(c, currentClub)).orDie) } + listing *> warnUnmanagedCurrent(clubs, currentClub) + } + + // `*` marks the club bare commands target, so `club list` answers "which one am I on?" as well as "which do I have?". + private def clubLine(c: ManagedClubResponse, currentClub: Option[String]): String = { + val marker = if (currentClub.exists(sameSlug(_, c.slug))) { "*" } + else { " " } + s"$marker ${c.slug} ${c.name} marked=${c.markedAt}" + } + + // A current club outside the managed set still resolves today, so it can't be silently omitted from the listing + // without leaving the user wondering which club their bare commands actually hit. + private def warnUnmanagedCurrent(clubs: List[ManagedClubResponse], currentClub: Option[String]): UIO[Unit] = + ZIO.foreachDiscard(currentClub.filterNot(cur => clubs.exists(c => sameSlug(cur, c.slug))))(cur => + Console + .printLineError( + s"warning: current club '$cur' is not in this list; add it with 'ccas club add $cur' " + + "or switch with 'ccas use-club '" + ) + .orDie + ) + // Run each club through submit-then-follow ONE at a time, so a multi-club (`--all`) run streams each club's logs and // bars live in turn rather than following the first while the rest run blind and dump their logs at the end. The next // club isn't submitted until the current one's follow completes; overall exit is 0 only if every club succeeded. Each @@ -237,20 +327,32 @@ object Dispatcher { // Each club is best-effort: a submit / transport failure (a 4xx/5xx or a dropped connection for one club) is caught, // reported, and scored as a failure for that club WITHOUT aborting the rest — matching `handleBatch`'s handling of // per-club errors in a 200 body, and preserving the old batch design's "every club still runs" property. - private def followEachClub(follower: JobFollower, slugs: NonEmptyChunk[ClubSlug], detach: Boolean)( + private def followEachClub( + follower: JobFollower, + slugs: NonEmptyChunk[ClubSlug], + detach: Boolean, + currentClub: Option[String] + )( submitOne: ClubSlug => Task[List[ClubJobResult]] ): Task[Int] = { val handle: List[ClubJobResult] => Task[Int] = if (detach) { results => - ZIO.foreach(results)(reportDetachedClub).map(codes => if (codes.forall(_ == 0)) { 0 } else { 1 }) + ZIO.foreach(results)(reportDetachedClub).map(codes => + if (codes.forall(_ == 0)) { 0 } + else { 1 } + ) } else { follower.handleBatch } ZIO .foreach(slugs.toChunk.toList)(slug => submitOne(slug) + .tap(results => noteMissingClubs(missingFrom(results), currentClub)) .flatMap(handle) .catchAll(e => Console.printLineError(s"${ClubSlug.unwrap(slug)}: ${rootMessage(e)}").orDie.as(1)) ) - .map(codes => if (codes.forall(_ == 0)) { 0 } else { 1 }) + .map(codes => + if (codes.forall(_ == 0)) { 0 } + else { 1 } + ) } // Detached submit of a single club-scoped job (#170): print its id (or its submit error) and DON'T follow. Cache the @@ -265,6 +367,37 @@ object Dispatcher { case _ => Console.printLineError(s"${result.clubSlug}: server returned no job id").orDie.as(1) } + // The server gates submission on an exact-slug `Club.selectBySlug` before any rename recovery can run, so a club + // renamed on Chess.com since our last cache refresh comes back as "Club not found" — indistinguishable from a typo, + // and the dead slug is very likely what completion just suggested. Two response shapes carry it: `ClubJobResult` + // ("Club not found") and `JobResult` ("Club not found: "), hence the prefix match. + private val ClubNotFound = "Club not found" + + private def missingClub(error: Option[String]): Boolean = error.exists(_.startsWith(ClubNotFound)) + + private def missingFrom(results: List[ClubJobResult]): List[String] = + results.filter(r => missingClub(r.error)).map(_.clubSlug) + + private def missingFor(slug: ClubSlug, result: JobResult): List[String] = + if (missingClub(result.error)) { List(ClubSlug.unwrap(slug)) } + else { Nil } + + // Drop the cache so the post-command refresh repopulates it — the 6h TTL would otherwise re-suggest the same dead + // slug on an immediate retry — and call out a stranded `current_club`, which nothing else ever repoints. + private def noteMissingClubs(missing: List[String], currentClub: Option[String]): UIO[Unit] = + ZIO.whenDiscard(missing.nonEmpty) { + CompletionCache.invalidate *> + ZIO.foreachDiscard(currentClub.filter(cur => missing.exists(sameSlug(cur, _))))(staleCurrentClubHint) + } + + private def staleCurrentClubHint(slug: String): UIO[Unit] = + Console + .printLineError( + s"note: '$slug' is your current club but the server doesn't know it — it may have been renamed on Chess.com. " + + "Point at the new slug with 'ccas use-club ', or drop it with 'ccas use-club --clear'." + ) + .orDie + // Resolution lives in the pure, testable `ClubResolver`; the `--all` expansion's network call is injected here. private def resolveClub(explicit: Option[String], currentClub: Option[String]): IO[CliError, ClubSlug] = ClubResolver.single(explicit, currentClub) @@ -337,9 +470,11 @@ object Dispatcher { private def confirmPrompt(api: CcasApiClient, jobId: String, names: List[String]): Task[Unit] = for { - _ <- Console.printLine(s"\nFound ${names.size} candidates:").orDie - _ <- ZIO.foreachDiscard(names)(n => Console.printLine(s" ${profileUrlLine(n)}").orDie) - answer <- ZIO.attemptBlocking(scala.io.StdIn.readLine(s"\nMark all ${names.size} as Invited and copy to clipboard? [Y/n] ")) + _ <- Console.printLine(s"\nFound ${names.size} candidates:").orDie + _ <- ZIO.foreachDiscard(names)(n => Console.printLine(s" ${profileUrlLine(n)}").orDie) + answer <- ZIO.attemptBlocking( + scala.io.StdIn.readLine(s"\nMark all ${names.size} as Invited and copy to clipboard? [Y/n] ") + ) .orElseSucceed("n") _ <- if (isYes(answer)) { confirmAndCopy(api, jobId) } @@ -471,8 +606,8 @@ object Dispatcher { pb.redirectError(ProcessBuilder.Redirect.DISCARD) val process = pb.start() val stdin = process.getOutputStream - try { stdin.write(payload.getBytes(StandardCharsets.UTF_8)) } - finally { stdin.close() } + try stdin.write(payload.getBytes(StandardCharsets.UTF_8)) + finally stdin.close() process.waitFor() == 0 }.orElseSucceed(false) diff --git a/src/main/scala/ccas/cli/Main.scala b/src/main/scala/ccas/cli/Main.scala index ecd3e11..2161ff2 100644 --- a/src/main/scala/ccas/cli/Main.scala +++ b/src/main/scala/ccas/cli/Main.scala @@ -62,7 +62,11 @@ object Main extends ZIOAppDefault { case CliCommand.Stop => stopServe case CliCommand.ServerStatus => statusServe case CliCommand.Completion(shell) => printCompletion(shell) - case CliCommand.Use(slug) => UseClub.run(slug) + case CliCommand.Use(slugs, clear) => + // Same server the `--server`-less commands resolve to (config `api_url`, else the built-in default): use-club has + // no `--server` flag of its own — its network use is a best-effort probe against the configured server, not a + // server operation — so it inherits the same default the command tree was built with. + UseClub.run(slugs, clear, cfg.currentClub, cfg.apiUrl.getOrElse(CliCommand.DefaultServer)) case CliCommand.ConfigGet(key) => ConfigCommand.get(key) case CliCommand.ConfigSet(key, v) => ConfigCommand.set(key, v) case CliCommand.ConfigUnset(key) => ConfigCommand.unset(key) diff --git a/src/main/scala/ccas/cli/UseClub.scala b/src/main/scala/ccas/cli/UseClub.scala index 8802ac8..a399eb1 100644 --- a/src/main/scala/ccas/cli/UseClub.scala +++ b/src/main/scala/ccas/cli/UseClub.scala @@ -1,38 +1,183 @@ package ccas.cli -import zio.{Console, ExitCode, UIO, ZIO} +import zio.{Console, Duration, ExitCode, UIO, ZIO} +import zio.http.Client import ccas.cli.config.ConfigWriter +import ccas.server.routes.ManagedClubRoutes.ManagedClubResponse -/** `ccas use-club ` — a purely local command that records the current club in the CLI config file (no network). On - * success it best-effort warns if the slug isn't among the offline completion-cache clubs (a likely typo), but never - * blocks the write: a brand-new user — or any club CCAS hasn't fetched yet — has nothing to validate against, so a - * miss is a hint, not an error. +/** `ccas use-club [slug] [--clear]` — a per-machine pointer at the club that commands target when they omit `--club`. + * + * Three modes, chosen by the arguments: no slug prints the current club (the only read path for a value that is + * otherwise write-only, and which nothing else in the CLI ever echoes); `--clear` drops it; a slug sets it. + * + * The config write is the command's identity and always succeeds — it works with the server down, which is why this + * stays a top-level local command rather than a `ServerCommand` under `club`. On a *set*, and only when a server is + * reachable within a short timeout, it additionally fetches the live managed set to (a) refresh the completion cache so + * it isn't stale for the club just chosen, and (b) give a definitive "not one of your managed clubs" note instead of a + * guess against a possibly-stale cache. Both are best-effort: any transport error, or an unreachable server, falls back + * to the offline cache hint and never blocks the write. Verification is managed-vs-not only; distinguishing a typo from + * a real-but-unmanaged club needs a single-club existence check the server does not yet expose. */ object UseClub { - def run(slug: String): UIO[ExitCode] = { + // A local pointer write must not hang on a dead server, so the verification probe is tightly bounded; on timeout it is + // simply treated as "couldn't verify" and the offline path takes over. + private val VerifyTimeout: Duration = Duration.fromSeconds(2) + + /** Outcome of the best-effort managed-set check. `Unverified` means the server couldn't be reached (or timed out), not + * that the club is bad — the offline cache hint stands in for it. + */ + private[cli] enum Verify { + case Managed, Unmanaged, Unverified + } + + def run(slugs: List[String], clear: Boolean, currentClub: Option[String], server: String): UIO[ExitCode] = + slugs match { + case Nil if clear => clearCurrent(currentClub) + case Nil => showCurrent(currentClub) + case _ :: Nil if clear => usageError("--clear takes no slug; pass one or the other") + case single :: Nil => setCurrent(single, server) + case extra => tooManySlugs(extra) + } + + // More than one positional is always a mistake, but the *likely* mistake is an option written after the slug, which + // zio-cli swallows as a positional — so `ccas use-club team-alpha --clear` lands here rather than in the guard above. + // Naming the offending tokens and the working order turns a silently-wrong action into a fixable message. + private def tooManySlugs(slugs: List[String]): UIO[ExitCode] = { + val flagLike = slugs.tail.filter(_.startsWith("-")) + val hint = + if (flagLike.nonEmpty) { + s"; options must come before the slug — try 'ccas use-club ${flagLike.mkString(" ")} ${slugs.head}'" + } else { "" } + usageError(s"expected at most one club slug, got ${slugs.size} (${slugs.mkString(", ")})$hint") + } + + // Exit 2 when unset so a script can branch on it, matching `ConfigCommand.printGet`'s convention for a missing key. + // A pure read — no network, no cache refresh — so it stays instant and offline. + private def showCurrent(currentClub: Option[String]): UIO[ExitCode] = + currentClub match { + case Some(s) => Console.printLine(s).orDie.as(ExitCode.success) + case None => + Console.printLineError("no current club set; set one with 'ccas use-club '").orDie.as(ExitCode(2)) + } + + // Clearing an already-absent value is a success, not an error — `--clear` states a desired end state. + private def clearCurrent(currentClub: Option[String]): UIO[ExitCode] = + currentClub match { + case None => Console.printLine("no current club set").orDie.as(ExitCode.success) + case Some(s) => + ConfigWriter + .clearCurrentClub(XdgPaths.configFile) + .foldZIO(saveFailed("clear current club"), _ => cleared(s)) + } + + private def setCurrent(slug: String, server: String): UIO[ExitCode] = { // Trim before storing: a slug never has surrounding whitespace, and `ClubSlug.normalize` only lowercases (no trim), // so a padded value would otherwise persist and later reach the API path verbatim. val s = slug.trim - if (s.isEmpty) { - Console.printLineError("error: club slug must not be blank").orDie.as(ExitCode(2)) - } else { + if (s.isEmpty) { usageError("club slug must not be blank") } + else { + // Write first, unconditionally: the local pointer is the guaranteed effect and must land even offline. The + // verification that follows only refreshes the cache and refines the advisory note. ConfigWriter .setCurrentClub(XdgPaths.configFile, s) - .foldZIO( - e => Console.printLineError(s"error: failed to save current club: ${rootMessage(e)}").orDie.as(ExitCode(1)), - _ => warnIfUnknown(s) *> Console.printLine(s"current club set to $s").orDie.as(ExitCode.success) - ) + .foldZIO(saveFailed("save current club"), _ => verifyThenConfirm(s, server)) } } - private def warnIfUnknown(slug: String): UIO[Unit] = - CompletionCache.readClubs.flatMap { known => - ZIO.whenDiscard(known.nonEmpty && !known.contains(slug)) { - Console.printLineError(s"warning: '$slug' not in known clubs (cache may be stale)").orDie - } + private def verifyThenConfirm(slug: String, server: String): UIO[ExitCode] = + for { + managed <- fetchManaged(server) + // A live list means the cache is now authoritative for this club too — write it so the false-alarm case (a + // managed club the cache hadn't yet learned) can't recur, and so completion picks the club up immediately. + // Only worth caching when the managed set has something in it. Writing an empty list would truncate the cache to + // zero bytes AND stamp a fresh mtime, which reads as "fresh" to `clubsStale` — suppressing `Dispatcher`'s refresh + // and its `/api/clubs` fallback for the whole TTL, while `seedClubs` no-ops because the file now exists. That + // fallback policy is `Dispatcher`'s to apply; this opportunistic write deliberately owns none of it and just + // steps aside. + written <- ZIO.foreach(managed.filter(_.nonEmpty))(CompletionCache.writeClubs) + _ <- ZIO.whenDiscard(written.contains(false))(cacheWriteFailed) + _ <- advise(slug, classify(slug, managed)) + _ <- Console.printLine(s"current club set to $slug").orDie + } yield ExitCode.success + + // Best-effort: build the CLI client, fetch the managed set, bound it, and collapse every failure (refused connection, + // timeout, non-2xx, decode error, defect) to None so the caller falls back to the offline path. + // + // Operator order is load-bearing. `timeout` must sit OUTSIDE `provide` or the layer's acquire/release runs unbounded, + // and `disconnect` is required because plain `timeout` waits for the interrupted fiber to finish unwinding — + // zio-http's `NettyConnectionPool.createChannel` is uninterruptible with a 30s connect default, so against a + // black-holed host the "2s" bound measured 31s before `disconnect` moved that unwinding into the background. + private def fetchManaged(server: String): UIO[Option[List[String]]] = + CcasApiClient + .live(server) + .flatMap(_.getJson[List[ManagedClubResponse]]("/api/managed-clubs").map(_.map(_.slug))) + .provide(Client.default) + .disconnect + .timeout(VerifyTimeout) + .catchAllCause(_ => ZIO.none) + + private[cli] def classify(slug: String, managed: Option[List[String]]): Verify = + managed match { + case Some(list) if list.exists(_.equalsIgnoreCase(slug)) => Verify.Managed + case Some(_) => Verify.Unmanaged + case None => Verify.Unverified + } + + private[cli] def advise(slug: String, verify: Verify): UIO[Unit] = + verify match { + case Verify.Managed => ZIO.unit + case Verify.Unmanaged => + note( + s"warning: '$slug' is not one of your managed clubs — bare commands still target it; " + + s"manage it with 'ccas club add $slug'" + ) + // Only this branch has no live answer, so only it pays for a cache read. + case Verify.Unverified => CompletionCache.readClubs.flatMap(offlineHint(slug, _)) + } + + // With no live answer the cache is all we have, so keep its two failure modes apart: a cache we read and that simply + // doesn't list the slug is a real (if weak) typo hint, while a cache we couldn't read at all is a broken cache — say + // so rather than staying silent, which would read as "looks fine" in the state we know least about. + // + // Both messages say "did not answer" rather than "was unreachable": `fetchManaged` collapses a refused connection, a + // timeout, a non-2xx status and a decode failure into the same `None`, so asserting unreachability would point an + // operator away from a server that is up but erroring. + private[cli] def offlineHint(slug: String, cached: Option[List[String]]): UIO[Unit] = + cached match { + case None => + note(s"warning: could not verify '$slug': the server did not answer and the club cache could not be read") + case Some(known) => + ZIO.whenDiscard(isUnknown(slug, known))( + note(s"warning: '$slug' not in the cached club list and the server did not answer to verify (may be stale)") + ) } + // Naming the path is the actionable part: an unwritable cache directory (a root-owned one left by a `sudo ccas` run + // is the usual cause) otherwise leaves tab-completion silently dead with nothing to pull on. Advisory only — the + // pointer is already written and the command still succeeds. + private def cacheWriteFailed: UIO[Unit] = + note( + s"warning: could not update the club cache at ${XdgPaths.clubsFile} — " + + "shell completion won't reflect your managed clubs until that is writable" + ) + + private def cleared(previous: String): UIO[ExitCode] = + Console.printLine(s"current club cleared (was $previous)").orDie.as(ExitCode.success) + + // An empty cache knows nothing, so it never warns. Case-insensitive because `ClubSlug.normalize` lowercases: a + // hand-typed `Team-Alpha` reaches the API as `team-alpha` and is not a typo. + private[cli] def isUnknown(slug: String, known: List[String]): Boolean = + known.nonEmpty && !known.exists(_.equalsIgnoreCase(slug)) + + private def note(message: String): UIO[Unit] = Console.printLineError(message).orDie + + private def usageError(message: String): UIO[ExitCode] = + Console.printLineError(s"error: $message").orDie.as(ExitCode(2)) + + private def saveFailed(action: String)(e: Throwable): UIO[ExitCode] = + Console.printLineError(s"error: failed to $action: ${rootMessage(e)}").orDie.as(ExitCode(1)) + private def rootMessage(e: Throwable): String = Option(e.getMessage).getOrElse(e.getClass.getSimpleName) } diff --git a/src/main/scala/ccas/cli/config/ConfigWriter.scala b/src/main/scala/ccas/cli/config/ConfigWriter.scala index 3565e8c..a953723 100644 --- a/src/main/scala/ccas/cli/config/ConfigWriter.scala +++ b/src/main/scala/ccas/cli/config/ConfigWriter.scala @@ -7,11 +7,11 @@ import scala.util.matching.Regex import zio.{Task, ZIO} -/** Writes the single `current_club` key into the CLI config file. zio-config (the read side, [[CliConfig]]) is a parser - * only — it has no HOCON writer — so this does a *surgical* line edit rather than serialising the whole config: it - * drops any existing top-level `current_club` assignment and appends a fresh one, leaving every other key, blank line, - * and comment untouched. `current_club` is always a simple quoted string, so a flat-key edit is sufficient (the - * descriptor only reads flat keys anyway). +/** Writes (and removes) the single `current_club` key in the CLI config file. zio-config (the read side, [[CliConfig]]) + * is a parser only — it has no HOCON writer — so this does a *surgical* line edit rather than serialising the whole + * config: it drops any existing top-level `current_club` assignment and, when setting, appends a fresh one, leaving + * every other key, blank line, and comment untouched. `current_club` is always a simple quoted string, so a flat-key + * edit is sufficient (the descriptor only reads flat keys anyway). */ object ConfigWriter { @@ -20,29 +20,50 @@ object ConfigWriter { private val CurrentClubAssignment: Regex = """^\s*current_club\s*[=:].*$""".r def setCurrentClub(file: Path, slug: String): Task[Unit] = + rewrite(file, Some(s"""current_club = "${escape(slug)}"""")) + + /** Drop the `current_club` assignment, leaving the rest of the config intact. Used by `ccas use-club --clear` and by + * `ccas club remove` when it unmanages the club the pointer names. + */ + def clearCurrentClub(file: Path): Task[Unit] = rewrite(file, None) + + // Shared surgical edit: strip any existing assignment, then append `assignment` if there is one. + private def rewrite(file: Path, assignment: Option[String]): Task[Unit] = ZIO.attemptBlocking { - val dir = Option(file.getParent).getOrElse(Paths.get(".")) - Files.createDirectories(dir) - val existing = if (Files.exists(file)) Files.readAllLines(file).asScala.toList else Nil - val kept = existing.filterNot(l => CurrentClubAssignment.matches(l)) - // Drop trailing blank lines so re-runs don't accumulate them before the re-appended key. - val trimmed = kept.reverse.dropWhile(_.trim.isEmpty).reverse - val out = (trimmed :+ s"""current_club = "${escape(slug)}"""").mkString("", "\n", "\n") - // Write to a sibling temp file, then rename over the target: same-dir rename is atomic on POSIX, so a crash or a - // concurrent writer can never leave a half-written or interleaved config — readers see the old or new file, whole. - // `createTempFile` yields owner-only (0600) perms, which the rename carries onto config.conf — fine, and tighter - // than the previous default-umask write for a file that may hold connection settings. - val tmp = Files.createTempFile(dir, "config", ".tmp") - try { - Files.writeString(tmp, out) - Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING) - } catch { - case e: Throwable => - Files.deleteIfExists(tmp) // don't leak the temp on a write/rename failure - throw e + val dir = Option(file.getParent).getOrElse(Paths.get(".")) + val exists = Files.exists(file) + // Clearing a config that was never written is a no-op: don't create an empty file just to remove nothing. + if (!exists && assignment.isEmpty) { () } + else { + Files.createDirectories(dir) + val existing = if (exists) Files.readAllLines(file).asScala.toList else Nil + val kept = existing.filterNot(l => CurrentClubAssignment.matches(l)) + // Drop trailing blank lines so re-runs don't accumulate them before the re-appended key. + val trimmed = kept.reverse.dropWhile(_.trim.isEmpty).reverse + val lines = trimmed ++ assignment + // An emptied config writes as a genuinely empty file, not a lone newline. + val out = if (lines.isEmpty) { "" } + else { lines.mkString("", "\n", "\n") } + writeAtomically(dir, file, out) } + } + + // Write to a sibling temp file, then rename over the target: same-dir rename is atomic on POSIX, so a crash or a + // concurrent writer can never leave a half-written or interleaved config — readers see the old or new file, whole. + // `createTempFile` yields owner-only (0600) perms, which the rename carries onto config.conf — fine, and tighter + // than a default-umask write for a file that may hold connection settings. + private def writeAtomically(dir: Path, file: Path, contents: String): Unit = { + val tmp = Files.createTempFile(dir, "config", ".tmp") + try { + Files.writeString(tmp, contents) + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING) () + } catch { + case e: Throwable => + Files.deleteIfExists(tmp) // don't leak the temp on a write/rename failure + throw e } + } private def escape(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"") } diff --git a/src/test/scala/ccas/cli/TestCliParser.scala b/src/test/scala/ccas/cli/TestCliParser.scala index 3e5e599..41ee78b 100644 --- a/src/test/scala/ccas/cli/TestCliParser.scala +++ b/src/test/scala/ccas/cli/TestCliParser.scala @@ -49,7 +49,26 @@ object TestCliParser extends ZIOSpecDefault { ) }, test("use-club parses the slug (local, no server)") { - parsed("use-club", "team-alpha").map(c => assertTrue(c.contains(CliCommand.Use("team-alpha")))) + parsed("use-club", "team-alpha").map(c => assertTrue(c.contains(CliCommand.Use(List("team-alpha"), false)))) + }, + test("bare use-club parses with no slug (the show-current form)") { + parsed("use-club").map(c => assertTrue(c.contains(CliCommand.Use(Nil, false)))) + }, + test("use-club --clear parses with no slug") { + parsed("use-club", "--clear").map(c => assertTrue(c.contains(CliCommand.Use(Nil, true)))) + }, + // Every positional must survive parsing so `UseClub` can reject the arity. `atMost(1)` silently dropped the extra, + // which (with zio-cli swallowing a trailing option as a positional) made `use-club --clear` a silent SET of + // the club the user asked to clear. + test("use-club keeps extra positionals so the arity can be rejected downstream") { + parsed("use-club", "team-a", "team-b").map(c => + assertTrue(c.contains(CliCommand.Use(List("team-a", "team-b"), false))) + ) + }, + test("use-club with --clear AFTER the slug keeps the flag as a positional (zio-cli ordering bug)") { + parsed("use-club", "team-alpha", "--clear").map(c => + assertTrue(c.contains(CliCommand.Use(List("team-alpha", "--clear"), false))) + ) }, test("config set parses key and value (local, no server)") { parsed("config", "set", "DB_HOST", "localhost").map(c => diff --git a/src/test/scala/ccas/cli/TestClubResolver.scala b/src/test/scala/ccas/cli/TestClubResolver.scala index 59101ae..52e6896 100644 --- a/src/test/scala/ccas/cli/TestClubResolver.scala +++ b/src/test/scala/ccas/cli/TestClubResolver.scala @@ -35,6 +35,13 @@ object TestClubResolver extends ZIOSpecDefault { test("single: a whitespace-only --club is treated as unset and falls back to current_club") { ClubResolver.single(Some(" "), Some("current")).map(s => assertTrue(ClubSlug.unwrap(s) == "current")) }, + // Pins the deliberate non-validation the auto-clear in `Dispatcher` exists to compensate for: an unmanaged (or + // renamed-away) current_club resolves verbatim, so nothing here catches a club the user has stopped managing. + test("single: returns an unmanaged current_club verbatim — it validates nothing") { + ClubResolver.single(None, Some("no-longer-managed")).map(s => + assertTrue(ClubSlug.unwrap(s) == "no-longer-managed") + ) + }, test("multi: explicit list wins, without fetching managed clubs") { ClubResolver.multi(mustNotFetch, List("a", "b"), all = false, Some("current")).map(cs => assertTrue(cs.map(ClubSlug.unwrap).toList == List("a", "b")) diff --git a/src/test/scala/ccas/cli/TestCompletionCache.scala b/src/test/scala/ccas/cli/TestCompletionCache.scala new file mode 100644 index 0000000..d83c01e --- /dev/null +++ b/src/test/scala/ccas/cli/TestCompletionCache.scala @@ -0,0 +1,189 @@ +package ccas.cli + +import java.nio.file.{Files, Path} +import java.nio.file.attribute.FileTime +import java.time.Instant + +import zio.{UIO, ZIO} +import zio.test.{assertTrue, Spec, TestClock, ZIOSpecDefault} + +/** Tests [[CompletionCache]] against temp files via its path-explicit `…In` forms — the public no-arg entry points + * resolve through [[XdgPaths]], whose environment-variable lookups a JVM can't rebind, so driving those would write to + * the developer's real `~/.cache/ccas`. + * + * The TTL boundary runs under `TestClock` (the reason `clubsStaleIn` reads `Clock.instant` rather than + * `Instant.now()`): mtime is pinned to a fixed epoch and the clock is moved to either side of the 6h window. + */ +object TestCompletionCache extends ZIOSpecDefault { + + private val Base: Instant = Instant.parse("2026-01-01T00:00:00Z") + private val SixHours: Long = 6L * 60 * 60 * 1000 + + // deleteOnExit is LIFO (same reasoning as TestCliConfig): register the dir first, then the leaves it will come to + // contain, so at JVM exit the leaves are removed before the dir's turn and it is empty by then. Registering the dir + // alone leaves it populated and the delete silently fails, littering /tmp with a tree per test. + private def tempDir: UIO[Path] = + ZIO.attemptBlocking { + val dir = Files.createTempDirectory("ccas-completion-cache") + dir.toFile.deleteOnExit() + dir.resolve("clubs.txt").toFile.deleteOnExit() + dir.resolve("recent-jobs.txt").toFile.deleteOnExit() + dir + }.orDie + + private def clubsFile: UIO[Path] = tempDir.map(_.resolve("clubs.txt")) + + private def setMtime(file: Path, at: Instant): UIO[Unit] = + ZIO.attemptBlocking(Files.setLastModifiedTime(file, FileTime.from(at))).unit.orDie + + private def read(file: Path): UIO[String] = ZIO.attemptBlocking(Files.readString(file)).orDie + + private def exists(file: Path): UIO[Boolean] = ZIO.attemptBlocking(Files.exists(file)).orDie + + private def mtimeMillis(file: Path): UIO[Long] = + ZIO.attemptBlocking(Files.getLastModifiedTime(file).toMillis).orDie + + override def spec: Spec[Any, Any] = suite("TestCompletionCache")( + suite("clubsStale")( + test("an absent file is stale") { + for { + f <- clubsFile + stale <- CompletionCache.clubsStaleIn(f) + } yield assertTrue(stale) + }, + test("a file written just inside the TTL is fresh") { + for { + f <- clubsFile + _ <- CompletionCache.writeClubsIn(f, List("team-alpha")) + _ <- setMtime(f, Base) + _ <- TestClock.setTime(Base.plusMillis(SixHours - 1)) + stale <- CompletionCache.clubsStaleIn(f) + } yield assertTrue(!stale) + }, + test("a file older than the TTL is stale") { + for { + f <- clubsFile + _ <- CompletionCache.writeClubsIn(f, List("team-alpha")) + _ <- setMtime(f, Base) + _ <- TestClock.setTime(Base.plusMillis(SixHours + 1)) + stale <- CompletionCache.clubsStaleIn(f) + } yield assertTrue(stale) + } + ), + suite("writeClubs / readClubs")( + test("round-trips slugs one per line and truncates a longer previous list") { + for { + f <- clubsFile + _ <- CompletionCache.writeClubsIn(f, List("a", "b", "c")) + _ <- CompletionCache.writeClubsIn(f, List("z")) + text <- read(f) + slugs <- CompletionCache.readClubsIn(f) + } yield assertTrue(text == "z\n", slugs.contains(List("z"))) + }, + test("an empty list writes an empty file, not a stray newline") { + for { + f <- clubsFile + _ <- CompletionCache.writeClubsIn(f, Nil) + text <- read(f) + slugs <- CompletionCache.readClubsIn(f) + } yield assertTrue(text.isEmpty, slugs.contains(Nil)) + }, + // Absent is a normal state, not a failure: Some(Nil) = "read it, nothing cached". + test("readClubs yields Some(Nil) for a missing file") { + clubsFile.flatMap(CompletionCache.readClubsIn).map(slugs => assertTrue(slugs.contains(Nil))) + }, + // The distinction the Option exists for: an unreadable cache must NOT masquerade as an empty one. A directory at + // the cache path forces a genuine read error portably, without chmod games that a root test runner would defeat. + test("readClubs yields None when the cache exists but cannot be read") { + for { + f <- clubsFile + _ <- ZIO.attemptBlocking(Files.createDirectories(f)).orDie + slugs <- CompletionCache.readClubsIn(f) + } yield assertTrue(slugs.isEmpty) + }, + test("writeClubs reports true on success") { + clubsFile.flatMap(CompletionCache.writeClubsIn(_, List("a"))).map(ok => assertTrue(ok)) + }, + // The write signal exists so an unwritable cache can't kill completion silently; same directory trick. + test("writeClubs reports false when the target cannot be written") { + for { + f <- clubsFile + _ <- ZIO.attemptBlocking(Files.createDirectories(f)).orDie + ok <- CompletionCache.writeClubsIn(f, List("a")) + } yield assertTrue(!ok) + } + ), + suite("seedClubs")( + test("seeds an absent file and stamps its mtime to the epoch so it still reads as stale") { + for { + f <- clubsFile + _ <- CompletionCache.seedClubsIn(f, List("team-alpha", "team-beta")) + slugs <- CompletionCache.readClubsIn(f) + mtime <- mtimeMillis(f) + _ <- TestClock.setTime(Base) + stale <- CompletionCache.clubsStaleIn(f) + } yield assertTrue(slugs.contains(List("team-alpha", "team-beta")), mtime == 0L, stale) + }, + test("does not overwrite an existing cache") { + for { + f <- clubsFile + _ <- CompletionCache.writeClubsIn(f, List("real")) + _ <- CompletionCache.seedClubsIn(f, List("seed")) + slugs <- CompletionCache.readClubsIn(f) + } yield assertTrue(slugs.contains(List("real"))) + }, + test("an empty seed list creates no file") { + for { + f <- clubsFile + _ <- CompletionCache.seedClubsIn(f, Nil) + ex <- exists(f) + } yield assertTrue(!ex) + } + ), + suite("invalidate")( + test("deletes the cache so the next staleness check refreshes regardless of the TTL") { + for { + f <- clubsFile + _ <- CompletionCache.writeClubsIn(f, List("team-alpha")) + _ <- setMtime(f, Base) + _ <- TestClock.setTime(Base) // well inside the TTL: only the delete can make this stale + freshBefore <- CompletionCache.clubsStaleIn(f) + _ <- CompletionCache.invalidateIn(f) + ex <- exists(f) + staleAfter <- CompletionCache.clubsStaleIn(f) + } yield assertTrue(!freshBefore, !ex, staleAfter) + }, + // Pins the load-bearing property: invalidate must DELETE the file (so `clubsStale` refreshes), not blank it — + // and must not take the containing directory with it. + test("is a no-op on an absent file, and leaves the cache directory intact") { + for { + f <- clubsFile + _ <- CompletionCache.invalidateIn(f) + gone <- exists(f) + parent <- exists(f.getParent) + } yield assertTrue(!gone, parent) + } + ), + suite("appendJob")( + test("prepends newest-first and drops an earlier duplicate") { + for { + dir <- tempDir + f = dir.resolve("recent-jobs.txt") + _ <- CompletionCache.appendJobIn(f, "one") + _ <- CompletionCache.appendJobIn(f, "two") + _ <- CompletionCache.appendJobIn(f, "one") + text <- read(f) + } yield assertTrue(text == "one\ntwo\n") + }, + test("caps the retained list at 50") { + for { + dir <- tempDir + f = dir.resolve("recent-jobs.txt") + _ <- ZIO.foreachDiscard(1 to 60)(i => CompletionCache.appendJobIn(f, s"job-$i")) + text <- read(f) + lines = text.linesIterator.toList + } yield assertTrue(lines.size == 50, lines.head == "job-60", lines.last == "job-11") + } + ) + ) +} diff --git a/src/test/scala/ccas/cli/TestUseClub.scala b/src/test/scala/ccas/cli/TestUseClub.scala index f5b6ee7..90ff72c 100644 --- a/src/test/scala/ccas/cli/TestUseClub.scala +++ b/src/test/scala/ccas/cli/TestUseClub.scala @@ -1,15 +1,134 @@ package ccas.cli import zio.ExitCode -import zio.test.{assertTrue, Spec, ZIOSpecDefault} +import zio.test.{assertTrue, Spec, TestConsole, ZIOSpecDefault} -/** Tests the [[UseClub]] guard that doesn't touch the filesystem: a blank slug is rejected (exit 2) without writing - * config. The happy path (which writes the real config file) is covered indirectly via `TestConfigWriter`. */ +/** Tests the [[UseClub]] modes and helpers that touch neither the filesystem nor the network: argument validation, the + * show/clear paths that short-circuit before any write, the classify/isUnknown predicates, and the advisory messages. + * + * The `use-club ` *set* path is deliberately not driven here — it resolves through `XdgPaths.configFile` and + * would clobber the developer's real `~/.config/ccas/config.conf`, and its probe hits a real server. The write + * mechanics are covered in `TestConfigWriter`; a dummy server is passed because none of these cases reach the probe. + */ object TestUseClub extends ZIOSpecDefault { + private val Server = "http://127.0.0.1:8080" + override def spec: Spec[Any, Any] = suite("TestUseClub")( - test("blank slug is rejected with exit 2 (no write)") { - UseClub.run(" ").map(code => assertTrue(code == ExitCode(2))) + test("blank slug is rejected with exit 2 (no write, no probe)") { + UseClub.run(List(" "), clear = false, None, Server).map(code => assertTrue(code == ExitCode(2))) + }, + test("a slug together with --clear is rejected with exit 2 (conflicting intent)") { + UseClub.run(List("team-alpha"), clear = true, Some("team-beta"), Server) + .map(code => assertTrue(code == ExitCode(2))) + }, + test("no slug prints the current club") { + for { + code <- UseClub.run(Nil, clear = false, Some("team-alpha"), Server) + out <- TestConsole.output + } yield assertTrue(code == ExitCode.success, out == Vector("team-alpha\n")) + }, + test("no slug with no current club exits 2 and says how to set one") { + for { + code <- UseClub.run(Nil, clear = false, None, Server) + err <- TestConsole.outputErr + } yield assertTrue(code == ExitCode(2), err.exists(_.contains("ccas use-club "))) + }, + test("--clear with nothing set succeeds without writing") { + for { + code <- UseClub.run(Nil, clear = true, None, Server) + out <- TestConsole.output + } yield assertTrue(code == ExitCode.success, out == Vector("no current club set\n")) + }, + // Arity rejection — without it these silently set the first slug and discard the rest. + test("two slugs are rejected with exit 2, naming both") { + for { + code <- UseClub.run(List("team-a", "team-b"), clear = false, None, Server) + err <- TestConsole.outputErr + } yield assertTrue(code == ExitCode(2), err.exists(e => e.contains("team-a") && e.contains("team-b"))) + }, + // The case that used to silently SET the club the user asked to clear: zio-cli swallows an option written after a + // positional, so `--clear` arrives as a second slug. The message must point at the working ordering. + test("a flag swallowed as a second positional is rejected with the correct ordering hinted") { + for { + code <- UseClub.run(List("team-alpha", "--clear"), clear = false, None, Server) + err <- TestConsole.outputErr + } yield assertTrue( + code == ExitCode(2), + err.exists(_.contains("ccas use-club --clear team-alpha")) + ) + }, + // classify decides the advisory from the live managed-set fetch. `None` = the probe got no usable answer, which + // must not be reported as "unmanaged" — it falls through to the offline cache hint instead. + test("classify: a slug in the live managed set is Managed") { + assertTrue(UseClub.classify("team-alpha", Some(List("team-beta", "team-alpha"))) == UseClub.Verify.Managed) + }, + test("classify: managed match is case-insensitive") { + assertTrue(UseClub.classify("Team-Alpha", Some(List("team-alpha"))) == UseClub.Verify.Managed) + }, + test("classify: a slug absent from a reached managed set is Unmanaged") { + assertTrue(UseClub.classify("team-gamma", Some(List("team-alpha"))) == UseClub.Verify.Unmanaged) + }, + test("classify: an empty-but-reached managed set is Unmanaged, not Unverified") { + assertTrue(UseClub.classify("team-alpha", Some(Nil)) == UseClub.Verify.Unmanaged) + }, + test("classify: no usable answer from the server is Unverified") { + assertTrue(UseClub.classify("team-alpha", None) == UseClub.Verify.Unverified) + }, + suite("advise")( + test("Managed says nothing") { + for { + _ <- UseClub.advise("team-alpha", UseClub.Verify.Managed) + err <- TestConsole.outputErr + } yield assertTrue(err.isEmpty) + }, + test("Unmanaged names the club and how to manage it") { + for { + _ <- UseClub.advise("team-gamma", UseClub.Verify.Unmanaged) + err <- TestConsole.outputErr + } yield assertTrue(err.exists(e => e.contains("not one of your managed clubs") && e.contains("ccas club add team-gamma"))) + } + ), + // offlineHint's `None` branch is the only consumer of the Option that `readClubs` returns — without it, collapsing + // that back to a bare list would compile and break nothing. + suite("offlineHint")( + test("an unreadable cache is reported, not treated as empty") { + for { + _ <- UseClub.offlineHint("team-alpha", None) + err <- TestConsole.outputErr + } yield assertTrue(err.exists(_.contains("club cache could not be read"))) + }, + test("a read cache that lacks the slug gives the stale-list hint") { + for { + _ <- UseClub.offlineHint("team-gamma", Some(List("team-alpha"))) + err <- TestConsole.outputErr + } yield assertTrue(err.exists(_.contains("not in the cached club list"))) + }, + test("a read cache that has the slug says nothing") { + for { + _ <- UseClub.offlineHint("team-alpha", Some(List("team-alpha"))) + err <- TestConsole.outputErr + } yield assertTrue(err.isEmpty) + }, + test("an empty cache says nothing — absence proves nothing") { + for { + _ <- UseClub.offlineHint("team-alpha", Some(Nil)) + err <- TestConsole.outputErr + } yield assertTrue(err.isEmpty) + } + ), + // isUnknown is the predicate behind offlineHint's Some branch. + test("isUnknown: an empty cache never warns") { + assertTrue(!UseClub.isUnknown("team-alpha", Nil)) + }, + test("isUnknown: an exact hit does not warn") { + assertTrue(!UseClub.isUnknown("team-alpha", List("team-beta", "team-alpha"))) + }, + test("isUnknown: a case-differing hit does not warn (ClubSlug lowercases anyway)") { + assertTrue(!UseClub.isUnknown("Team-Alpha", List("team-alpha"))) + }, + test("isUnknown: a genuine miss warns") { + assertTrue(UseClub.isUnknown("team-gamma", List("team-alpha", "team-beta"))) } ) } diff --git a/src/test/scala/ccas/cli/config/TestConfigWriter.scala b/src/test/scala/ccas/cli/config/TestConfigWriter.scala index d2f9534..51393d9 100644 --- a/src/test/scala/ccas/cli/config/TestConfigWriter.scala +++ b/src/test/scala/ccas/cli/config/TestConfigWriter.scala @@ -5,9 +5,12 @@ import java.nio.file.{Files, Path} import zio.{UIO, ZIO} import zio.test.{assertTrue, Spec, ZIOSpecDefault} -/** Tests [[ConfigWriter.setCurrentClub]] against temp config files: it creates the file (and parents) when absent, - * preserves other keys and comments, and overwrites an existing `current_club` rather than duplicating it. Each case - * round-trips through [[CliConfig.load]] to confirm the written file still parses. No server, no DB. +/** Tests [[ConfigWriter]] against temp config files. + * + * Set side: creates the file (and parents) when absent, preserves other keys and comments, overwrites an existing + * `current_club` rather than duplicating it. Clear side: removes the key while leaving other content untouched, never + * creates a config that was never written, and leaves a key that can be set again without duplicating. Every case that + * leaves a file behind round-trips through [[CliConfig.load]] to confirm it still parses. No server, no DB. */ object TestConfigWriter extends ZIOSpecDefault { @@ -64,6 +67,58 @@ object TestConfigWriter extends ZIOSpecDefault { cfg.currentClub.contains("second"), text.linesIterator.count(_.trim.startsWith("current_club")) == 1 ) + }, + test("clearCurrentClub removes the key while preserving other keys and comments") { + val existing = + """# my ccas config + |api_url = "http://host:9000" + |current_club = "team-alpha" + |""".stripMargin + for { + dir <- tempDir + file = dir.resolve("config.conf") + _ <- ZIO.attemptBlocking(Files.writeString(file, existing)).orDie + _ <- ConfigWriter.clearCurrentClub(file).orDie + text <- read(file) + cfg <- load(file) + } yield assertTrue( + cfg.currentClub.isEmpty, + cfg.apiUrl.contains("http://host:9000"), + text.contains("# my ccas config"), + !text.contains("current_club") + ) + }, + test("clearCurrentClub on a config holding only that key leaves an empty file that still parses") { + for { + dir <- tempDir + file = dir.resolve("config.conf") + _ <- ConfigWriter.setCurrentClub(file, "team-alpha").orDie + _ <- ConfigWriter.clearCurrentClub(file).orDie + text <- read(file) + cfg <- load(file) + } yield assertTrue(text.isEmpty, cfg.currentClub.isEmpty) + }, + test("clearCurrentClub does not create a config that was never written") { + for { + dir <- tempDir + file = dir.resolve("config.conf") + _ <- ConfigWriter.clearCurrentClub(file).orDie + exists <- ZIO.attemptBlocking(Files.exists(file)).orDie + } yield assertTrue(!exists) + }, + test("a cleared key can be set again") { + for { + dir <- tempDir + file = dir.resolve("config.conf") + _ <- ConfigWriter.setCurrentClub(file, "first").orDie + _ <- ConfigWriter.clearCurrentClub(file).orDie + _ <- ConfigWriter.setCurrentClub(file, "second").orDie + cfg <- load(file) + text <- read(file) + } yield assertTrue( + cfg.currentClub.contains("second"), + text.linesIterator.count(_.trim.startsWith("current_club")) == 1 + ) } ) }