Skip to content

Commit de8747c

Browse files
feat(sdk): add .NET SDK over libmoss (closes #431)
1 parent 899615e commit de8747c

15 files changed

Lines changed: 1438 additions & 0 deletions

sdks/dotnet/.gitignore

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## .NET build output
2+
bin/
3+
obj/
4+
*.user
5+
6+
## Test / coverage artifacts
7+
[Tt]est[Rr]esults/
8+
*.trx
9+
*.coverage
10+
coverage*.json
11+
coverage*.xml
12+
coverage*.cobertura.xml
13+
14+
## NuGet
15+
*.nupkg
16+
*.snupkg

sdks/dotnet/Moss.sln

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Microsoft Visual Studio Solution File, Format Version 12.00
2+
# Visual Studio Version 17
3+
VisualStudioVersion = 17.0.31903.59
4+
MinimumVisualStudioVersion = 10.0.40219.1
5+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Moss", "src\Moss\Moss.csproj", "{A1B2C3D4-0001-4000-8000-000000000001}"
6+
EndProject
7+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Moss.Tests", "tests\Moss.Tests\Moss.Tests.csproj", "{A1B2C3D4-0002-4000-8000-000000000002}"
8+
EndProject
9+
Global
10+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
11+
Debug|Any CPU = Debug|Any CPU
12+
Release|Any CPU = Release|Any CPU
13+
EndGlobalSection
14+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
15+
{A1B2C3D4-0001-4000-8000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
16+
{A1B2C3D4-0001-4000-8000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
17+
{A1B2C3D4-0001-4000-8000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
18+
{A1B2C3D4-0001-4000-8000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
19+
{A1B2C3D4-0002-4000-8000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
20+
{A1B2C3D4-0002-4000-8000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
21+
{A1B2C3D4-0002-4000-8000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
22+
{A1B2C3D4-0002-4000-8000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
23+
EndGlobalSection
24+
GlobalSection(SolutionProperties) = preSolution
25+
HideSolutionNode = FALSE
26+
EndGlobalSection
27+
EndGlobal

sdks/dotnet/README.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Moss .NET SDK
2+
3+
The .NET SDK for [Moss](https://github.com/usemoss/moss) — fast on-device
4+
retrieval. It wraps the native `libmoss` runtime through P/Invoke and exposes an
5+
idiomatic, async C# API for index management, hybrid search, and metadata
6+
filtering.
7+
8+
## Architecture
9+
10+
```
11+
┌──────────────────────────────────┐
12+
│ Your application code │
13+
└──────────────┬───────────────────┘
14+
15+
┌──────────────▼───────────────────┐
16+
│ Moss (managed C#) │ ← src/Moss
17+
│ MossClient — async API for │
18+
│ indexing, querying, management │
19+
└──────────────┬───────────────────┘
20+
│ P/Invoke ([DllImport("moss")])
21+
┌──────────────▼───────────────────┐
22+
│ libmoss (native C ABI) │ ← prebuilt runtime
23+
│ hybrid search, data models │
24+
└──────────────────────────────────┘
25+
```
26+
27+
- `src/Moss/` — the public SDK. `MossClient` plus the data models.
28+
- `src/Moss/Interop/` — the P/Invoke layer: raw `libmoss` declarations, C-ABI
29+
struct mirrors, UTF-8 marshaling, and native-memory conversion/cleanup.
30+
31+
The interop layer targets the same stable C ABI (`libmoss.h`) that the Go
32+
bindings bind via cgo.
33+
34+
## Quick start
35+
36+
```csharp
37+
using Moss;
38+
39+
using var client = new MossClient("your_project_id", "your_project_key");
40+
41+
await client.CreateIndexAsync("support-docs", new[]
42+
{
43+
new DocumentInfo("1", "Refunds are processed within 3-5 business days."),
44+
new DocumentInfo("2", "You can track your order on the dashboard."),
45+
});
46+
47+
await client.LoadIndexAsync("support-docs");
48+
49+
var results = await client.QueryAsync(
50+
"support-docs", "how long do refunds take?", new QueryOptions { TopK = 3 });
51+
52+
foreach (var doc in results.Docs)
53+
Console.WriteLine($"[{doc.Score:F3}] {doc.Text}");
54+
```
55+
56+
### Metadata filtering
57+
58+
Attach string metadata at index time and pass a JSON filter at query time:
59+
60+
```csharp
61+
await client.AddDocsAsync("support-docs", new[]
62+
{
63+
new DocumentInfo("3", "EU refund policy…",
64+
metadata: new Dictionary<string, string> { ["region"] = "eu" }),
65+
});
66+
67+
var results = await client.QueryAsync("support-docs", "refund policy",
68+
new QueryOptions
69+
{
70+
TopK = 5,
71+
FilterJson = "{\"region\": \"eu\"}",
72+
});
73+
```
74+
75+
## API surface
76+
77+
| Area | Methods |
78+
|------|---------|
79+
| Indexes | `CreateIndexAsync`, `GetIndexAsync`, `ListIndexesAsync`, `DeleteIndexAsync` |
80+
| Documents | `AddDocsAsync`, `DeleteDocsAsync`, `GetDocsAsync` |
81+
| Jobs | `GetJobStatusAsync` |
82+
| Local runtime | `LoadIndexAsync`, `UnloadIndexAsync`, `RefreshIndexAsync`, `QueryAsync` |
83+
84+
All methods are asynchronous and accept a `CancellationToken`. Failures from the
85+
native runtime surface as `MossException` (carrying the status `Code` and the
86+
`moss_last_error` message).
87+
88+
## The native runtime
89+
90+
The SDK calls into `libmoss`, distributed as a prebuilt native library
91+
(`libmoss.so` on Linux, `libmoss.dylib` on macOS, `moss.dll` on Windows). It
92+
must be discoverable at runtime — on the standard library search path, next to
93+
your application, or via `NativeLibrary` resolution. Building and unit-testing
94+
the SDK does **not** require the native library; only running queries does.
95+
96+
## Building and testing
97+
98+
Requires the [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0).
99+
100+
```bash
101+
cd sdks/dotnet
102+
dotnet build
103+
dotnet test # unit tests run without libmoss
104+
```
105+
106+
The unit tests cover the managed logic and the marshaling layer (UTF-8
107+
round-trips, native buffer packing, ABI struct sizes) and do not load the native
108+
library.
109+
110+
## License
111+
112+
[BSD 2-Clause License](../../LICENSE)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Runtime.InteropServices;
4+
5+
namespace Moss.Interop;
6+
7+
/// <summary>UTF-8 string marshaling helpers for the native boundary.</summary>
8+
internal static class Utf8
9+
{
10+
/// <summary>Allocates a NUL-terminated UTF-8 copy of <paramref name="value"/>, or
11+
/// <see cref="IntPtr.Zero"/> when it is null. Free with <see cref="Marshal.FreeCoTaskMem"/>.</summary>
12+
public static IntPtr Alloc(string? value)
13+
=> value is null ? IntPtr.Zero : Marshal.StringToCoTaskMemUTF8(value);
14+
15+
/// <summary>Reads a NUL-terminated UTF-8 string, mapping a null pointer to "".</summary>
16+
public static string Read(IntPtr ptr)
17+
=> ptr == IntPtr.Zero ? string.Empty : Marshal.PtrToStringUTF8(ptr) ?? string.Empty;
18+
19+
/// <summary>Reads a NUL-terminated UTF-8 string, preserving null as null (for optional fields).</summary>
20+
public static string? ReadOptional(IntPtr ptr)
21+
=> ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr);
22+
}
23+
24+
/// <summary>
25+
/// Tracks native allocations made while marshaling inputs and frees them all on
26+
/// <see cref="Dispose"/>. Strings are allocated via CoTaskMem; raw blocks via HGlobal.
27+
/// </summary>
28+
internal sealed class NativeArena : IDisposable
29+
{
30+
private readonly List<IntPtr> _coTaskMem = new();
31+
private readonly List<IntPtr> _hGlobal = new();
32+
33+
/// <summary>Allocate a UTF-8 string tracked by this arena.</summary>
34+
public IntPtr String(string? value)
35+
{
36+
IntPtr p = Utf8.Alloc(value);
37+
if (p != IntPtr.Zero) _coTaskMem.Add(p);
38+
return p;
39+
}
40+
41+
/// <summary>Allocate a raw block of <paramref name="bytes"/> bytes tracked by this arena.</summary>
42+
public IntPtr Alloc(int bytes)
43+
{
44+
IntPtr p = Marshal.AllocHGlobal(bytes);
45+
_hGlobal.Add(p);
46+
return p;
47+
}
48+
49+
/// <summary>Marshal an array of contiguous structs into a tracked native block.</summary>
50+
public IntPtr StructArray<T>(IReadOnlyList<T> items) where T : struct
51+
{
52+
if (items.Count == 0) return IntPtr.Zero;
53+
int size = Marshal.SizeOf<T>();
54+
IntPtr block = Alloc(size * items.Count);
55+
for (int i = 0; i < items.Count; i++)
56+
Marshal.StructureToPtr(items[i], block + i * size, false);
57+
return block;
58+
}
59+
60+
/// <summary>Marshal an array of floats into a tracked native block.</summary>
61+
public IntPtr FloatArray(IReadOnlyList<float>? values)
62+
{
63+
if (values is null || values.Count == 0) return IntPtr.Zero;
64+
IntPtr block = Alloc(sizeof(float) * values.Count);
65+
var tmp = new float[values.Count];
66+
for (int i = 0; i < values.Count; i++) tmp[i] = values[i];
67+
Marshal.Copy(tmp, 0, block, tmp.Length);
68+
return block;
69+
}
70+
71+
/// <summary>Marshal an array of UTF-8 strings into a tracked native array of char*.</summary>
72+
public IntPtr StringArray(IReadOnlyList<string> values)
73+
{
74+
if (values.Count == 0) return IntPtr.Zero;
75+
IntPtr block = Alloc(IntPtr.Size * values.Count);
76+
for (int i = 0; i < values.Count; i++)
77+
Marshal.WriteIntPtr(block, i * IntPtr.Size, String(values[i]));
78+
return block;
79+
}
80+
81+
public void Dispose()
82+
{
83+
foreach (IntPtr p in _coTaskMem) Marshal.FreeCoTaskMem(p);
84+
foreach (IntPtr p in _hGlobal) Marshal.FreeHGlobal(p);
85+
_coTaskMem.Clear();
86+
_hGlobal.Clear();
87+
}
88+
}

0 commit comments

Comments
 (0)