⚠️ Experimental — use at your own risk.This crate is in active development. The API has no stability guarantees: types, method signatures, and module structure can change in any release without prior notice. It is shared publicly for experimentation and feedback, not for production use.
The underlying GRASS GIS C library relies heavily on process-level global state. While this crate wraps it behind an ergonomic async API, it cannot eliminate all sources of undefined behaviour or subtle interaction effects. You are expected to understand the constraints described below before building on top of this library.
Experimental, high-level async Rust bindings for GRASS GIS 8.x.
grass-gis provides an ergonomic Rust interface around the GRASS GIS C library,
sitting between grass-gis-sys
(raw FFI via bindgen) and your application code.
All GRASS C calls execute on a single dedicated OS thread (the worker). Your
code interacts through GrassClient — an async, Send + Sync, cheaply cloneable
handle. Operations are submitted via async channels; results return via one-shot
channels.
your code (any thread)
│
├── client.raster_read_f64_row(...) ──┐
├── client.vector_write_line(...) ──┤ async mpsc channel
└── client.shutdown() ──┤ (OpRequest)
│
┌─────────▼─────────┐
│ Worker Thread │
│ (single OS thread)│
│ │
│ dispatch(op) { │
│ match op.kind { │
│ GisOp::... │
│ RasterOp::... │
│ VectorOp::... │
│ ... │
│ } │
│ } │
└─────────┬─────────┘
│ oneshot channel (OpReply)
┌─────────▼─────────┐
│ await result │
└───────────────────┘
This design confines the GRASS C library's global state to one thread, while your code stays fully concurrent. It does not make the GRASS C library memory-safe — it manages the access pattern.
use grass_gis::{GrassRuntime, gis::Region};
#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
let handle = GrassRuntime::new("my_module")
.capacity(16)
.spawn()?;
let client = handle.client().clone();
let region = Region::current(&client).await?;
println!("{}x{} cells", region.cols, region.rows);
handle.shutdown_and_join().await?;
Ok(())
}| Module | Description |
|---|---|
gis |
Region management, projections, location/mapset queries |
raster |
Type-safe raster I/O (CELL/FCELL/DCELL), null values, colors, categories, history |
vector |
Type-safe vector I/O, features, topology queries, spatial finders, bounding boxes |
segment |
Random-access raster tiling via Segment<T> |
db |
SQL execution, cursor-based queries, attribute row access |
parser |
GRASS command-line argument parsing |
mapset |
Mapset/location queries, search path management |
error |
Result<T, GrassError> error handling (16 variants) |
Many GRASS C functions call G_fatal_error() which would normally terminate the
process. The worker catches these via longjmp (through FatalGuard) and returns
Err(GrassError::Fatal{..}) instead — your application keeps running.
This mechanism is best-effort: G_fatal_error paths that allocate or hold locks
before jumping may leave internal GRASS state inconsistent. Treat a fatal error as
a signal to shut the session down rather than to continue operating normally.
use grass_gis::{GrassRuntime, RasterReader, RasterType};
#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
let handle = GrassRuntime::new("raster_example").spawn()?;
let client = handle.client().clone();
// Read metadata
let meta = grass_gis::RasterMetadata::read(&client, "elevation", "")
.await?;
println!("{}x{} ({:?})", meta.rows, meta.cols, meta.cell_type);
// Open and read rows
let reader = RasterReader::open(&client, "elevation", "").await?;
for row in 0..reader.rows() {
let data = reader.read_f64_row(row).await?;
// ... process row data ...
}
// Create and write a new raster
let mut writer = grass_gis::RasterWriter::create(
&client, "output_map", RasterType::DCell,
).await?;
let row_data: Vec<f64> = (0..writer.cols() as usize)
.map(|i| i as f64 * 1.5).collect();
writer.write_f64_row(0, &row_data).await?;
writer.flush().await?;
writer.close().await?;
reader.close().await?;
handle.shutdown_and_join().await?;
Ok(())
}use grass_gis::{GrassRuntime, VectorMapClient, FeatureType, PointsData, CatsData};
#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
let handle = GrassRuntime::new("vector_example").spawn()?;
let client = handle.client().clone();
// Open existing vector map
let map = VectorMapClient::open_readonly(&client, "roads", "").await?;
println!("Lines: {}, Areas: {}", map.num_lines().await?, map.num_areas().await?);
// Iterate features
let mut iter = map.iter_features();
while let Some(feature) = iter.next().await? {
println!("{:?}: {} points", feature.ftype(), feature.num_points());
}
map.close().await?;
// Create a new map and write features
let mut out = VectorMapClient::open_write(&client, "output", false).await?;
let points = PointsData::new_2d(vec![100.0, 150.0], vec![200.0, 250.0]);
let mut cats = CatsData::new();
cats.push(1, 1);
out.write_line(FeatureType::LINE, points, cats).await?;
out.build().await?;
out.close().await?;
handle.shutdown_and_join().await?;
Ok(())
}use grass_gis::{GrassRuntime, segment::Segment};
#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
let handle = GrassRuntime::new("segment_example").spawn()?;
let client = handle.client().clone();
let seg: Segment<f64> = Segment::create(&client, 100, 100, 64, 64, 4).await?;
seg.put(0, 0, 3.14).await?;
let val = seg.get(0, 0).await?;
assert_eq!(val, 3.14);
// Batch row I/O
let row: Vec<f64> = (0..100).map(|i| i as f64).collect();
seg.put_row(1, &row).await?;
let readback = seg.get_row(1).await?;
seg.close().await?;
handle.shutdown_and_join().await?;
Ok(())
}use grass_gis::{GrassRuntime, VectorMapClient, DbDriver};
#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
let handle = GrassRuntime::new("db_example").spawn()?;
let client = handle.client().clone();
// Query attributes for a vector map with an attribute table
let map = VectorMapClient::open_readonly(&client, "my_points", "").await?;
let rows = client.query_attributes(
map.handle(), 1,
"SELECT cat, name, value FROM my_points WHERE value > 0.0",
).await?;
for row in rows {
println!("cat={:?} name={:?} value={:?}",
row.get_i64("cat"),
row.get_str("name"),
row.get_f64("value"),
);
}
// Direct SQL execution via DbDriver
let field_info = client.field_info(map.handle(), 1).await?
.expect("expected dblink on layer 1");
let driver = DbDriver::open(&client, &field_info).await?;
let mut cursor = driver.open_select(
"SELECT * FROM my_points ORDER BY cat"
).await?;
while let Some(attr_row) = cursor.fetch().await? {
println!("{:?}", attr_row.values());
}
cursor.close().await?;
map.close().await?;
handle.shutdown_and_join().await?;
Ok(())
}A full Rust reimplementation of the GRASS r.slope.aspect module, demonstrating
how to build complete GRASS workflows using the async API.
# Build and run
export GISBASE=/path/to/grass86
export LD_LIBRARY_PATH=$GISBASE/lib:$LD_LIBRARY_PATH
export GISDBASE=/tmp/grass_test_db LOCATION_NAME=test_location MAPSET=test_mapset
export INPUT_MAPSET=test_mapset
cargo run --release --example r_slope_aspect -- \
elevation=test_elevation_f64 slope=rslope aspect=raspect \
dx=rdx dy=rdy precision=dcellOutputs slope, aspect, curvatures, and first/second partial derivatives from an elevation raster using Horn's 3×3 finite-difference window. All 9 output types match the C implementation exactly on projected coordinate systems.
Limitation: Geodesic distance (G_distance()) is not yet exposed in
grass-gis, so latlon projections use a planar approximation for H/V distance
factors. Results match the C version on projected (UTM, metric) data.
Benchmarked against the reference C implementation of r.slope.aspect on grids
from 4 000 × 4 000 to 16 000 × 16 000 cells (FCELL and DCELL output, 10 timed
runs each, median wall time):
| Scenario | Wall time vs C |
|---|---|
| Rust, 1 thread | 1–23 % slower |
| Rust, best thread count | 12–35 % faster |
Single-threaded Rust carries a small overhead from the async channel protocol and Tokio runtime. With 2–8 worker threads the parallelised row pipeline outpaces the C implementation across all tested grid sizes.
See benchmark_results/ for the full dataset and
instructions on reproducing the benchmark.
| Script | Description |
|---|---|
scripts/benchmark.py |
Benchmark runner and summary (run / summary subcommands) |
scripts/benchmark_slope_aspect.sh |
Compare Rust vs C performance and output |
scripts/setup_benchmark_data.sh |
Generate synthetic elevation rasters for benchmarking |
- Rust 1.94+ (edition 2024)
- GRASS GIS 8.x installed locally (tested with 8.6)
- C linker must find
libgrass_gis.so— configure in.cargo/config.toml:
[target.x86_64-unknown-linux-gnu]
rustflags = ["-L", "/path/to/grass86/lib"]| Variable | Required | Description |
|---|---|---|
GISBASE |
Yes (build + run) | Path to GRASS installation |
LD_LIBRARY_PATH |
Yes (build + run) | Must include $GISBASE/lib |
GISDBASE |
Test only | GRASS database directory (default: /tmp/grass_test_db) |
LOCATION_NAME |
Test only | Location name (default: test_location) |
MAPSET |
Test only | Mapset name (default: test_mapset) |
GRASS_SKIP_INTEGRATION |
Optional | Set to skip integration tests |
export GISBASE=/path/to/grass86
export LD_LIBRARY_PATH=$GISBASE/lib:$LD_LIBRARY_PATH
# Release builds may need:
export BINDGEN_EXTRA_CLANG_ARGS="-I$GISBASE/include"
cargo build# All tests (test-threads=1 is enforced automatically via .cargo/config.toml)
cargo test
# Unit tests only (no GRASS installation needed)
GRASS_SKIP_INTEGRATION=1 cargo test
# Specific test
cargo test test_raster_reader_dimensions
# Run doc-tests from README and source code
cargo test --docEach integration test spawns its own dedicated GRASS worker with an isolated
temporary mapset. The .cargo/config.toml file enforces test-threads = 1
because GRASS C maintains process-level global state (SQLite driver connections,
G_ globals) that can race when multiple worker threads are alive simultaneously.
The README code snippets are marked with rust,no_run (they compile but don't
execute — they need a live GRASS installation). You can verify they compile by
running:
cargo test --docThis compiles every no_run code block in the README and in src/**/*.rs doc
comments without executing them. Snippets marked ignore (which require runtime
state like an active worker) are skipped by cargo test.
Limitation: Full execution of doc-tests is not practical because every operation requires a running GRASS worker thread (with
GISBASEset, a validGISDBASE, etc.). Use the integration test suite (cargo test) for end-to-end verification against a real GRASS installation.
- Single session per process. GRASS C globals (
G_*, SQLite driver handles) are process-wide. Running twoGrassRuntimeworkers concurrently is untested and likely to corrupt state. - No Windows support. GRASS GIS does not ship shared libraries for Windows.
longjmp-based fatal recovery is best-effort. See Fatal Error Recovery above.- API coverage is partial. Imagery, 3D raster, and display modules are not yet wrapped. See API Coverage below.
latlonprojections use planar approximation for H/V distance factors in the slope/aspect example.
| GRASS Module | C Functions | Status | Notes |
|---|---|---|---|
gis (G_*) |
634 | ✅ Core | init, region, projection, messaging |
raster (Rast_*) |
389 | ✅ Core | read/write, null, colors, categories, history |
vector (Vect_*) |
358 | ✅ Core | open/close, read/write, build, topology, spatial queries |
segment (Segment_*) |
11 | ✅ Core | open/close, get/put cell, get/put row, flush |
db (db_*) |
412 | ✅ Core | SQL execution, cursor fetch, column metadata |
parser (G_parser) |
— | ✅ Core | command-line argument parsing |
imagery (I_*) |
188 | 🔜 Planned | |
raster3d (Rast3d_*) |
242 | 🔜 Planned |
Use GrassSession when launching a GRASS tool as a standalone binary outside an
active GRASS shell session. It bootstraps the environment, writes a temporary
GISRC, and optionally scaffolds a fresh mapset.
| Type | Description |
|---|---|
GrassSession |
Builder — resolves GISBASE/GISDBASE/location/mapset, scaffolds mapset, spawns worker |
GrassSessionHandle |
Owns the session; removes GISRC on drop; .client(), .shutdown_and_join() |
use grass_gis::GrassSession;
#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
let session = GrassSession::new("my_tool")
.from_env() // reads GISBASE, GISDBASE, LOCATION_NAME, MAPSET
.fresh_mapset("my_output") // wipe-and-recreate output mapset
.spawn()?;
let client = session.client().clone();
// ... do work ...
session.shutdown_and_join().await?;
Ok(())
}Use GrassRuntime directly when you are already inside a GRASS session (env vars
set) and only need the async worker handle without session lifecycle management.
| Type | Description |
|---|---|
GrassRuntime |
Builder — new("module").capacity(n).spawn() |
GrassRuntimeHandle |
.client() → &GrassClient, .join(), .shutdown_and_join() |
GrassClient |
~70 async methods for GIS, raster, vector, DB, segment, parser |
| Type | Module | Description |
|---|---|---|
RasterReader |
raster |
Open, read rows (i32/f32/f64/native), metadata, close |
RasterWriter |
raster |
Create, write rows (sequential), flush, close |
RasterMetadata |
raster |
Read header, history, color table, categories in one call |
RasterCategories |
raster |
Read/write category labels |
VectorMapClient |
vector |
Open/close, read/write lines, build, topology, spatial finders, iterators |
Feature |
vector |
Feature data — id, type, points, categories |
FeatureIter |
vector |
Iterator over all features |
Area / AreaIter |
vector |
Area data with boundaries, centroid, size, perimeter |
BBox |
vector |
2D/3D bounding box with containment/intersection tests |
DbDriver / DbCursor |
db |
SQL execution, cursor-based fetching |
AttributeRow / AttributeRows |
db |
Typed attribute access (i64, f64, str) |
Segment<T> |
segment |
Generic random-access raster tiling |
ParserClient |
parser |
GRASS command-line argument parsing |
Region |
gis |
Region queries, apply, from_raster |
Projection |
gis |
Projection info with lat/lon detection |
Mapset / Location |
mapset |
Mapset/location queries, map listing |
MapsetSearchPath |
mapset |
Search path management |
This project uses jj (Jujutsu) — a modern, git-compatible VCS — instead of raw git.
# View change history
jj log
# View current changes
jj diff
# Create a new change on top
jj new "description of next step"
# Squash a child change into its parent
jj squash -s <child-change-id>GPL-3.0-only — matching the GRASS GIS license.
