Skip to main content

README.md

# FsNotify

[![Hex.pm](https://img.shields.io/hexpm/v/fs_notify.svg)](https://hex.pm/packages/fs_notify)
[![HexDocs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/fs_notify)
[![License](https://img.shields.io/hexpm/l/fs_notify.svg)](https://github.com/fahchen/fs_notify/blob/main/LICENSE)
[![CI](https://github.com/fahchen/fs_notify/actions/workflows/ci.yml/badge.svg)](https://github.com/fahchen/fs_notify/actions/workflows/ci.yml)

Cross-platform filesystem watcher for Elixir, backed by
[notify-rs](https://github.com/notify-rs/notify) via
[Rustler](https://github.com/rusterlium/rustler).

Watch files and directories and receive change events as messages in your own
process. Each native watcher runs on its own OS-backed thread (FSEvents on
macOS, inotify on Linux, ReadDirectoryChangesW on Windows) and forwards events
into the BEAM.

## Features

- Native, low-overhead watching via notify-rs (no polling).
- Each event is delivered straight to a subscriber process as
  `{:fs_notify_event, %FsNotify.Event{}}` — one message per event.
- Watch one path or many with a single watcher; recursive or not.
- Fine-grained event kinds (`:create`, `:modify`, `:remove`, `:access`,
  `:other`) with the notify subtype preserved in `detail`.
- Automatic cleanup: the OS watch stops when the subscriber process dies (or
  the returned reference is garbage-collected) — no leaks, even on crash.

## Installation

Add `fs_notify` to your deps in `mix.exs`:

```elixir
def deps do
  [
    {:fs_notify, "~> 0.1.0"}
  ]
end
```

FsNotify ships **precompiled NIFs** via
[`rustler_precompiled`](https://hexdocs.pm/rustler_precompiled), so a Rust
toolchain is **not** required for the supported targets (macOS / Linux /
Windows on x86_64 and aarch64). The artifact for your platform is downloaded
and verified against a checksum at compile time.

### Building from source

To compile the NIF locally instead of downloading it (e.g. an unsupported
target, or to hack on the Rust code), force a build — this needs `cargo`:

```bash
FS_NOTIFY_BUILD=1 mix compile
```

You can also force a build for all `rustler_precompiled` packages with
`config :rustler_precompiled, force_build_all: true`. Non-`:prod` Mix
environments build from source by default.

## Quick start

Watch from a process and handle events in `handle_info/2`:

```elixir
defmodule DirWatcher do
  use GenServer

  def start_link(dir), do: GenServer.start_link(__MODULE__, dir)

  @impl true
  def init(dir) do
    {:ok, _ref} = FsNotify.watch(dir, recursive: true)
    {:ok, dir}
  end

  @impl true
  def handle_info({:fs_notify_event, %FsNotify.Event{kind: kind, paths: paths}}, dir) do
    IO.puts("#{kind}: #{Enum.join(paths, ", ")}")
    {:noreply, dir}
  end
end

{:ok, _pid} = DirWatcher.start_link("/path/to/dir")
```

The watch stops automatically when `DirWatcher` stops — no cleanup needed.

## Usage

```elixir
# Start watching a directory (or a list of paths). Events go to the caller by default.
{:ok, ref} = FsNotify.watch("/path/to/dir", recursive: true)

# A single watcher can cover several paths:
{:ok, ref} = FsNotify.watch(["/path/a", "/path/b"])

# Receive events — one message per event, carrying all affected paths.
receive do
  {:fs_notify_event, %FsNotify.Event{kind: kind, paths: paths}} ->
    IO.inspect({kind, paths})
end

# Stop watching. Optional — the native watch also stops automatically when the
# subscriber process dies or `ref` is garbage-collected.
FsNotify.unwatch(ref)
```

Keep `ref` for as long as you want to watch; the watch is tied to the
subscriber process.

### Message shape

Each notify event arrives as its own message:

```elixir
{:fs_notify_event, %FsNotify.Event{kind: kind, detail: detail, paths: paths}}
```

- `kind` — top-level category: `:any | :access | :create | :modify | :remove | :other`.
- `detail` — notify sub-kind under `kind`: a bare atom (`:file`, `:folder`, `:read`,
  `:any`, `:other`) or a `{group, sub}` tuple (`{:data, :content}`, `{:metadata, :write_time}`,
  `{:name, :both}`, `{:open, :write}`, `{:close, :read}`). See `FsNotify.Event`.
- `paths` — affected paths as binaries; a reconciled rename carries `[from, to]`.

> notify reports different event kinds per platform, so do not rely on a
> specific `kind`/`detail` for the same operation across OSes.

### Options

| Option        | Default          | Description                                                       |
| ------------- | ---------------- | ---------------------------------------------------------------- |
| `:recursive`     | `true`           | Watch subdirectories recursively.                                |
| `:debounce`      | `0`              | Coalesce events over this many ms (notify-rs debouncer); 0 = off.|
| `:backend`       | `:recommended`   | `:recommended` (OS-native) or `:poll` (portable polling).        |
| `:poll_interval` | `0`              | Poll interval (ms) for the `:poll` backend; 0 = notify default.  |
| `:subscriber`    | calling process  | Process that receives `{:fs_notify_event, ...}` messages.         |

## How it works

```
notify-rs thread  --{:fs_notify_event, %Event{}}-->  subscriber process
```

The native `RecommendedWatcher` is held inside a Rustler resource. Events are
mapped to `%FsNotify.Event{}` in Rust and pushed directly to the subscriber,
one message per event. With `:debounce` set, events are coalesced natively via
[`notify-debouncer-full`](https://crates.io/crates/notify-debouncer-full). The
resource monitors the subscriber: when it dies (or when the resource is
garbage-collected), `notify` is dropped and the OS watch stops. There is no
GenServer in the path — no event-kind filtering or supervision; do that in your
own process if you need it.

## Development

```bash
mix deps.get
mix test          # runs unit + native integration tests (uses real temp dirs)
mix format
mix docs
```

## Releasing

Publishing a GitHub release triggers `.github/workflows/release.yml`, which
cross-compiles the NIFs, attaches them to the release, generates the
`rustler_precompiled` checksum file, and runs `mix hex.publish`.

Required steps:

1. Bump the version in `mix.exs` and `native/fs_notify/Cargo.toml` (keep them in sync).
2. Update `CHANGELOG.md`.
3. Commit and push to `main`.
4. Create the release: `gh release create v<version> --generate-notes`.

Pushes to `main` and PRs touching `native/**` build the targets too (no upload,
no publish) to catch cross-compile breakage early.

## License

[MIT](LICENSE)