# `MobDev.Uninstaller`
[🔗](https://github.com/genericjam/mob_dev/blob/master/lib/mob_dev/uninstaller.ex#L1)

Uninstall a Mob app (or every Mob-prefixed app) from one or more
connected devices.

The user-facing surface is `mix mob.uninstall`. This module owns the
matrix math + per-platform uninstall mechanics so the Mix task stays
thin and the testable invariants live here.

## Scope dimensions

Two orthogonal axes:

  * **Devices** — one auto-detected (when exactly one is connected),
    a list of named devices (`--device foo --device bar`), or every
    connected device (`--all-devices`).
  * **Apps** — the current project's bundle id by default, every
    installed package matching the user's `bundle_prefix`
    (`--all-apps`), or an explicit override (`--bundle-id`).

The (devices × apps) matrix gets flattened into per-pair uninstall
attempts and the results bucket into `{uninstalled, failed, skipped}`
— same shape as `MobDev.Deployer.deploy_all/1` so the report
rendering can borrow the same idiom.

## Per-platform mechanics

  * **Android (adb)** — `adb -s <serial> uninstall <pkg>`.
    stderr "Unknown package" → `:skipped` (not installed). All
    other non-zero exits → `:error`.

  * **iOS simulator (xcrun simctl)** — `xcrun simctl uninstall
    <udid> <bundle>`. simctl returns exit 0 whether the app was
    installed or not, so we probe with `xcrun simctl listapps`
    first to distinguish skip-vs-uninstall.

  * **iOS physical device (devicectl)** — `xcrun devicectl device
    uninstall app --device <udid> <bundle>`. Exit 0 → uninstalled.
    `ContainerLookupErrorDomain` in stderr → `:skipped`
    (app not installed on this device).

# `outcome`

```elixir
@type outcome() :: :uninstalled | :skipped | :error
```

# `plan`

```elixir
@type plan() :: [{MobDev.Device.t(), [String.t()]}]
```

An uninstall plan — list of (device, [bundle_id]) pairs.
Built from `plan/1`; executed by `execute_plan/1`.

# `plan_error`

```elixir
@type plan_error() :: MobDev.TaskTargets.selection_error()
```

Why `plan/1` couldn't build a matrix.

# `result`

```elixir
@type result() :: %{
  device: MobDev.Device.t(),
  bundle_id: String.t(),
  outcome: outcome(),
  reason: String.t() | nil
}
```

# `categorize_results`

```elixir
@spec categorize_results([result()]) :: {[result()], [result()], [result()]}
```

Bucket `results` into `{uninstalled, failed, skipped}`.
Mirrors `MobDev.Deployer.categorize_results/1`.

# `execute_plan`

```elixir
@spec execute_plan(plan()) :: {[result()], [result()], [result()]}
```

Execute a `plan/0` against the connected devices. Returns
`{uninstalled, failed, skipped}` lists of `result/0` maps.

# `filter_devices_by_id`

```elixir
@spec filter_devices_by_id([MobDev.Device.t()], [String.t()]) :: [MobDev.Device.t()]
```

Filter a list of `%Device{}` by `device_ids`. When `device_ids` is
empty, return all. When non-empty, match by serial OR name (case-
sensitive prefix on serial, exact on either name). Devices not
found are dropped silently — the caller is expected to validate
user intent.

Used by `resolve_devices/1` when the user passes `--device foo`.

# `interpret_adb_uninstall`

```elixir
@spec interpret_adb_uninstall(String.t(), non_neg_integer()) ::
  {outcome(), String.t() | nil}
```

Parse `adb uninstall` output into an outcome.

Adb's reporting is informal:

  * `"Success"` (sometimes followed by other lines) → `:uninstalled`
  * stderr contains `"Failure"` with `"DELETE_FAILED_INTERNAL_ERROR"`
    and `"Unknown package"` → `:skipped` (not installed)
  * any other non-zero exit → `:error`

`output` is the combined stdout+stderr; `exit_code` is the process
exit status. Returns `{outcome, reason}` where `reason` is a
short user-facing string (or nil for the success case).

# `interpret_devicectl_uninstall`

```elixir
@spec interpret_devicectl_uninstall(String.t(), non_neg_integer()) ::
  {outcome(), String.t() | nil}
```

Parse `xcrun devicectl device uninstall app` output into an outcome.

devicectl is more structured than adb but its error reporting still
varies by Xcode version. Patterns:

  * exit 0 → app actually uninstalled (or wasn't there — devicectl
    doesn't always distinguish; check the listapps probe if
    precision matters).
  * `ContainerLookupErrorDomain` → bundle id not installed → `:skipped`.
  * `MissingProfileError` / `NotPaired` → device-pairing problem,
    real error.
  * Anything else exit != 0 → `:error` with trimmed output.

Output is the combined stdout+stderr; exit_code is the process exit
status. Returns `{outcome, reason}`.

Public for regression-testing without a paired physical device.

# `parse_package_list`

```elixir
@spec parse_package_list(String.t()) :: [String.t()]
```

Parse `adb shell pm list packages <prefix>` output into a list of
package names. Returns names without the `package:` prefix, sorted
for deterministic ordering.

    iex> MobDev.Uninstaller.parse_package_list("package:com.example.a\npackage:com.example.b\n")
    ["com.example.a", "com.example.b"]

    iex> MobDev.Uninstaller.parse_package_list("")
    []

    iex> MobDev.Uninstaller.parse_package_list("garbage\n")
    []

# `plan`

```elixir
@spec plan(keyword()) :: {:ok, plan()} | {:error, plan_error(), map()}
```

Build the (devices × apps) plan without executing it.

The Mix task uses this to render a "what's about to happen" preview
and prompt for confirmation before any destructive work runs.

Recognized opts: same as `uninstall_all/1`.

Returns:
  * `{:ok, plan}` — plan ready to execute. Each pair has at least
    one device and one bundle id.
  * `{:error, :no_devices, %{detected: 0}}` — no connected devices.
  * `{:error, :ambiguous_devices, %{detected: N}}` — multiple
    devices connected, no `--device` or `--all-devices` flag.
  * `{:error, :no_matching_devices, %{requested: [...]}}` — the
    user passed `--device` IDs but none matched.

# `preview_lines`

```elixir
@spec preview_lines([{MobDev.Device.t(), [String.t()]}]) :: [String.t()]
```

Build a human-readable preview of the uninstall matrix.

Used by the Mix task to show "what's about to happen" before the
destructive step. Lines are colored ANSI (faint/cyan/dim) but the
ANSI codes can be stripped for assertion-friendly tests.

# `select_devices`

```elixir
@spec select_devices([MobDev.Device.t()], [String.t()], keyword()) :: [
  MobDev.Device.t()
]
```

Pick the target device set from `all` connected devices given the
user-supplied opts.

Precedence:
  1. `:device_ids` non-empty — match by id, regardless of type
     (the user typed the id, that's explicit consent).
  2. Both `:all_devices` and `:all_physical` — every connected
     device.
  3. `:all_devices` only — emulators/simulators (NEVER physical).
     The destructive sweep is safe by default; touching a physical
     device requires explicit `--all-physical` or `--device <id>`.
  4. `:all_physical` only — physical devices only.
  5. Auto-detect: exactly one NON-physical device → target it.
     Physical devices are never the auto-target.

Public for testing — the precedence ladder is the safety contract.

# `uninstall_all`

```elixir
@spec uninstall_all(keyword()) :: {[result()], [result()], [result()]}
```

Top-level orchestration: discover devices, resolve target apps,
run the uninstall matrix. Equivalent to `plan/1 |> execute_plan/1`
but raises on the plan-error cases instead of returning a tuple —
useful for tests that just want results without going through the
Mix-task layer.

Recognized opts: see `plan/1`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
