[v0.1/P1] Add one explicit --config PATH control surface #27

Closed
opened 2026-09-03 16:18:07 +09:00 by natsukium · 1 comment
Owner

Parent: #12 (P1.7). Supersedes the path-override decision in #4.

Why

Without an explicit config path, one-off launches cannot be reproduced without mutating the real profile and Windows config/doctor end-to-end tests remain disabled. Parallel environment-variable spellings would create permanent aliases.

Scope

  • Add one --config PATH launch option.
  • Forward it consistently through felis, GUI launch/exec, config, and doctor.
  • Define path resolution and diagnostics once.
  • Do not add FELIS_CONFIG or a second spelling.
  • Use the option to enable Windows config/doctor end-to-end coverage.

Acceptance criteria

  • Bare launch, attach, command launch, config verbs, and doctor read the same selected document.
  • Relative path behavior is explicit and tested.
  • Missing, unreadable, and invalid selected files produce consistent diagnostics.
  • Existing default platform discovery remains unchanged when the flag is absent.
  • Config reference/schema/manual, completions, man pages, Home Manager notes, and changelog are updated.
Parent: #12 (P1.7). Supersedes the path-override decision in #4. ## Why Without an explicit config path, one-off launches cannot be reproduced without mutating the real profile and Windows config/doctor end-to-end tests remain disabled. Parallel environment-variable spellings would create permanent aliases. ## Scope - Add one `--config PATH` launch option. - Forward it consistently through `felis`, GUI launch/exec, `config`, and `doctor`. - Define path resolution and diagnostics once. - Do not add `FELIS_CONFIG` or a second spelling. - Use the option to enable Windows config/doctor end-to-end coverage. ## Acceptance criteria - [ ] Bare launch, attach, command launch, config verbs, and doctor read the same selected document. - [ ] Relative path behavior is explicit and tested. - [ ] Missing, unreadable, and invalid selected files produce consistent diagnostics. - [ ] Existing default platform discovery remains unchanged when the flag is absent. - [ ] Config reference/schema/manual, completions, man pages, Home Manager notes, and changelog are updated.
Author
Owner

Triage plan (2026-09-03)

Source-grounded triage against main at 69076d42, reviewed through seven rounds of an independent reviewer (pi sol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12.

Claim check

Accurate at HEAD.

  • No override exists. config_path() is ProjectDirs::from("", "", "felis").config_dir()/config.toml with no environment or flag input (crates/felis-client-core/src/config/document.rs:292-306). Every reader goes through it: EffectiveConfig::load/try_load (document.rs:198-212), the CLI config verbs' resolve_path (crates/felis-cli/src/cli_config.rs:96-104), doctor's config_check (crates/felis-cli/src/cli_doctor.rs:170-172), the GUI's startup load (crates/felis-client/src/main.rs:339) and its file watcher (main.rs:861-866). grep -rn FELIS_CONFIG over crates/ docs/ nix/ finds nothing, so there is no second spelling to unify.
  • Windows config/doctor coverage is disabled because of it. crates/felis-cli/tests/cli_config.rs:4-9 (#![cfg(unix)]) and the four #[cfg(unix)] tests in tests/cli_doctor.rs:22-25, 102, 165, 188 inject a temp home through HOME/XDG_CONFIG_HOME (cli_config.rs:16-22), which Windows's known-folder lookup ignores. docs/reference/testing.md:1124-1136 and docs/backlog.md:29-37 record the gap and explicitly defer it to "a config-path control surface decision (principle 1)". This issue is that decision.
  • Forwarding path. felis rebuilds the root flags it forwards to felis-client explicitly in global_passthrough (crates/felis-cli/src/main.rs:522-540), so a new root flag has one place to be forwarded and one clap struct on the client side (felis-client/src/main.rs:249-305) to receive it.
  • Home Manager module writes the file at the platform default path (nix/hm-module.nix:212-233) and its settings doc names the default locations (hm-module.nix:135-137); it needs a note, not a new option.

#55 reconfirms the scope as sufficient and adds the second consumer: config check/show-effective against a candidate file without replacing the live profile.

Verdict

accept. Principle 1 test: "if a dedicated tool at the shell … does a feature as well or better … the terminal does not implement it." The shell-layer alternative is an environment variable, and it does the job worse here for a reason worth recording: felis stamps the spawning environment into every session (docs/reference/terminal-identity.md), so a FELIS_CONFIG set for one launch would be inherited by every shell in that window and silently redirect any felis config run inside it. A per-invocation flag has no such leak. Principle 4 passes (explicit path, no discovery heuristics added). No new spelling beyond the flag.

Approach

Core (felis-client-core)

  • document.rs: add pub enum ConfigSource { Default, Explicit(PathBuf) } with fn path(&self) -> Option<PathBuf> (Default → config_path()), and EffectiveConfig::load_from(&ConfigSource, client_id) / try_load_from_source; keep load(client_id) as load_from(&Default, …) so nothing else moves. diagnose(path, client_id) gains a selected: bool argument (or a diagnose_source) so an explicitly selected file that does not exist yields an error DiagnosticKind::MissingFile (config/diagnostics.rs:26 already has the kind, used today as a warning for path-valued keys at validate.rs:146), while the default path missing stays the documented "use the defaults" Ok(None) (document.rs:216-217).
  • Relative paths: resolve once, at the front door, against the invoking process's cwd (std::env::current_dir()?.join(p)), and forward the absolute form. Rule to document: "relative to the directory felis was run from; the reported path (config path, logs) is always absolute".
  • Unreadable file: already an io error diagnostic through ConfigDocument::read (document.rs:242-245); no change, but the test matrix must cover it.

CLI (felis-cli)

  • Cli (main.rs:286-339): #[arg(long, value_name = "PATH")] config: Option<PathBuf>, root-before-command like --socket. Semantics per verb:
    • bare launch / attach: forwarded via global_passthrough (main.rs:525) as --config <abs>;
    • config path|check|show-effective: cli_config::run(op, &source); path reports the selected file and exists;
    • doctor: config_check(&source) (cli_doctor.rs:170-206); the row says "selected by --config" in detail when explicit;
    • every verb that reads no config (sessions, notifications, daemon, window retarget, bridge, completions) refuses it with exit 2 through the same path reject_global_carrier uses (cli_sessions.rs:828-838; after #23, in the verb's class framing). A silently ignored root flag is exactly what #54 just removed, so accepting it there is not an option;
    • frontend <name>: refused like the carriers (main.rs:463-469), with "put it after the frontend name".
  • Diagnostics wording lives once: a fn describe_config_source(&ConfigSource) -> String used by config path human output, doctor, and the GUI startup log line.

GUI client (felis-client)

  • Cli (felis-client/src/main.rs:249): --config <PATH>; EffectiveConfig::load_from at main.rs:339; the watcher (main.rs:861-866, config_watcher::spawn(handle, path, …) at config_watcher.rs:22) watches the selected path. Startup logs one line naming the source.

Tests

  • felis-cli/src/tests.rs: parse matrix (--config accepted before config, doctor, bare, attach; refused after the command; global_passthrough_rebuilds_set_flags_only (tests.rs:530) gains the flag and asserts it is absolute).
  • tests/cli_config.rs: drop #![cfg(unix)]; replace the HOME/XDG_CONFIG_HOME helper (16-22) with --config <tempdir>/config.toml. Keep one Unix-only test proving the default discovery is untouched when the flag is absent (the existing config_path read-back helper at 27-35). Add: relative path resolved against Command::current_dir; missing explicit file → check exit 1 with one missing_file error, show-effective exit 1, doctor config row fail; unreadable (chmod 000, cfg(unix)) → io; invalid → parse; the three verbs report the identical path string.
  • tests/cli_doctor.rs: the four cfg(unix) tests switch to --config and run on Windows; the_config_row_points_at_the_config_verb (cli_doctor.rs:457) stays.
  • felis-client-core unit test: ConfigSource::Explicit on a missing file is an error diagnostic; Default on a missing file is Ok(default).
  • Windows: windows-test (.forgejo/workflows/pr.yml:93-193) picks the tests up with no workflow change; testing.md:1124-1136 and backlog.md:29-37 are rewritten to say the family now runs there.

Cascade

  • docs/reference/cli.md: a "Global options" paragraph (or the "Config verbs" section at 193-250) stating the flag, its per-verb acceptance, relative-path rule, and the missing-explicit-file diagnostic; "Window launches" (377-400) notes the forward to the GUI.
  • docs/reference/config.md:16-30 ("where the file lives"): the override, one sentence, no env var.
  • docs/explanation/architecture/control-surfaces.md "Diagnostic verbs": the decision record — chosen: one flag; rejected: FELIS_CONFIG (inherits into sessions and their children; a second spelling); rejected: a config key naming another config (evaluator-shaped); Revisit if a multi-profile workflow appears that a flag cannot serve.
  • docs/reference/testing.md:1124-1136, docs/backlog.md:29-37: close the gap.
  • nix/hm-module.nix:135-137: one sentence that --config overrides the path the module writes to.
  • Schema: no config key changes, so felis-config.schema.json is untouched; just schema is a no-op (state this in the commit body so CI's stale-schema check is not expected to move).
  • felis completions, __mangen: regenerate from clap automatically; skills/felis/SKILL.md:263-282 ("Is the config valid?") gains the --config line.
  • CHANGELOG.md: one "Added" entry under CLI.

Dependencies

  • No hard blocker. After #23 is preferable: the refusal on non-config verbs should be emitted through #23's usage-error framing rather than adding a second human-only eprintln! path that #23 then rewrites. If #23 slips, land this with the existing reject_global_carrier shape and let #23 reframe it.
  • #54 (landed) is the precedent for "a root flag is refused, never ignored, on verbs it does not apply to".
  • #28 (landed) is unrelated despite sharing the config surface.
  • Enables: the Windows config/doctor rows in #31's release gate.

Risk/effort

M. Three crates plus tests and docs, but every change is additive and local. Main risk: the Windows CI job now executes the config/doctor suites for the first time, and path rendering (\ separators, %APPDATA% case) may break string assertions written on Linux; write the tests against config path --format json output rather than literal strings. Secondary risk: felis-client and felis disagreeing on relative-path resolution if the client also resolves; resolve only in felis and treat the client's flag as already absolute (document, and assert in the passthrough test).

Labels

Keep priority/P1, release/v0.1.0: it is inside the "documented environment variables / CLI option meanings" freeze boundary, and #31's Windows gate is stronger with it.

## Triage plan (2026-09-03) Source-grounded triage against `main` at `69076d42`, reviewed through seven rounds of an independent reviewer (`pi` sol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. ## Claim check Accurate at HEAD. - **No override exists.** `config_path()` is `ProjectDirs::from("", "", "felis").config_dir()/config.toml` with no environment or flag input (`crates/felis-client-core/src/config/document.rs:292-306`). Every reader goes through it: `EffectiveConfig::load`/`try_load` (`document.rs:198-212`), the CLI `config` verbs' `resolve_path` (`crates/felis-cli/src/cli_config.rs:96-104`), `doctor`'s `config_check` (`crates/felis-cli/src/cli_doctor.rs:170-172`), the GUI's startup load (`crates/felis-client/src/main.rs:339`) and its file watcher (`main.rs:861-866`). `grep -rn FELIS_CONFIG` over `crates/ docs/ nix/` finds nothing, so there is no second spelling to unify. - **Windows config/doctor coverage is disabled because of it.** `crates/felis-cli/tests/cli_config.rs:4-9` (`#![cfg(unix)]`) and the four `#[cfg(unix)]` tests in `tests/cli_doctor.rs:22-25, 102, 165, 188` inject a temp home through `HOME`/`XDG_CONFIG_HOME` (`cli_config.rs:16-22`), which Windows's known-folder lookup ignores. `docs/reference/testing.md:1124-1136` and `docs/backlog.md:29-37` record the gap and explicitly defer it to "a config-path control surface decision (principle 1)". This issue is that decision. - **Forwarding path.** `felis` rebuilds the root flags it forwards to `felis-client` explicitly in `global_passthrough` (`crates/felis-cli/src/main.rs:522-540`), so a new root flag has one place to be forwarded and one clap struct on the client side (`felis-client/src/main.rs:249-305`) to receive it. - **Home Manager module** writes the file at the platform default path (`nix/hm-module.nix:212-233`) and its `settings` doc names the default locations (`hm-module.nix:135-137`); it needs a note, not a new option. #55 reconfirms the scope as sufficient and adds the second consumer: `config check`/`show-effective` against a candidate file without replacing the live profile. ## Verdict **accept.** Principle 1 test: "if a dedicated tool at the shell … does a feature as well or better … the terminal does not implement it." The shell-layer alternative is an environment variable, and it does the job *worse* here for a reason worth recording: felis stamps the spawning environment into every session (`docs/reference/terminal-identity.md`), so a `FELIS_CONFIG` set for one launch would be inherited by every shell in that window and silently redirect any `felis config` run inside it. A per-invocation flag has no such leak. Principle 4 passes (explicit path, no discovery heuristics added). No new spelling beyond the flag. ## Approach ### Core (felis-client-core) - `document.rs`: add `pub enum ConfigSource { Default, Explicit(PathBuf) }` with `fn path(&self) -> Option<PathBuf>` (Default → `config_path()`), and `EffectiveConfig::load_from(&ConfigSource, client_id)` / `try_load_from_source`; keep `load(client_id)` as `load_from(&Default, …)` so nothing else moves. `diagnose(path, client_id)` gains a `selected: bool` argument (or a `diagnose_source`) so an explicitly selected file that does not exist yields an **error** `DiagnosticKind::MissingFile` (`config/diagnostics.rs:26` already has the kind, used today as a warning for path-valued keys at `validate.rs:146`), while the default path missing stays the documented "use the defaults" `Ok(None)` (`document.rs:216-217`). - Relative paths: resolve once, at the front door, against the invoking process's cwd (`std::env::current_dir()?.join(p)`), and forward the absolute form. Rule to document: "relative to the directory `felis` was run from; the reported path (`config path`, logs) is always absolute". - Unreadable file: already an `io` error diagnostic through `ConfigDocument::read` (`document.rs:242-245`); no change, but the test matrix must cover it. ### CLI (felis-cli) - `Cli` (`main.rs:286-339`): `#[arg(long, value_name = "PATH")] config: Option<PathBuf>`, root-before-command like `--socket`. Semantics per verb: - bare launch / `attach`: forwarded via `global_passthrough` (`main.rs:525`) as `--config <abs>`; - `config path|check|show-effective`: `cli_config::run(op, &source)`; `path` reports the selected file and `exists`; - `doctor`: `config_check(&source)` (`cli_doctor.rs:170-206`); the row says "selected by --config" in `detail` when explicit; - every verb that reads no config (`sessions`, `notifications`, `daemon`, `window retarget`, `bridge`, `completions`) refuses it with exit 2 through the same path `reject_global_carrier` uses (`cli_sessions.rs:828-838`; after #23, in the verb's class framing). A silently ignored root flag is exactly what #54 just removed, so accepting it there is not an option; - `frontend <name>`: refused like the carriers (`main.rs:463-469`), with "put it after the frontend name". - Diagnostics wording lives once: a `fn describe_config_source(&ConfigSource) -> String` used by `config path` human output, `doctor`, and the GUI startup log line. ### GUI client (felis-client) - `Cli` (`felis-client/src/main.rs:249`): `--config <PATH>`; `EffectiveConfig::load_from` at `main.rs:339`; the watcher (`main.rs:861-866`, `config_watcher::spawn(handle, path, …)` at `config_watcher.rs:22`) watches the selected path. Startup logs one line naming the source. ### Tests - `felis-cli/src/tests.rs`: parse matrix (`--config` accepted before `config`, `doctor`, bare, `attach`; refused after the command; `global_passthrough_rebuilds_set_flags_only` (`tests.rs:530`) gains the flag and asserts it is absolute). - `tests/cli_config.rs`: drop `#![cfg(unix)]`; replace the `HOME`/`XDG_CONFIG_HOME` helper (`16-22`) with `--config <tempdir>/config.toml`. Keep one Unix-only test proving the default discovery is untouched when the flag is absent (the existing `config_path` read-back helper at `27-35`). Add: relative path resolved against `Command::current_dir`; missing explicit file → `check` exit 1 with one `missing_file` error, `show-effective` exit 1, `doctor` config row `fail`; unreadable (chmod 000, `cfg(unix)`) → `io`; invalid → `parse`; the three verbs report the identical `path` string. - `tests/cli_doctor.rs`: the four `cfg(unix)` tests switch to `--config` and run on Windows; `the_config_row_points_at_the_config_verb` (`cli_doctor.rs:457`) stays. - felis-client-core unit test: `ConfigSource::Explicit` on a missing file is an error diagnostic; `Default` on a missing file is `Ok(default)`. - Windows: `windows-test` (`.forgejo/workflows/pr.yml:93-193`) picks the tests up with no workflow change; `testing.md:1124-1136` and `backlog.md:29-37` are rewritten to say the family now runs there. ### Cascade - `docs/reference/cli.md`: a "Global options" paragraph (or the "Config verbs" section at `193-250`) stating the flag, its per-verb acceptance, relative-path rule, and the missing-explicit-file diagnostic; "Window launches" (`377-400`) notes the forward to the GUI. - `docs/reference/config.md:16-30` ("where the file lives"): the override, one sentence, no env var. - `docs/explanation/architecture/control-surfaces.md` "Diagnostic verbs": the decision record — chosen: one flag; rejected: `FELIS_CONFIG` (inherits into sessions and their children; a second spelling); rejected: a config key naming another config (evaluator-shaped); *Revisit if* a multi-profile workflow appears that a flag cannot serve. - `docs/reference/testing.md:1124-1136`, `docs/backlog.md:29-37`: close the gap. - `nix/hm-module.nix:135-137`: one sentence that `--config` overrides the path the module writes to. - Schema: no config key changes, so `felis-config.schema.json` is untouched; `just schema` is a no-op (state this in the commit body so CI's stale-schema check is not expected to move). - `felis completions`, `__mangen`: regenerate from clap automatically; `skills/felis/SKILL.md:263-282` ("Is the config valid?") gains the `--config` line. - `CHANGELOG.md`: one "Added" entry under CLI. ## Dependencies - No hard blocker. **After #23** is preferable: the refusal on non-config verbs should be emitted through #23's usage-error framing rather than adding a second human-only `eprintln!` path that #23 then rewrites. If #23 slips, land this with the existing `reject_global_carrier` shape and let #23 reframe it. - #54 (landed) is the precedent for "a root flag is refused, never ignored, on verbs it does not apply to". - #28 (landed) is unrelated despite sharing the config surface. - Enables: the Windows config/doctor rows in #31's release gate. ## Risk/effort **M.** Three crates plus tests and docs, but every change is additive and local. Main risk: the Windows CI job now executes the config/doctor suites for the first time, and path rendering (`\` separators, `%APPDATA%` case) may break string assertions written on Linux; write the tests against `config path --format json` output rather than literal strings. Secondary risk: `felis-client` and `felis` disagreeing on relative-path resolution if the client also resolves; resolve only in `felis` and treat the client's flag as already absolute (document, and assert in the passthrough test). ## Labels Keep **priority/P1**, **release/v0.1.0**: it is inside the "documented environment variables / CLI option meanings" freeze boundary, and #31's Windows gate is stronger with it.
Sign in to join this conversation.
No description provided.