pub fn cid_from_bytes(data: &[u8]) -> String {
use sha2::Digest;
let digest: [u8; 32] = sha2::Sha256::digest(data).into();
let mut multihash = Vec::with_capacity(34);
multihash.push(0x12u8);
multihash.push(0x20u8);
multihash.extend_from_slice(&digest);
let mut cid_bytes = Vec::with_capacity(36);
cid_bytes.push(0x01u8);
cid_bytes.push(0x55u8);
cid_bytes.extend_from_slice(&multihash);
let encoded = base32_lower(&cid_bytes);
format!("b{encoded}")
}
fn base32_lower(input: &[u8]) -> String {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
let mut output = Vec::with_capacity((input.len() * 8).div_ceil(5));
let mut buffer: u64 = 0;
let mut bits_left: u32 = 0;
for &byte in input {
buffer = (buffer << 8) | u64::from(byte);
bits_left += 8;
while bits_left >= 5 {
bits_left -= 5;
let idx = ((buffer >> bits_left) & 0x1f) as usize;
output.push(ALPHABET[idx]);
}
}
if bits_left > 0 {
let idx = ((buffer << (5 - bits_left)) & 0x1f) as usize;
output.push(ALPHABET[idx]);
}
String::from_utf8(output).unwrap_or_default()
}
struct InMemoryStore {
blocks: std::collections::HashMap<String, Vec<u8>>,
total_bytes: usize,
}
impl InMemoryStore {
fn new() -> Self {
Self {
blocks: std::collections::HashMap::new(),
total_bytes: 0,
}
}
fn put(&mut self, data: &[u8]) -> String {
let cid = cid_from_bytes(data);
if !self.blocks.contains_key(&cid) {
self.total_bytes += data.len();
self.blocks.insert(cid.clone(), data.to_vec());
}
cid
}
fn get(&self, cid: &str) -> Option<&Vec<u8>> {
self.blocks.get(cid)
}
fn has(&self, cid: &str) -> bool {
self.blocks.contains_key(cid)
}
fn delete(&mut self, cid: &str) -> bool {
if let Some(data) = self.blocks.remove(cid) {
self.total_bytes = self.total_bytes.saturating_sub(data.len());
true
} else {
false
}
}
fn list(&self) -> Vec<String> {
self.blocks.keys().cloned().collect()
}
fn block_count(&self) -> usize {
self.blocks.len()
}
fn total_bytes(&self) -> usize {
self.total_bytes
}
}
#[cfg(feature = "napi")]
#[napi_derive::napi]
pub struct IpfrsClient {
data_dir: String,
store: std::sync::Mutex<InMemoryStore>,
}
#[cfg(not(feature = "napi"))]
pub struct IpfrsClient {
data_dir: String,
store: std::sync::Mutex<InMemoryStore>,
}
impl IpfrsClient {
pub fn create(data_dir: String) -> Result<Self, ClientError> {
Ok(Self {
data_dir,
store: std::sync::Mutex::new(InMemoryStore::new()),
})
}
pub fn add_bytes_inner(&self, data: &[u8]) -> Result<String, ClientError> {
let mut store = self
.store
.lock()
.map_err(|_| ClientError::lock("add_bytes"))?;
Ok(store.put(data))
}
pub fn get_bytes_inner(&self, cid: &str) -> Result<Option<Vec<u8>>, ClientError> {
let store = self
.store
.lock()
.map_err(|_| ClientError::lock("get_bytes"))?;
Ok(store.get(cid).cloned())
}
pub fn has_inner(&self, cid: &str) -> Result<bool, ClientError> {
let store = self.store.lock().map_err(|_| ClientError::lock("has"))?;
Ok(store.has(cid))
}
pub fn list_cids_inner(&self) -> Result<Vec<String>, ClientError> {
let store = self
.store
.lock()
.map_err(|_| ClientError::lock("list_cids"))?;
Ok(store.list())
}
pub fn delete_inner(&self, cid: &str) -> Result<bool, ClientError> {
let mut store = self.store.lock().map_err(|_| ClientError::lock("delete"))?;
Ok(store.delete(cid))
}
pub fn version_inner(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
pub fn stats_inner(&self) -> Result<String, ClientError> {
let store = self.store.lock().map_err(|_| ClientError::lock("stats"))?;
let json = serde_json::json!({
"data_dir": self.data_dir,
"block_count": store.block_count(),
"total_bytes": store.total_bytes(),
"version": env!("CARGO_PKG_VERSION"),
});
serde_json::to_string(&json).map_err(|e| ClientError::serialise(e.to_string()))
}
}
#[cfg(feature = "napi")]
#[napi_derive::napi]
impl IpfrsClient {
#[napi(constructor)]
pub fn new(data_dir: String) -> napi::Result<Self> {
IpfrsClient::create(data_dir).map_err(into_napi)
}
#[napi]
pub fn add_bytes(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<String> {
self.add_bytes_inner(data.as_ref()).map_err(into_napi)
}
#[napi]
pub fn get_bytes(&self, cid: String) -> napi::Result<Option<napi::bindgen_prelude::Buffer>> {
self.get_bytes_inner(&cid)
.map(|opt| opt.map(|v| v.into()))
.map_err(into_napi)
}
#[napi]
pub fn has(&self, cid: String) -> napi::Result<bool> {
self.has_inner(&cid).map_err(into_napi)
}
#[napi]
pub fn list_cids(&self) -> napi::Result<Vec<String>> {
self.list_cids_inner().map_err(into_napi)
}
#[napi]
pub fn delete(&self, cid: String) -> napi::Result<bool> {
self.delete_inner(&cid).map_err(into_napi)
}
#[napi]
pub fn version(&self) -> String {
self.version_inner()
}
#[napi]
pub fn stats(&self) -> napi::Result<String> {
self.stats_inner().map_err(into_napi)
}
}
#[cfg(feature = "napi")]
#[napi_derive::napi]
pub fn compute_cid(data: napi::bindgen_prelude::Buffer) -> String {
cid_from_bytes(data.as_ref())
}
#[cfg(not(feature = "napi"))]
pub fn compute_cid(data: &[u8]) -> String {
cid_from_bytes(data)
}
#[cfg(feature = "napi")]
#[napi_derive::napi]
pub fn verify_cid(cid: String, data: napi::bindgen_prelude::Buffer) -> bool {
cid_from_bytes(data.as_ref()) == cid
}
#[cfg(not(feature = "napi"))]
pub fn verify_cid(cid: &str, data: &[u8]) -> bool {
cid_from_bytes(data) == cid
}
#[cfg(feature = "napi")]
#[napi_derive::napi]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[cfg(not(feature = "napi"))]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[cfg(feature = "napi")]
#[napi_derive::napi]
pub struct Node {
inner: std::sync::Arc<tokio::sync::Mutex<ipfrs::Node>>,
runtime: std::sync::Arc<tokio::runtime::Runtime>,
}
#[cfg(feature = "napi")]
#[napi_derive::napi]
impl Node {
#[napi(constructor)]
pub fn new(config: Option<NodeConfig>) -> napi::Result<Self> {
let rust_config = config.map(|c| c.into_rust_config()).unwrap_or_default();
let runtime = tokio::runtime::Runtime::new()
.map_err(|e| napi::Error::from_reason(format!("Failed to create runtime: {e}")))?;
let inner = ipfrs::Node::new(rust_config)
.map_err(|e| napi::Error::from_reason(format!("Failed to create node: {e}")))?;
Ok(Self {
inner: std::sync::Arc::new(tokio::sync::Mutex::new(inner)),
runtime: std::sync::Arc::new(runtime),
})
}
#[napi]
pub async fn start(&self) -> napi::Result<()> {
let inner = self.inner.clone();
self.runtime
.block_on(async move {
let mut node = inner.lock().await;
node.start().await
})
.map_err(|e| napi::Error::from_reason(format!("Failed to start: {e}")))
}
#[napi]
pub async fn stop(&self) -> napi::Result<()> {
let inner = self.inner.clone();
self.runtime
.block_on(async move {
let mut node = inner.lock().await;
node.stop().await
})
.map_err(|e| napi::Error::from_reason(format!("Failed to stop: {e}")))
}
#[napi]
pub async fn put_block(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<String> {
let data_bytes: bytes::Bytes = bytes::Bytes::from(data.to_vec());
let block = ipfrs::Block::new(data_bytes)
.map_err(|e| napi::Error::from_reason(format!("Failed to create block: {e}")))?;
let cid = *block.cid();
let inner = self.inner.clone();
self.runtime
.block_on(async move {
let node = inner.lock().await;
node.put_block(&block).await
})
.map_err(|e| napi::Error::from_reason(format!("Failed to put block: {e}")))?;
Ok(cid.to_string())
}
#[napi]
pub async fn get_block(
&self,
cid: String,
) -> napi::Result<Option<napi::bindgen_prelude::Buffer>> {
let rust_cid: ipfrs::Cid = cid
.parse()
.map_err(|_| napi::Error::from_reason("Invalid CID"))?;
let inner = self.inner.clone();
let result = self.runtime.block_on(async move {
let node = inner.lock().await;
node.get_block(&rust_cid).await
});
match result {
Ok(Some(block)) => Ok(Some(block.data().to_vec().into())),
Ok(None) => Ok(None),
Err(e) => Err(napi::Error::from_reason(format!(
"Failed to get block: {e}"
))),
}
}
#[napi]
pub async fn has_block(&self, cid: String) -> napi::Result<bool> {
let rust_cid: ipfrs::Cid = cid
.parse()
.map_err(|_| napi::Error::from_reason("Invalid CID"))?;
let inner = self.inner.clone();
self.runtime
.block_on(async move {
let node = inner.lock().await;
node.has_block(&rust_cid).await
})
.map_err(|e| napi::Error::from_reason(format!("Failed to check block: {e}")))
}
#[napi]
pub async fn delete_block(&self, cid: String) -> napi::Result<()> {
let rust_cid: ipfrs::Cid = cid
.parse()
.map_err(|_| napi::Error::from_reason("Invalid CID"))?;
let inner = self.inner.clone();
self.runtime
.block_on(async move {
let node = inner.lock().await;
node.delete_block(&rust_cid).await
})
.map_err(|e| napi::Error::from_reason(format!("Failed to delete block: {e}")))
}
#[napi]
pub async fn index_content(&self, cid: String, embedding: Vec<f64>) -> napi::Result<()> {
let rust_cid: ipfrs::Cid = cid
.parse()
.map_err(|_| napi::Error::from_reason("Invalid CID"))?;
let embedding_f32: Vec<f32> = embedding.iter().map(|&v| v as f32).collect();
let inner = self.inner.clone();
self.runtime
.block_on(async move {
let node = inner.lock().await;
node.index_content(&rust_cid, &embedding_f32).await
})
.map_err(|e| napi::Error::from_reason(format!("Failed to index: {e}")))
}
#[napi]
pub async fn search_similar(
&self,
query: Vec<f64>,
k: u32,
) -> napi::Result<Vec<NodeSearchResult>> {
let query_f32: Vec<f32> = query.iter().map(|&v| v as f32).collect();
let k_usize = k as usize;
let inner = self.inner.clone();
let results = self
.runtime
.block_on(async move {
let node = inner.lock().await;
node.search_similar(&query_f32, k_usize).await
})
.map_err(|e| napi::Error::from_reason(format!("Search failed: {e}")))?;
Ok(results
.into_iter()
.map(|r| NodeSearchResult {
cid: r.cid.to_string(),
score: f64::from(r.score),
})
.collect())
}
#[napi]
pub fn add_fact(&self, fact: NodePredicate) -> napi::Result<()> {
let rust_fact = fact.into_rust_predicate()?;
let node = self.inner.blocking_lock();
node.add_fact(rust_fact)
.map_err(|e| napi::Error::from_reason(format!("Failed to add fact: {e}")))
}
#[napi]
pub fn add_rule(&self, rule: NodeRule) -> napi::Result<()> {
let rust_rule = rule.into_rust_rule()?;
let node = self.inner.blocking_lock();
node.add_rule(rust_rule)
.map_err(|e| napi::Error::from_reason(format!("Failed to add rule: {e}")))
}
#[napi]
pub fn infer(&self, goal: NodePredicate) -> napi::Result<Vec<String>> {
let rust_goal = goal.into_rust_predicate()?;
let node = self.inner.blocking_lock();
let results = node
.infer(&rust_goal)
.map_err(|e| napi::Error::from_reason(format!("Inference failed: {e}")))?;
Ok(results.into_iter().map(|s| format!("{s:?}")).collect())
}
#[napi]
pub fn prove(&self, goal: NodePredicate) -> napi::Result<Option<String>> {
let rust_goal = goal.into_rust_predicate()?;
let node = self.inner.blocking_lock();
let proof = node
.prove(&rust_goal)
.map_err(|e| napi::Error::from_reason(format!("Proof generation failed: {e}")))?;
Ok(proof.map(|p| format!("{p:?}")))
}
#[napi]
pub fn kb_stats(&self) -> napi::Result<KbStats> {
let node = self.inner.blocking_lock();
let stats = node
.tensorlogic_stats()
.map_err(|e| napi::Error::from_reason(format!("Failed to get stats: {e}")))?;
Ok(KbStats {
num_facts: stats.num_facts as u32,
num_rules: stats.num_rules as u32,
})
}
#[napi]
pub async fn save_semantic_index(&self, path: String) -> napi::Result<()> {
let inner = self.inner.clone();
self.runtime
.block_on(async move {
let node = inner.lock().await;
node.save_semantic_index(std::path::PathBuf::from(path))
.await
})
.map_err(|e| napi::Error::from_reason(format!("Failed to save index: {e}")))
}
#[napi]
pub async fn load_semantic_index(&self, path: String) -> napi::Result<()> {
let inner = self.inner.clone();
self.runtime
.block_on(async move {
let node = inner.lock().await;
node.load_semantic_index(std::path::PathBuf::from(path))
.await
})
.map_err(|e| napi::Error::from_reason(format!("Failed to load index: {e}")))
}
#[napi]
pub async fn save_kb(&self, path: String) -> napi::Result<()> {
let inner = self.inner.clone();
self.runtime
.block_on(async move {
let node = inner.lock().await;
node.save_knowledge_base(std::path::PathBuf::from(path))
.await
})
.map_err(|e| napi::Error::from_reason(format!("Failed to save KB: {e}")))
}
#[napi]
pub async fn load_kb(&self, path: String) -> napi::Result<()> {
let inner = self.inner.clone();
self.runtime
.block_on(async move {
let node = inner.lock().await;
node.load_knowledge_base(std::path::PathBuf::from(path))
.await
})
.map_err(|e| napi::Error::from_reason(format!("Failed to load KB: {e}")))
}
}
#[cfg(feature = "napi")]
#[napi_derive::napi(object)]
#[derive(Clone)]
pub struct NodeConfig {
pub storage_path: Option<String>,
pub enable_semantic: Option<bool>,
pub enable_tensorlogic: Option<bool>,
}
#[cfg(feature = "napi")]
impl NodeConfig {
fn into_rust_config(self) -> ipfrs::NodeConfig {
let mut config = ipfrs::NodeConfig::default();
if let Some(ref path) = self.storage_path {
config.storage.path = std::path::PathBuf::from(path);
}
if let Some(v) = self.enable_semantic {
config.enable_semantic = v;
}
if let Some(v) = self.enable_tensorlogic {
config.enable_tensorlogic = v;
}
config
}
}
#[cfg(feature = "napi")]
#[napi_derive::napi(object)]
pub struct NodeSearchResult {
pub cid: String,
pub score: f64,
}
#[cfg(feature = "napi")]
#[napi_derive::napi(object)]
pub struct KbStats {
pub num_facts: u32,
pub num_rules: u32,
}
#[cfg(feature = "napi")]
#[napi_derive::napi(object)]
#[derive(Clone)]
pub struct NodeTerm {
pub kind: String,
pub value: String,
}
#[cfg(feature = "napi")]
impl NodeTerm {
fn into_rust_term(self) -> napi::Result<ipfrs::Term> {
use ipfrs::{Constant, Term};
match self.kind.as_str() {
"int" => {
let val: i64 = self
.value
.parse()
.map_err(|_| napi::Error::from_reason("Invalid integer"))?;
Ok(Term::Const(Constant::Int(val)))
}
"float" => Ok(Term::Const(Constant::Float(self.value))),
"string" => Ok(Term::Const(Constant::String(self.value))),
"bool" => {
let val: bool = self
.value
.parse()
.map_err(|_| napi::Error::from_reason("Invalid boolean"))?;
Ok(Term::Const(Constant::Bool(val)))
}
"var" => Ok(Term::Var(self.value)),
other => Err(napi::Error::from_reason(format!(
"Unknown term kind: {other}"
))),
}
}
}
#[cfg(feature = "napi")]
#[napi_derive::napi(object)]
#[derive(Clone)]
pub struct NodePredicate {
pub name: String,
pub args: Vec<NodeTerm>,
}
#[cfg(feature = "napi")]
impl NodePredicate {
fn into_rust_predicate(self) -> napi::Result<ipfrs::Predicate> {
let rust_args: napi::Result<Vec<ipfrs::Term>> =
self.args.into_iter().map(|t| t.into_rust_term()).collect();
Ok(ipfrs::Predicate::new(self.name, rust_args?))
}
}
#[cfg(feature = "napi")]
#[napi_derive::napi(object)]
#[derive(Clone)]
pub struct NodeRule {
pub head: NodePredicate,
pub body: Vec<NodePredicate>,
}
#[cfg(feature = "napi")]
impl NodeRule {
fn into_rust_rule(self) -> napi::Result<ipfrs::Rule> {
let rust_head = self.head.into_rust_predicate()?;
let rust_body: napi::Result<Vec<ipfrs::Predicate>> = self
.body
.into_iter()
.map(|p| p.into_rust_predicate())
.collect();
let body = rust_body?;
if body.is_empty() {
Ok(ipfrs::Rule::fact(rust_head))
} else {
Ok(ipfrs::Rule::new(rust_head, body))
}
}
}
#[derive(Debug)]
pub struct ClientError {
message: String,
}
impl ClientError {
fn lock(op: &str) -> Self {
Self {
message: format!("Mutex poisoned during '{op}'"),
}
}
fn serialise(msg: String) -> Self {
Self { message: msg }
}
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "IpfrsClient error: {}", self.message)
}
}
impl std::error::Error for ClientError {}
#[cfg(feature = "napi")]
fn into_napi(e: ClientError) -> napi::Error {
napi::Error::from_reason(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_add_get_roundtrip() {
let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
let payload = b"hello, IPFRS!";
let cid = client.add_bytes_inner(payload).expect("add_bytes");
let retrieved = client
.get_bytes_inner(&cid)
.expect("get_bytes")
.expect("should be Some");
assert_eq!(payload.as_slice(), retrieved.as_slice());
}
#[test]
fn test_cid_deterministic() {
let data = b"determinism matters";
let cid1 = cid_from_bytes(data);
let cid2 = cid_from_bytes(data);
assert_eq!(cid1, cid2);
}
#[test]
fn test_cid_unique_per_content() {
let cid_a = cid_from_bytes(b"alpha");
let cid_b = cid_from_bytes(b"beta");
assert_ne!(cid_a, cid_b);
}
#[test]
fn test_has_returns_false_for_unknown() {
let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
let present = client
.has_inner("bafyreifake000000000000000000000000000000000000000")
.expect("has");
assert!(!present);
}
#[test]
fn test_has_returns_true_after_add() {
let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
let cid = client.add_bytes_inner(b"exists").expect("add_bytes");
assert!(client.has_inner(&cid).expect("has"));
}
#[test]
fn test_delete_block() {
let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
let cid = client.add_bytes_inner(b"to delete").expect("add_bytes");
assert!(client.has_inner(&cid).expect("has before delete"));
let removed = client.delete_inner(&cid).expect("delete");
assert!(removed);
assert!(!client.has_inner(&cid).expect("has after delete"));
}
#[test]
fn test_delete_unknown_returns_false() {
let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
let removed = client
.delete_inner("bafyreifake000000000000000000000000000000000000000")
.expect("delete");
assert!(!removed);
}
#[test]
fn test_list_cids() {
let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
assert!(
client.list_cids_inner().expect("list empty").is_empty(),
"starts empty"
);
let cid1 = client.add_bytes_inner(b"first").expect("add 1");
let cid2 = client.add_bytes_inner(b"second").expect("add 2");
let mut cids = client.list_cids_inner().expect("list two");
cids.sort();
let mut expected = vec![cid1, cid2];
expected.sort();
assert_eq!(cids, expected);
}
#[test]
fn test_stats_json_valid() {
let client =
IpfrsClient::create("/tmp/ipfrs-stats-test".to_string()).expect("create client");
client.add_bytes_inner(b"some data").expect("add");
let json_str = client.stats_inner().expect("stats");
let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
assert_eq!(parsed["block_count"], 1);
assert_eq!(parsed["data_dir"], "/tmp/ipfrs-stats-test");
assert!(parsed["total_bytes"].as_u64().unwrap_or(0) > 0);
}
#[test]
fn test_verify_cid() {
let data = b"verifiable content";
let cid = cid_from_bytes(data);
assert!(cid_from_bytes(data) == cid, "correct CID should verify");
assert!(cid_from_bytes(data) != "bwrongcid", "wrong CID should fail");
}
#[test]
fn test_compute_cid_free_function() {
let cid = cid_from_bytes(b"ipfrs nodejs");
assert!(
cid.starts_with('b'),
"CIDv1 multibase base32lower must start with 'b', got: {cid}"
);
assert_eq!(cid.len(), 59, "unexpected CID length: {}", cid.len());
}
#[test]
fn test_version_string() {
let v = version();
assert!(!v.is_empty(), "version must not be empty");
let parts: Vec<&str> = v.split('.').collect();
assert_eq!(parts.len(), 3, "version should be semver: {v}");
}
#[test]
fn test_idempotent_add() {
let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
let cid1 = client.add_bytes_inner(b"idempotent").expect("add 1");
let cid2 = client.add_bytes_inner(b"idempotent").expect("add 2");
assert_eq!(cid1, cid2, "same content must yield same CID");
let cids = client.list_cids_inner().expect("list");
assert_eq!(cids.len(), 1, "should only store one block");
}
#[test]
fn test_get_bytes_unknown_returns_none() {
let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
let result = client
.get_bytes_inner("bafyreifake000000000000000000000000000000000000000")
.expect("get_bytes");
assert!(result.is_none());
}
#[test]
fn test_cid_format_base32lower() {
let cid = cid_from_bytes(b"format check");
assert!(cid.starts_with('b'));
for ch in cid.chars().skip(1) {
assert!(
ch.is_ascii_lowercase() || ('2'..='7').contains(&ch),
"unexpected char '{ch}' in CID '{cid}'"
);
}
}
#[test]
fn test_cid_known_value_stability() {
let cid = cid_from_bytes(b"ipfrs stable");
assert!(cid.starts_with('b'));
assert_eq!(cid.len(), 59);
let cid2 = cid_from_bytes(b"ipfrs stable");
assert_eq!(cid, cid2);
}
}