A terminal-first DICOM node client built around dicom-rs.
This repository does not publish GitHub releases yet. For now, treat main as a build-from-source prerelease track, not a stable packaged product.
- Track packaging-facing changes in
CHANGELOG.md. - Use the prerelease-only versioning and release gates in
docs/release-checklist.md. - Until a tagged prerelease exists, installation support is source-first and no binary artifact contract is promised yet.
This project intentionally does not include any image viewer, thumbnailer, or preview feature. It focuses on:
- Remote DICOM node persistence and editing
- C-FIND study/series/image querying
- C-MOVE retrieval with an embedded storage SCP
- Local study indexing in SQLite (schema is initialized/updated automatically on startup; see
src/db.rs) - C-STORE sending of local study/series content to other nodes
- Importing DICOM files from directories recursively (with configurable safety limits)
- Importing ZIP files containing DICOM files, even when the files do not use a
.dcmextension (with ZIP safety hardening) - A
ratatui+crosstermcommand-driven TUI
Until the first tagged prerelease exists, the supported installation path is a local source build.
Prerequisites:
- Rust 1.88 or newer
- Cargo
- A C compiler for the bundled SQLite dependency used by
rusqlite
Build from source:
git clone https://github.com/ThalesMMS/rusty-dicom-node.git
cd rusty-dicom-node
cargo build --release
./target/release/dicom-node-client --helpOn Windows:
target\release\dicom-node-client.exe --helpInstall locally from a checkout:
cargo install --path .
dicom-node-client --helpThis section is written to get you from zero to a successful end-to-end run:
- configure your local AE title
- add a remote node
- run a C-FIND query
- retrieve a study (C-MOVE)
- import from disk into the local index
- inspect the local inventory
- send to another node (C-STORE)
- check logs / task results
All commands below assume you are in a source checkout and use cargo run -- ....
After cargo install --path ., replace cargo run -- with dicom-node-client.
cargo build --release
./target/release/dicom-node-client --helpOn first run, the app creates:
- a config file (paths depend on OS)
- a local SQLite database
- a managed storage directory for received/imported objects
Run any command once to initialize:
cargo run -- --help >/dev/nullThen find the config + data locations:
- See “Configuration” and “Log locations” sections below in this README.
- See
docs/node-setup.mdfor a deeper explanation of AE titles, nodes, and move destinations. - See
docs/query-filters.mdfor supported query levels and filters. - See
docs/retrieve-c-move.mdfor C-MOVE retrieval prerequisites, examples, and troubleshooting. - See
docs/import-and-local-indexing.mdfor importing from disk and what gets indexed locally. - See
docs/send.mdfor sending local studies/series (C-STORE SCU) to another node. - See
docs/storage-scp.mdfor running a standalone Storage SCP (C-STORE receiver). - See
docs/logs-and-troubleshooting.mdfor log locations, verbosity, and troubleshooting common failures.
Notes:
- Your local AE title is what remote nodes will use as the move destination for C-MOVE.
- C-MOVE requires that something is listening as a storage SCP for the move destination AE.
dicom-node-client retrieve ...will start an embedded storage SCP automatically when needed.
Create a named entry for a PACS / test SCP you can reach:
cargo run -- node add \
--name pacs \
--ae-title PACSAE \
--host 10.0.0.10 \
--port 104 \
--move-destination DICOMNODECLIENT
cargo run -- node list--nameis a local nickname used by other commands (--node pacs).--ae-title/--host/--portdescribe the remote peer.--move-destinationis the default destination AE used byretrieveif you don’t override it.
cargo run -- query \
--node pacs \
--patient-name "DOE^JOHN" \
--study-date-from 20240101 \
--study-date-to 20241231 \
--modality CTIf you want machine-readable output:
cargo run -- query --node pacs --patient-name "DOE^JOHN" --jsonYou need a Study Instance UID from query results:
cargo run -- retrieve \
--node pacs \
--study-instance-uid 1.2.3.4.5 \
--move-destination DICOMNODECLIENTWhat to expect:
- progress is shown in the terminal
- datasets are written under the app’s managed storage directory (see config)
- the local SQLite index is updated as objects arrive
If you already have DICOM files on disk:
cargo run -- import /path/to/folder-or-archive.zipUse --json to capture an operation summary:
cargo run -- import /path/to/folder-or-archive.zip --jsoncargo run -- local studiesMore details (filters, series listing, export, and TUI drill-down):
First add (or reuse) a destination node:
cargo run -- node add --name archive --ae-title ARCHIVEAE --host 10.0.0.11 --port 104Send an entire study (by Study Instance UID):
cargo run -- send study --study-instance-uid 1.2.3.4.5 --destination-node archiveOr send a single series:
cargo run -- send series --series-instance-uid 1.2.3.4.5.6 --destination-node archive- CLI commands print a human summary; many support
--json. - The TUI has a dedicated Tasks pane to inspect running/finished operations.
- Logs are also written to disk (see "Log locations" below).
Start the TUI:
cargo run -- tuiCommon first-success flow in the TUI:
- Add a node: focus Nodes pane → press
a(or typenode add ...in the input prompt). - Query: select a node → press
f(or typequery node=pacs ...). - Retrieve: in the Query pane → press
mon a selected result (or typeretrieve node=pacs study_uid=...). - Import: in the Local pane → press
i(or typeimport path=...). - Inspect: in Local pane, use
Enterto drill down studies → series → instances. - Send: in Local pane → press
s(or typesend-study .../send-series ...). - Review results: focus Tasks or Logs pane.
Most list panes now include a right-side header counter showing the current selection and total items (for example 3/120, or -/0 when nothing is selected or the list is empty). When a list contains more items than can fit in the current pane height, the UI shows small up/down indicators (▲ / ▼) to hint that more items exist off-screen.
The Logs pane header shows how many log entries are visible vs total, and indicates when older entries are hidden by the visible window ("capped").
Background tasks (query/retrieve/import/send) run without freezing the UI. While a task is running, the footer shows a spinner + elapsed time and how many tasks are queued.
A Tasks pane is available for inspecting queued and recently finished tasks:
- Press
Tabuntil the Tasks pane is focused. - Use
Up/Down(orj/k) to select a task. - Press
tto toggle between queued tasks and task history. - Press
Enterto open a task details modal, including per-task logs and the final status.
Tasks pane: focus, navigation, toggle, details
Footer: / Running query node=pacs... 00:03 | 2 queued
[Tab] Tasks
> #12 Running Querying pacs... 00:03
#13 Queued Retrieving from pacs...
#14 Queued Importing ./incoming...
Up/Down or j/k move selection | t queued/history | Enter details
Then enter commands at the bottom prompt:
node add name=pacs ae=PACSAE host=10.0.0.10 port=104 dest=DICOMNODECLIENT
import path=/path/to/folder-or-archive.zip
query node=pacs patient_name="DOE^JOHN" date_from=20240101 date_to=20241231 modality=CT accession=ACC-123
retrieve node=pacs study_uid=1.2.3.4.5 dest=DICOMNODECLIENT
send-study node=archive study_uid=1.2.3.4.5
send-series destination_node=archive series_uid=1.2.3.4.5.6
After cargo install --path ., replace cargo run -- with dicom-node-client.
rusqlite is built with bundled SQLite, so source builds need a working C compiler. GitHub Actions Ubuntu runners include the required compiler toolchain.
| Platform | Support | Notes |
|---|---|---|
| Linux | Supported | Install Rust and the standard distribution build tools, including a C compiler. |
| macOS | Supported | Install Rust and Apple Command Line Tools or Xcode. |
| Windows | Supported | Install Rust with the MSVC toolchain and Microsoft C++ Build Tools. |
This project uses two Rust ecosystem security tools:
cargo audit(RustSec) to detect vulnerable or yanked dependencies.cargo denyto enforce policy around advisories, licenses, crate bans, and allowed sources.
Run locally from a checkout:
# RustSec advisories / yanked crates
cargo install cargo-audit --locked
cargo audit
# Policy checks (uses ./deny.toml)
cargo install cargo-deny --locked
cargo deny checkIf CI fails:
- Advisory/vulnerability: update the dependency (preferred), or add a narrowly-scoped exception in
deny.tomlunder the appropriate section (with a comment explaining why). - License issue: either switch to an alternative dependency or update the
licenses.allow/licenses.exceptionsentries indeny.toml. - Source issue: dependencies are expected to come from crates.io; git/path dependencies will be rejected by the
sourcespolicy.
Run the full test suite, including in-process DICOM integration tests:
cargo testThe integration tests start C-FIND, C-MOVE, and C-STORE test SCPs in-process
using dicom-ul. They bind only to 127.0.0.1, allocate ephemeral ports, and
use isolated temporary data directories so they can run in parallel without an
external PACS or DICOM service.
Incoming C-STORE datasets handled by the embedded storage_scp are streamed to
a per-request temporary file (rather than buffered fully in memory) before being
parsed and persisted into the managed store/ directory. Temp files are cleaned
up on success and on most failure paths.
-
Case-insensitive matching depends on SQLite behavior: text filters use
LOWER(column) LIKE LOWER(?)for substring matching. This is broadly case-insensitive for ASCII, but behavior for non-ASCII characters depends on SQLite build options and collation support. -
Date/time filtering is lexicographic:
study_dateandimported_atfilters treat stored values as strings and apply inclusive ranges lexicographically. For reliable results:- Use
YYYYMMDDforstudy_date. - Use a consistent, sortable timestamp format for
imported_at(the app stores ISO/RFC3339-ish timestamps).
- Use
-
TUI filtering is partial: the TUI supports filtering via command arguments for
local studies(e.g.local studies patient_name=doe modality=CT imported_at=20250101.. duplicate=true). Export is CLI-only. -
No "retrieved_at" support yet: the current local index schema records
imported_attimestamps, but does not record a separate retrieve timestamp.
The networking layer distinguishes several classes of failures so you can tell "peer rejected the association" apart from "peer replied with a DIMSE failure status" or "the connection dropped".
Examples:
- Wrong called AE title (peer rejects association)
- No acceptable presentation context / transfer syntax
What it means:
- The DICOM association was not established, so no DIMSE operation was sent.
What to check:
- You are connecting to the right host/port.
- The remote AE title matches what the peer expects.
- The operation's SOP Class / transfer syntax is supported by the peer.
Examples:
- Response command set is missing required elements (e.g. Status)
- Response command field does not match the expected response type
What it means:
- The peer sent an invalid DIMSE response (or bytes were corrupted / decoded incorrectly). This is treated as a parse/validation error, not a status failure.
What to check:
- Peer implementation quirks; try capturing traffic with a DICOM-capable proxy.
- Confirm both sides agree on transfer syntax and maximum PDU length.
Examples:
status=0x0000successstatus=0xFF00/0xFF01pending (multi-response operations like C-FIND/C-MOVE)status=0xBxxxwarning (operation completed with warnings)status=0xAxxx/0xCxxxfailurestatus=0xFE00cancel
What it means:
- The peer parsed the request and produced a semantic result. Failures often include a short meaning (and sometimes a hint) to help diagnose the cause.
What to check:
- For C-MOVE failures, verify the move destination AE title is known to the PACS.
- For C-STORE failures, verify SOP classes / transfer syntaxes are accepted.
What it means:
- The association was established, but no response arrived before the configured receive timeout.
What to check:
- Peer performance / load.
- Network stability.
- Whether the peer keeps the connection open while processing.
Examples:
- TCP connection reset/closed mid-operation
What it means:
- The association was interrupted at the transport layer while waiting for a DIMSE response.
What to check:
- Middleboxes/NAT/firewalls dropping idle connections.
- Peer crash/restart.
- MTU / network path issues if failures correlate with large datasets.
Application data is stored in the platform-specific local data directory:
- Linux:
~/.local/share/rusty-dicom-node/ - macOS:
~/Library/Application Support/com.ThalesMMS.rusty-dicom-node/ - Windows:
%LOCALAPPDATA%\ThalesMMS\rusty-dicom-node\
The data directory contains:
config.json- application configuration-
The embedded Storage SCP (C-STORE/C-ECHO) now supports inbound association allowlists:
allowed_calling_aet: list of allowed Calling AE Titles.allowed_peer_ips: list of allowed peer IPs, as exact IPs (e.g.127.0.0.1) or CIDR ranges (e.g.10.0.0.0/8,fd00::/8).
If a list is empty or omitted, that check is skipped (backward-compatible allow-all behavior). If both lists are configured, both must match (AND).
Example snippet:
{ "allowed_calling_aet": ["MODALITY1", "PACS_A"], "allowed_peer_ips": ["127.0.0.1", "10.0.0.0/8", "192.168.1.0/24"] }Security note: AE Titles are not strong authentication; combine with network controls (firewalls/VPN) as needed.
-
rusty-dicom-node.sqlite3- local SQLite index- The main catalog table is
local_instances. - Study list ordering is backed by a
local_studiessummary table + index (idx_local_studies_ordering). - Numeric ordering for SeriesNumber / InstanceNumber uses persisted sort keys on
local_instances(e.g.series_number_sort_class,series_number_sort_int,instance_number_sort_class,instance_number_sort_int). - Upgrade note: on first run after upgrading, SQLite may spend time backfilling the new summary rows/sort keys and building indexes. This is a one-time cost.
- Compatibility note: the catalog schema avoids SQLite generated columns/expression indexes, so it should work on older SQLite versions (the app builds
rusqlitewith bundled SQLite).
- The main catalog table is
store/- managed local DICOM object storagelogs/- application logs
Existing users upgrading from pre-release versions will have data from the legacy location migrated automatically.
This project exposes two command surfaces:
- CLI for scripting and automation, using
subcommand --flag value - TUI for interactive use, using
command key=value
The TUI accepts both canonical parameter names and short aliases. Canonical
TUI names match the CLI flag names without the leading --, using underscores
instead of hyphens. When a value contains spaces or shell-sensitive characters,
quote it in the TUI input, for example patient_name="DOE^JOHN" or
study_description="Head CT".
Use the CLI when you want shell history, scripts, or automation:
dicom-node-client node add --name pacs --ae-title PACSAE --host 10.0.0.10 --port 104 --move-destination DICOMNODECLIENT --notes "Primary archive"
dicom-node-client node edit pacs --host 10.0.0.11 --port 11112 --move-destination DICOMNODECLIENT
dicom-node-client node delete pacs
dicom-node-client node list
dicom-node-client import /data/inbox
# Local inventory listing (filtering + export)
dicom-node-client local studies --patient-name doe --modality CT,MR
dicom-node-client local studies --study-date 20240101..20241231 --duplicate false
dicom-node-client local studies --imported-at ..2025-01-01T00:00:00Z --export json
dicom-node-client local studies --export csv --out /tmp/studies.csv
# Exported fields
#
# Exports are metadata-only (no pixel data / bulk binary content).
# JSON uses these exact keys; CSV uses the same names as headers.
#
# Study export row (local studies)
# - study_instance_uid: Stable StudyInstanceUID (primary identifier)
# - patient_name: PatientName (as indexed)
# - patient_id: PatientID (as indexed)
# - accession_number: AccessionNumber (may be empty)
# - study_date_max: Latest StudyDate seen for the study (string, typically YYYYMMDD)
# - study_description: StudyDescription
# - modalities: Comma-separated distinct modalities observed in the study
# - series_count: Number of series indexed for the study
# - instance_count: Number of instances indexed for the study
#
# Series export row (local series)
# - study_instance_uid: Parent StudyInstanceUID
# - series_instance_uid: Stable SeriesInstanceUID (primary identifier within the study)
# - series_number: SeriesNumber (may be null/empty depending on source)
# - series_description: SeriesDescription
# - modality: Series modality
# - instance_count: Number of instances indexed for the series
# - imported_at_max: Latest imported_at timestamp among instances in the series (string)
# List series within a study (supports filters + export)
dicom-node-client local series 1.2.3.4.5 --modality CT --duplicate true
dicom-node-client local series 1.2.3.4.5 --export csv
dicom-node-client query --node pacs --model study-root --level study --patient-name "DOE^JOHN" --patient-id MRN-123 --accession-number ACC-123 --study-instance-uid 1.2.3 --series-instance-uid 1.2.3.4 --sop-instance-uid 1.2.3.4.5 --study-date-from 20240101 --study-date-to 20241231 --modality CT --study-description "Head CT"
dicom-node-client retrieve --node pacs --study-instance-uid 1.2.3 --series-instance-uid 1.2.3.4 --sop-instance-uid 1.2.3.4.5 --move-destination DICOMNODECLIENT
dicom-node-client send study --study-instance-uid 1.2.3 --destination-node archive
dicom-node-client send series --series-instance-uid 1.2.3.4 --destination-node archive
dicom-node-client storage-scpInside the TUI, the command line at the bottom accepts the same operations in
key=value form:
help
refresh
node add name=pacs ae=PACSAE host=10.0.0.10 port=104 dest=DICOMNODECLIENT notes="Primary archive"
node edit target=pacs ae_title=PACSAE host=10.0.0.11 port=11112 move_destination=DICOMNODECLIENT
node delete target=pacs
import path=/data/inbox
query node=pacs model=study level=study patient_name="DOE^JOHN" patient_id=MRN-123 accession=ACC-123 study_uid=1.2.3 series_uid=1.2.3.4 instance_uid=1.2.3.4.5 date_from=20240101 date_to=20241231 modality=CT study_description="Head CT"
retrieve node=pacs study_instance_uid=1.2.3 series_instance_uid=1.2.3.4 sop_instance_uid=1.2.3.4.5 move_destination=DICOMNODECLIENT
send-study destination_node=archive study=1.2.3
send-series node=archive series_instance_uid=1.2.3.4
The TUI accepts the following short aliases in addition to canonical names:
aeorae_titledestormove_destinationaccessionoraccession_numberstudyorstudy_uidorstudy_instance_uidseriesorseries_uidorseries_instance_uidinstance_uidorsop_instance_uiddate_fromorstudy_date_fromdate_toorstudy_date_tonodeordestination_nodeforsend-studyandsend-series
| CLI flag or argument | TUI canonical name | TUI alias or shorthand |
|---|---|---|
--ae-title |
ae_title |
ae |
--move-destination |
move_destination |
dest |
--accession-number |
accession_number |
accession |
--study-instance-uid |
study_instance_uid |
study_uid, study |
--series-instance-uid |
series_instance_uid |
series_uid, series |
--sop-instance-uid |
sop_instance_uid |
instance_uid |
--study-date-from |
study_date_from |
date_from |
--study-date-to |
study_date_to |
date_to |
--destination-node |
destination_node |
node |
positional node in node edit and node delete |
target |
id, name |
| positional import path | path |
none |
The CLI uses send study and send series subcommands, while the TUI uses the
hyphenated commands send-study and send-series.
Imports (directories and ZIP archives) are designed to be streaming and bounded-memory, and can be constrained via config.json to reduce risk from extremely large trees or archives.
Common import-related limits (all optional; null disables a limit):
max_file_import_bytes: reject single files larger than this (checked via file metadata before reading).max_import_total_files: stop scanning once this many candidates have been seen.max_import_directory_depth: maximum recursion depth when importing a directory.max_import_path_length: reject overly-long filesystem paths / ZIP entry paths.
ZIP-specific hardening limits (all optional):
max_zip_entry_count: maximum number of entries processed from an archive.max_zip_entry_bytes: maximum uncompressed size per entry.max_zip_total_bytes: maximum total uncompressed bytes processed across all entries.
ZIP safety notes:
- Unsafe entry paths (absolute paths,
..components) are rejected. - If a ZIP contains multiple entries targeting the same normalized path, later entries are rejected (no overwrite).
After an import, the summary includes:
scanned_files: candidates encountered (directory entries or ZIP entries).accepted: successfully imported and persisted.duplicates: candidates identified as already present in the index (with a breakdown by duplicate cause).invalid_dicom: candidates that could be read but did not parse as a valid DICOM object.unreadable: candidates that could not be read/staged (permissions, I/O errors, limit rejections, ZIP corruption, unsafe ZIP paths, etc.).skipped: candidates skipped intentionally (if applicable).failed_cleanup: candidates where a temp/staged file cleanup failed.
Most network and ingest commands emit a structured operation summary at the end.
- In the CLI, the default output includes the normal command output (for example query matches, import report, etc.) followed by a concise summary block.
- In the TUI, completed tasks show the same summary content in the task details modal.
For scripting, supported commands also accept --json to emit a stable JSON summary (instead of human-readable output):
# Query (C-FIND)
dicom-node-client query --node pacs --patient-name "DOE^JOHN" --json
# Retrieve (C-MOVE)
dicom-node-client retrieve --node pacs --study-instance-uid 1.2.3 --move-destination DICOMNODECLIENT --json
# Send (C-STORE SCU)
dicom-node-client send study --study-instance-uid 1.2.3 --destination-node archive --json
# Import (directory or zip)
dicom-node-client import ./incoming --json
# Storage SCP session
dicom-node-client storage-scp --jsonThe JSON shape is designed to be stable across versions:
- A schema version field is included (
schema_version). - Enums serialize to explicit strings (do not rely on Rust
Debugoutput). - Optional fields are omitted when not applicable.
Summaries may include logs references pointing at the application's log file (typically logs/app.log).
- CLI: use the
logspath from the JSON summary, or read the path shown in the human-readable summary block. - TUI: the task detail view shows the same summary (including the log path) so you can jump to the right file without guessing.
Long-running operations (query/retrieve/import/send) run as background tasks so the TUI stays responsive.
- Open the Tasks pane (press
Tabuntil focused). - Select the running task.
- Press
cto request cancellation.
The task will immediately switch to Cancelling, then to Cancelled once the underlying operation reaches a cancellation checkpoint.
While a long-running CLI operation is running, press Ctrl-C to request cooperative cancellation.
- Cancellation is cooperative: the operation stops at safe checkpoints. Some work may complete after you request cancellation, but new work should not be started once cancellation is observed.
- Partial results/counters (for example, instances sent or files imported before cancellation) may be shown in task details and history.
- Temporary/staging files are cleaned up best-effort on cancellation.
- Some DICOM protocol operations do not send protocol-level DIMSE cancel/abort; cancellation is implemented primarily via cooperative checkpoints.
src/
cli.rs clap command definitions
config.rs application paths and config
db.rs SQLite persistence
dicom.rs DICOM object helpers and dataset builders
importer.rs directory/ZIP import + indexer
models.rs domain models
services.rs application service layer
tui.rs interactive terminal UI
net/
assoc.rs association helpers and DIMSE command builders
find.rs C-FIND SCU
move_scu.rs C-MOVE SCU
store_scu.rs C-STORE SCU
storage_scp.rs embedded C-STORE / C-ECHO SCP
transfer.rs supported storage SOP classes
config.json now carries a preferred_store_transfer_syntax setting which controls the default C-STORE transfer syntax preference proposed to peers. Supported values are:
jpeg2000_losslessexplicit_vr_little_endianimplicit_vr_little_endiandeflated_explicit_vr_little_endianexplicit_vr_big_endian
The default is jpeg2000_lossless.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.