angular-switcher 0.1.0

Switch between Angular component files (.ts, .html, styles, .spec.ts) from the Zed editor with a customizable keybinding.
Documentation
# 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

| Crate          | Role                              | Why this one                                       |
|----------------|-----------------------------------|----------------------------------------------------|
| `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"]
```