# Architecture
## Why a CLI instead of a Zed extension
As of 2026-05, the Zed extension API only supports these kinds:
- Languages
- Debuggers
- Themes / Icon Themes
- Snippets
- MCP servers
Extensions cannot register editor commands bound to keybindings, cannot read the path of the active editor, and cannot open files in the editor. Tracking issue: [zed-industries/zed #30873](https://github.com/zed-industries/zed/discussions/30873).
What Zed *does* support: arbitrary `tasks.json` entries bindable to keys via `task::Spawn`, with `$ZED_FILE` and friends injected. So we move the logic out of the extension sandbox and into a tiny CLI invoked by a task. When `task::Spawn` becomes `command::Spawn`, the same `lib.rs` core can be wrapped in a `zed_extension_api` shim with zero rewrite.
## Module layout
```
src/
├── error.rs Typed SwitcherError + stable exit codes
├── config.rs Strict TOML schema + precedence loader
├── resolver.rs Path → (target, basename) identification + sibling lookup
├── strategy.rs Cycle / direct-jump selection on top of the resolver
├── opener.rs Spawns the `zed` CLI on the resolved path
├── cli.rs clap definitions
├── lib.rs Public reexports — every module is testable in isolation
└── main.rs Thin wrapper that maps SwitcherError → ExitCode
```
The split is rigid by design: `error`, `config`, `resolver`, and `strategy` have **no I/O** beyond reading config files and stat-ing siblings. The opener is the only module that spawns a process. This keeps the resolver trivially testable and the security surface minimal.
## Resolution algorithm
Given an input file path and a config:
1. **Identify current target.** For each `(target, extension)` pair, check whether the filename ends with `.{extension}` and is not blocked by an `exclude_suffixes` entry. Among matches, the *longest* extension wins. This is how `foo.component.spec.ts` maps to the `spec` target and not the `ts` target without ambiguity.
2. **Extract basename.** The portion of the filename before the matched extension. `foo.component.scss` → `foo.component`.
3. **Resolve sibling.**
- **Direct mode (`--to X`):** for target `X`, iterate `preference` then `extensions`, return the first `parent/basename.ext` that exists.
- **Cycle mode:** start at the current target's index in `cycle.order`, advance by ±1 (respecting `--reverse` and `wrap`), and for each candidate target apply the same sibling lookup. Skip missing siblings if `skip_missing = true`.
4. **Open.** Hand the resolved `PathBuf` to `Command::new("zed").arg(path).status()`. No shell involved.
## Security posture
- `#![forbid(unsafe_code)]` at every crate root.
- No string concatenation passed to a shell. The opener uses `Command::new` with the path as a separate `OsStr` argument; there is no `sh -c` anywhere.
- Path inputs are validated to reject NUL bytes before being passed downstream.
- TOML is parsed with `deny_unknown_fields` so typos are loud, not silently dropped.
- No network calls. No telemetry.
- Dependency hygiene: `Cargo.lock` is committed; CI runs `cargo audit` and `cargo deny` on every PR; release pipeline pins `--locked`.
## Why these dependencies
| `clap` | CLI parsing | Standard, generates help, well-audited |
| `serde`/`toml` | Config schema | Strict typed deserialization |
| `thiserror` | Typed library errors | Zero overhead, derives `Display` |
| `anyhow` | Allowed only in `main.rs` | Kept out of `lib.rs` so library callers get typed errors |
| `directories` | Cross-platform config dir | Saves us a per-OS conditional |
`tokio`, `reqwest`, regex engines, etc. are not present — none are needed.
## Extending to other frameworks
The resolver and strategy modules are framework-agnostic. They operate purely on the target/extension model defined in config. Adding a React companion-file switcher is a simple config change:
```toml
[targets.tsx]
extensions = ["tsx"]
exclude_suffixes = ["test.tsx", "stories.tsx"]
[targets.test]
extensions = ["test.tsx"]
[targets.stories]
extensions = ["stories.tsx"]
[cycle]
order = ["tsx", "test", "stories"]
```