Skip to main content

ipfrs_nodejs/
lib.rs

1//! Node.js bindings for IPFRS v0.2.0
2//!
3//! This module provides JavaScript/TypeScript bindings for IPFRS using NAPI-RS.
4//! It exposes two independent surface areas:
5//!
6//! 1. **`IpfrsClient`** – a self-contained, in-memory content-addressed store
7//!    (mirrors the WASM `IpfrsClient`) that works without a running tokio
8//!    runtime and is perfectly testable with `cargo test`.
9//!
10//! 2. **`Node`** – a full IPFRS node that integrates block storage, semantic
11//!    search, and TensorLogic reasoning.  When built with NAPI-RS this struct
12//!    is exposed to JavaScript; otherwise only the `#[cfg(test)]` surface is
13//!    available.
14//!
15//! Free functions:
16//! * `compute_cid(data)` – deterministic CIDv1 (SHA-256, raw codec, base32lower)
17//! * `verify_cid(cid, data)` – check a CID against raw bytes
18//! * `version()` – library version string
19
20// ---------------------------------------------------------------------------
21// CID helpers – identical algorithm to ipfrs-wasm for cross-platform parity
22// ---------------------------------------------------------------------------
23
24/// Compute a CIDv1 (base32-lower, SHA2-256, raw codec) string for `data`.
25///
26/// The encoding is:
27/// ```text
28/// <varint version=1><varint codec=0x55><varint mh-fn=0x12><varint mh-len=32><32-byte-digest>
29/// ```
30/// encoded in lowercase RFC 4648 base32 with the `b` multibase prefix.
31pub fn cid_from_bytes(data: &[u8]) -> String {
32    use sha2::Digest;
33
34    // 1. SHA-256 digest
35    let digest: [u8; 32] = sha2::Sha256::digest(data).into();
36
37    // 2. Multihash: [0x12 (sha2-256), 0x20 (32 bytes), <digest>]
38    let mut multihash = Vec::with_capacity(34);
39    multihash.push(0x12u8);
40    multihash.push(0x20u8);
41    multihash.extend_from_slice(&digest);
42
43    // 3. CIDv1: [0x01 (version), 0x55 (raw codec), <multihash>]
44    let mut cid_bytes = Vec::with_capacity(36);
45    cid_bytes.push(0x01u8);
46    cid_bytes.push(0x55u8);
47    cid_bytes.extend_from_slice(&multihash);
48
49    // 4. Multibase base32lower with 'b' prefix
50    let encoded = base32_lower(&cid_bytes);
51    format!("b{encoded}")
52}
53
54/// RFC 4648 base32 (lowercase, no padding) encoder.
55fn base32_lower(input: &[u8]) -> String {
56    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
57    let mut output = Vec::with_capacity((input.len() * 8).div_ceil(5));
58    let mut buffer: u64 = 0;
59    let mut bits_left: u32 = 0;
60
61    for &byte in input {
62        buffer = (buffer << 8) | u64::from(byte);
63        bits_left += 8;
64        while bits_left >= 5 {
65            bits_left -= 5;
66            let idx = ((buffer >> bits_left) & 0x1f) as usize;
67            output.push(ALPHABET[idx]);
68        }
69    }
70    if bits_left > 0 {
71        let idx = ((buffer << (5 - bits_left)) & 0x1f) as usize;
72        output.push(ALPHABET[idx]);
73    }
74
75    // SAFETY: ALPHABET is all ASCII, so the Vec<u8> is valid UTF-8.
76    String::from_utf8(output).unwrap_or_default()
77}
78
79// ---------------------------------------------------------------------------
80// In-memory block store (backing type for IpfrsClient)
81// ---------------------------------------------------------------------------
82
83struct InMemoryStore {
84    blocks: std::collections::HashMap<String, Vec<u8>>,
85    total_bytes: usize,
86}
87
88impl InMemoryStore {
89    fn new() -> Self {
90        Self {
91            blocks: std::collections::HashMap::new(),
92            total_bytes: 0,
93        }
94    }
95
96    /// Store `data` and return its CID.  Content-addressed idempotency:
97    /// storing the same bytes twice does not create a duplicate entry.
98    fn put(&mut self, data: &[u8]) -> String {
99        let cid = cid_from_bytes(data);
100        if !self.blocks.contains_key(&cid) {
101            self.total_bytes += data.len();
102            self.blocks.insert(cid.clone(), data.to_vec());
103        }
104        cid
105    }
106
107    fn get(&self, cid: &str) -> Option<&Vec<u8>> {
108        self.blocks.get(cid)
109    }
110
111    fn has(&self, cid: &str) -> bool {
112        self.blocks.contains_key(cid)
113    }
114
115    /// Remove a block.  Returns `true` if the block existed.
116    fn delete(&mut self, cid: &str) -> bool {
117        if let Some(data) = self.blocks.remove(cid) {
118            self.total_bytes = self.total_bytes.saturating_sub(data.len());
119            true
120        } else {
121            false
122        }
123    }
124
125    fn list(&self) -> Vec<String> {
126        self.blocks.keys().cloned().collect()
127    }
128
129    fn block_count(&self) -> usize {
130        self.blocks.len()
131    }
132
133    fn total_bytes(&self) -> usize {
134        self.total_bytes
135    }
136}
137
138// ---------------------------------------------------------------------------
139// IpfrsClient – simple in-process content-addressed store
140// ---------------------------------------------------------------------------
141
142/// IPFRS in-process content-addressed client.
143///
144/// This is the simplest way to use IPFRS from Node.js: all blocks are kept in
145/// memory during the lifetime of the object.  For persistent storage backed by
146/// a full IPFRS node, use `Node` instead.
147///
148/// ```javascript
149/// const { IpfrsClient, compute_cid, verify_cid, version } = require('@cool-japan/ipfrs-node');
150///
151/// const client = new IpfrsClient('/tmp/ipfrs-data');
152/// const cid = client.addBytes(Buffer.from('hello, IPFRS!'));
153/// const data = client.getBytes(cid);
154/// ```
155#[cfg(feature = "napi")]
156#[napi_derive::napi]
157pub struct IpfrsClient {
158    data_dir: String,
159    store: std::sync::Mutex<InMemoryStore>,
160}
161
162/// Non-NAPI version used in unit tests and when building without the napi
163/// feature (e.g. `cargo test --lib`).
164#[cfg(not(feature = "napi"))]
165pub struct IpfrsClient {
166    data_dir: String,
167    store: std::sync::Mutex<InMemoryStore>,
168}
169
170impl IpfrsClient {
171    /// Create a new `IpfrsClient` backed by an in-memory store.
172    ///
173    /// `data_dir` is recorded for informational purposes (e.g. `stats()`);
174    /// it is **not** used for actual I/O in the in-memory implementation.
175    pub fn create(data_dir: String) -> Result<Self, ClientError> {
176        Ok(Self {
177            data_dir,
178            store: std::sync::Mutex::new(InMemoryStore::new()),
179        })
180    }
181
182    /// Add raw bytes, returning the CIDv1 string.
183    pub fn add_bytes_inner(&self, data: &[u8]) -> Result<String, ClientError> {
184        let mut store = self
185            .store
186            .lock()
187            .map_err(|_| ClientError::lock("add_bytes"))?;
188        Ok(store.put(data))
189    }
190
191    /// Retrieve bytes by CID.  Returns `None` when the CID is not present.
192    pub fn get_bytes_inner(&self, cid: &str) -> Result<Option<Vec<u8>>, ClientError> {
193        let store = self
194            .store
195            .lock()
196            .map_err(|_| ClientError::lock("get_bytes"))?;
197        Ok(store.get(cid).cloned())
198    }
199
200    /// Return `true` if `cid` is in the store.
201    pub fn has_inner(&self, cid: &str) -> Result<bool, ClientError> {
202        let store = self.store.lock().map_err(|_| ClientError::lock("has"))?;
203        Ok(store.has(cid))
204    }
205
206    /// Return all stored CIDs.
207    pub fn list_cids_inner(&self) -> Result<Vec<String>, ClientError> {
208        let store = self
209            .store
210            .lock()
211            .map_err(|_| ClientError::lock("list_cids"))?;
212        Ok(store.list())
213    }
214
215    /// Delete a block.  Returns `true` if the block existed and was removed.
216    pub fn delete_inner(&self, cid: &str) -> Result<bool, ClientError> {
217        let mut store = self.store.lock().map_err(|_| ClientError::lock("delete"))?;
218        Ok(store.delete(cid))
219    }
220
221    /// Return the library version string.
222    pub fn version_inner(&self) -> String {
223        env!("CARGO_PKG_VERSION").to_string()
224    }
225
226    /// Return a JSON string with basic storage statistics.
227    pub fn stats_inner(&self) -> Result<String, ClientError> {
228        let store = self.store.lock().map_err(|_| ClientError::lock("stats"))?;
229        let json = serde_json::json!({
230            "data_dir": self.data_dir,
231            "block_count": store.block_count(),
232            "total_bytes": store.total_bytes(),
233            "version": env!("CARGO_PKG_VERSION"),
234        });
235        serde_json::to_string(&json).map_err(|e| ClientError::serialise(e.to_string()))
236    }
237}
238
239// ---------------------------------------------------------------------------
240// NAPI bindings – only compiled when the `napi` feature is active
241// ---------------------------------------------------------------------------
242
243#[cfg(feature = "napi")]
244#[napi_derive::napi]
245impl IpfrsClient {
246    /// Create a new `IpfrsClient`.
247    ///
248    /// `data_dir` is the intended storage directory.  In the current
249    /// in-memory implementation it is stored for informational purposes only.
250    #[napi(constructor)]
251    pub fn new(data_dir: String) -> napi::Result<Self> {
252        IpfrsClient::create(data_dir).map_err(into_napi)
253    }
254
255    /// Add raw bytes, returning the CIDv1 string.
256    #[napi]
257    pub fn add_bytes(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<String> {
258        self.add_bytes_inner(data.as_ref()).map_err(into_napi)
259    }
260
261    /// Retrieve bytes by CID.  Returns `null` when the CID is not present.
262    #[napi]
263    pub fn get_bytes(&self, cid: String) -> napi::Result<Option<napi::bindgen_prelude::Buffer>> {
264        self.get_bytes_inner(&cid)
265            .map(|opt| opt.map(|v| v.into()))
266            .map_err(into_napi)
267    }
268
269    /// Return `true` if `cid` is present in the store.
270    #[napi]
271    pub fn has(&self, cid: String) -> napi::Result<bool> {
272        self.has_inner(&cid).map_err(into_napi)
273    }
274
275    /// Return all stored CIDs.
276    #[napi]
277    pub fn list_cids(&self) -> napi::Result<Vec<String>> {
278        self.list_cids_inner().map_err(into_napi)
279    }
280
281    /// Delete a block.  Returns `true` if the block existed and was removed.
282    #[napi]
283    pub fn delete(&self, cid: String) -> napi::Result<bool> {
284        self.delete_inner(&cid).map_err(into_napi)
285    }
286
287    /// Return the library version string.
288    #[napi]
289    pub fn version(&self) -> String {
290        self.version_inner()
291    }
292
293    /// Return a JSON string with basic storage statistics.
294    #[napi]
295    pub fn stats(&self) -> napi::Result<String> {
296        self.stats_inner().map_err(into_napi)
297    }
298}
299
300// ---------------------------------------------------------------------------
301// Free functions
302// ---------------------------------------------------------------------------
303
304/// Compute the CIDv1 (base32-lower, SHA2-256, raw codec) for the given bytes.
305///
306/// The result is deterministic and matches the output of `compute_cid` in the
307/// WASM module.
308#[cfg(feature = "napi")]
309#[napi_derive::napi]
310pub fn compute_cid(data: napi::bindgen_prelude::Buffer) -> String {
311    cid_from_bytes(data.as_ref())
312}
313
314/// Non-NAPI version used in tests.
315#[cfg(not(feature = "napi"))]
316pub fn compute_cid(data: &[u8]) -> String {
317    cid_from_bytes(data)
318}
319
320/// Return `true` when `data` hashes to `cid`.
321#[cfg(feature = "napi")]
322#[napi_derive::napi]
323pub fn verify_cid(cid: String, data: napi::bindgen_prelude::Buffer) -> bool {
324    cid_from_bytes(data.as_ref()) == cid
325}
326
327/// Non-NAPI version used in tests.
328#[cfg(not(feature = "napi"))]
329pub fn verify_cid(cid: &str, data: &[u8]) -> bool {
330    cid_from_bytes(data) == cid
331}
332
333/// Return the library version string.
334#[cfg(feature = "napi")]
335#[napi_derive::napi]
336pub fn version() -> String {
337    env!("CARGO_PKG_VERSION").to_string()
338}
339
340/// Non-NAPI version used in tests.
341#[cfg(not(feature = "napi"))]
342pub fn version() -> String {
343    env!("CARGO_PKG_VERSION").to_string()
344}
345
346// ---------------------------------------------------------------------------
347// Full Node – wraps the real IPFRS node with all features
348// ---------------------------------------------------------------------------
349
350/// IPFRS Node – full node with block storage, semantic search, and TensorLogic.
351///
352/// This is the production-grade binding that delegates to the Rust `ipfrs::Node`
353/// type.  All I/O operations use an embedded tokio runtime so they can be called
354/// from synchronous JavaScript contexts.
355#[cfg(feature = "napi")]
356#[napi_derive::napi]
357pub struct Node {
358    inner: std::sync::Arc<tokio::sync::Mutex<ipfrs::Node>>,
359    runtime: std::sync::Arc<tokio::runtime::Runtime>,
360}
361
362#[cfg(feature = "napi")]
363#[napi_derive::napi]
364impl Node {
365    /// Create a new IPFRS node with the given configuration.
366    #[napi(constructor)]
367    pub fn new(config: Option<NodeConfig>) -> napi::Result<Self> {
368        let rust_config = config.map(|c| c.into_rust_config()).unwrap_or_default();
369
370        let runtime = tokio::runtime::Runtime::new()
371            .map_err(|e| napi::Error::from_reason(format!("Failed to create runtime: {e}")))?;
372
373        let inner = ipfrs::Node::new(rust_config)
374            .map_err(|e| napi::Error::from_reason(format!("Failed to create node: {e}")))?;
375
376        Ok(Self {
377            inner: std::sync::Arc::new(tokio::sync::Mutex::new(inner)),
378            runtime: std::sync::Arc::new(runtime),
379        })
380    }
381
382    /// Start the node.
383    #[napi]
384    pub async fn start(&self) -> napi::Result<()> {
385        let inner = self.inner.clone();
386        self.runtime
387            .block_on(async move {
388                let mut node = inner.lock().await;
389                node.start().await
390            })
391            .map_err(|e| napi::Error::from_reason(format!("Failed to start: {e}")))
392    }
393
394    /// Stop the node.
395    #[napi]
396    pub async fn stop(&self) -> napi::Result<()> {
397        let inner = self.inner.clone();
398        self.runtime
399            .block_on(async move {
400                let mut node = inner.lock().await;
401                node.stop().await
402            })
403            .map_err(|e| napi::Error::from_reason(format!("Failed to stop: {e}")))
404    }
405
406    /// Store raw bytes and return the CIDv1 string.
407    #[napi]
408    pub async fn put_block(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<String> {
409        let data_bytes: bytes::Bytes = bytes::Bytes::from(data.to_vec());
410        let block = ipfrs::Block::new(data_bytes)
411            .map_err(|e| napi::Error::from_reason(format!("Failed to create block: {e}")))?;
412        let cid = *block.cid();
413
414        let inner = self.inner.clone();
415        self.runtime
416            .block_on(async move {
417                let node = inner.lock().await;
418                node.put_block(&block).await
419            })
420            .map_err(|e| napi::Error::from_reason(format!("Failed to put block: {e}")))?;
421
422        Ok(cid.to_string())
423    }
424
425    /// Retrieve a block by CID.  Returns `null` when the CID is not found.
426    #[napi]
427    pub async fn get_block(
428        &self,
429        cid: String,
430    ) -> napi::Result<Option<napi::bindgen_prelude::Buffer>> {
431        let rust_cid: ipfrs::Cid = cid
432            .parse()
433            .map_err(|_| napi::Error::from_reason("Invalid CID"))?;
434
435        let inner = self.inner.clone();
436        let result = self.runtime.block_on(async move {
437            let node = inner.lock().await;
438            node.get_block(&rust_cid).await
439        });
440
441        match result {
442            Ok(Some(block)) => Ok(Some(block.data().to_vec().into())),
443            Ok(None) => Ok(None),
444            Err(e) => Err(napi::Error::from_reason(format!(
445                "Failed to get block: {e}"
446            ))),
447        }
448    }
449
450    /// Return `true` when the given CID exists in the store.
451    #[napi]
452    pub async fn has_block(&self, cid: String) -> napi::Result<bool> {
453        let rust_cid: ipfrs::Cid = cid
454            .parse()
455            .map_err(|_| napi::Error::from_reason("Invalid CID"))?;
456
457        let inner = self.inner.clone();
458        self.runtime
459            .block_on(async move {
460                let node = inner.lock().await;
461                node.has_block(&rust_cid).await
462            })
463            .map_err(|e| napi::Error::from_reason(format!("Failed to check block: {e}")))
464    }
465
466    /// Delete a block from storage.
467    #[napi]
468    pub async fn delete_block(&self, cid: String) -> napi::Result<()> {
469        let rust_cid: ipfrs::Cid = cid
470            .parse()
471            .map_err(|_| napi::Error::from_reason("Invalid CID"))?;
472
473        let inner = self.inner.clone();
474        self.runtime
475            .block_on(async move {
476                let node = inner.lock().await;
477                node.delete_block(&rust_cid).await
478            })
479            .map_err(|e| napi::Error::from_reason(format!("Failed to delete block: {e}")))
480    }
481
482    /// Index content for semantic (vector) search.
483    #[napi]
484    pub async fn index_content(&self, cid: String, embedding: Vec<f64>) -> napi::Result<()> {
485        let rust_cid: ipfrs::Cid = cid
486            .parse()
487            .map_err(|_| napi::Error::from_reason("Invalid CID"))?;
488        let embedding_f32: Vec<f32> = embedding.iter().map(|&v| v as f32).collect();
489
490        let inner = self.inner.clone();
491        self.runtime
492            .block_on(async move {
493                let node = inner.lock().await;
494                node.index_content(&rust_cid, &embedding_f32).await
495            })
496            .map_err(|e| napi::Error::from_reason(format!("Failed to index: {e}")))
497    }
498
499    /// Search for similar content by vector embedding.
500    #[napi]
501    pub async fn search_similar(
502        &self,
503        query: Vec<f64>,
504        k: u32,
505    ) -> napi::Result<Vec<NodeSearchResult>> {
506        let query_f32: Vec<f32> = query.iter().map(|&v| v as f32).collect();
507        let k_usize = k as usize;
508
509        let inner = self.inner.clone();
510        let results = self
511            .runtime
512            .block_on(async move {
513                let node = inner.lock().await;
514                node.search_similar(&query_f32, k_usize).await
515            })
516            .map_err(|e| napi::Error::from_reason(format!("Search failed: {e}")))?;
517
518        Ok(results
519            .into_iter()
520            .map(|r| NodeSearchResult {
521                cid: r.cid.to_string(),
522                score: f64::from(r.score),
523            })
524            .collect())
525    }
526
527    /// Add a fact to the knowledge base.
528    #[napi]
529    pub fn add_fact(&self, fact: NodePredicate) -> napi::Result<()> {
530        let rust_fact = fact.into_rust_predicate()?;
531        let node = self.inner.blocking_lock();
532        node.add_fact(rust_fact)
533            .map_err(|e| napi::Error::from_reason(format!("Failed to add fact: {e}")))
534    }
535
536    /// Add a rule to the knowledge base.
537    #[napi]
538    pub fn add_rule(&self, rule: NodeRule) -> napi::Result<()> {
539        let rust_rule = rule.into_rust_rule()?;
540        let node = self.inner.blocking_lock();
541        node.add_rule(rust_rule)
542            .map_err(|e| napi::Error::from_reason(format!("Failed to add rule: {e}")))
543    }
544
545    /// Run an inference query against the knowledge base.
546    ///
547    /// Returns a list of substitution strings (serialised as JSON objects).
548    #[napi]
549    pub fn infer(&self, goal: NodePredicate) -> napi::Result<Vec<String>> {
550        let rust_goal = goal.into_rust_predicate()?;
551        let node = self.inner.blocking_lock();
552        let results = node
553            .infer(&rust_goal)
554            .map_err(|e| napi::Error::from_reason(format!("Inference failed: {e}")))?;
555
556        Ok(results.into_iter().map(|s| format!("{s:?}")).collect())
557    }
558
559    /// Generate a proof for a goal.  Returns `null` when no proof is found.
560    #[napi]
561    pub fn prove(&self, goal: NodePredicate) -> napi::Result<Option<String>> {
562        let rust_goal = goal.into_rust_predicate()?;
563        let node = self.inner.blocking_lock();
564        let proof = node
565            .prove(&rust_goal)
566            .map_err(|e| napi::Error::from_reason(format!("Proof generation failed: {e}")))?;
567
568        Ok(proof.map(|p| format!("{p:?}")))
569    }
570
571    /// Return knowledge-base statistics.
572    #[napi]
573    pub fn kb_stats(&self) -> napi::Result<KbStats> {
574        let node = self.inner.blocking_lock();
575        let stats = node
576            .tensorlogic_stats()
577            .map_err(|e| napi::Error::from_reason(format!("Failed to get stats: {e}")))?;
578
579        Ok(KbStats {
580            num_facts: stats.num_facts as u32,
581            num_rules: stats.num_rules as u32,
582        })
583    }
584
585    /// Save the semantic index to disk.
586    #[napi]
587    pub async fn save_semantic_index(&self, path: String) -> napi::Result<()> {
588        let inner = self.inner.clone();
589        self.runtime
590            .block_on(async move {
591                let node = inner.lock().await;
592                node.save_semantic_index(std::path::PathBuf::from(path))
593                    .await
594            })
595            .map_err(|e| napi::Error::from_reason(format!("Failed to save index: {e}")))
596    }
597
598    /// Load the semantic index from disk.
599    #[napi]
600    pub async fn load_semantic_index(&self, path: String) -> napi::Result<()> {
601        let inner = self.inner.clone();
602        self.runtime
603            .block_on(async move {
604                let node = inner.lock().await;
605                node.load_semantic_index(std::path::PathBuf::from(path))
606                    .await
607            })
608            .map_err(|e| napi::Error::from_reason(format!("Failed to load index: {e}")))
609    }
610
611    /// Save the knowledge base to disk.
612    #[napi]
613    pub async fn save_kb(&self, path: String) -> napi::Result<()> {
614        let inner = self.inner.clone();
615        self.runtime
616            .block_on(async move {
617                let node = inner.lock().await;
618                node.save_knowledge_base(std::path::PathBuf::from(path))
619                    .await
620            })
621            .map_err(|e| napi::Error::from_reason(format!("Failed to save KB: {e}")))
622    }
623
624    /// Load the knowledge base from disk.
625    #[napi]
626    pub async fn load_kb(&self, path: String) -> napi::Result<()> {
627        let inner = self.inner.clone();
628        self.runtime
629            .block_on(async move {
630                let node = inner.lock().await;
631                node.load_knowledge_base(std::path::PathBuf::from(path))
632                    .await
633            })
634            .map_err(|e| napi::Error::from_reason(format!("Failed to load KB: {e}")))
635    }
636}
637
638// ---------------------------------------------------------------------------
639// Supporting NAPI object types
640// ---------------------------------------------------------------------------
641
642/// Node configuration exposed to JavaScript.
643#[cfg(feature = "napi")]
644#[napi_derive::napi(object)]
645#[derive(Clone)]
646pub struct NodeConfig {
647    /// Path to the storage directory.
648    pub storage_path: Option<String>,
649    /// Enable semantic (vector) search.
650    pub enable_semantic: Option<bool>,
651    /// Enable TensorLogic reasoning.
652    pub enable_tensorlogic: Option<bool>,
653}
654
655#[cfg(feature = "napi")]
656impl NodeConfig {
657    fn into_rust_config(self) -> ipfrs::NodeConfig {
658        let mut config = ipfrs::NodeConfig::default();
659        if let Some(ref path) = self.storage_path {
660            config.storage.path = std::path::PathBuf::from(path);
661        }
662        if let Some(v) = self.enable_semantic {
663            config.enable_semantic = v;
664        }
665        if let Some(v) = self.enable_tensorlogic {
666            config.enable_tensorlogic = v;
667        }
668        config
669    }
670}
671
672/// Search result returned by `Node::search_similar`.
673#[cfg(feature = "napi")]
674#[napi_derive::napi(object)]
675pub struct NodeSearchResult {
676    pub cid: String,
677    pub score: f64,
678}
679
680/// Knowledge-base statistics.
681#[cfg(feature = "napi")]
682#[napi_derive::napi(object)]
683pub struct KbStats {
684    pub num_facts: u32,
685    pub num_rules: u32,
686}
687
688/// A logical term passed from JavaScript.
689#[cfg(feature = "napi")]
690#[napi_derive::napi(object)]
691#[derive(Clone)]
692pub struct NodeTerm {
693    /// One of `"int"`, `"float"`, `"string"`, `"bool"`, or `"var"`.
694    pub kind: String,
695    pub value: String,
696}
697
698#[cfg(feature = "napi")]
699impl NodeTerm {
700    fn into_rust_term(self) -> napi::Result<ipfrs::Term> {
701        use ipfrs::{Constant, Term};
702        match self.kind.as_str() {
703            "int" => {
704                let val: i64 = self
705                    .value
706                    .parse()
707                    .map_err(|_| napi::Error::from_reason("Invalid integer"))?;
708                Ok(Term::Const(Constant::Int(val)))
709            }
710            "float" => Ok(Term::Const(Constant::Float(self.value))),
711            "string" => Ok(Term::Const(Constant::String(self.value))),
712            "bool" => {
713                let val: bool = self
714                    .value
715                    .parse()
716                    .map_err(|_| napi::Error::from_reason("Invalid boolean"))?;
717                Ok(Term::Const(Constant::Bool(val)))
718            }
719            "var" => Ok(Term::Var(self.value)),
720            other => Err(napi::Error::from_reason(format!(
721                "Unknown term kind: {other}"
722            ))),
723        }
724    }
725}
726
727/// A logical predicate passed from JavaScript.
728#[cfg(feature = "napi")]
729#[napi_derive::napi(object)]
730#[derive(Clone)]
731pub struct NodePredicate {
732    pub name: String,
733    pub args: Vec<NodeTerm>,
734}
735
736#[cfg(feature = "napi")]
737impl NodePredicate {
738    fn into_rust_predicate(self) -> napi::Result<ipfrs::Predicate> {
739        let rust_args: napi::Result<Vec<ipfrs::Term>> =
740            self.args.into_iter().map(|t| t.into_rust_term()).collect();
741        Ok(ipfrs::Predicate::new(self.name, rust_args?))
742    }
743}
744
745/// A logical rule passed from JavaScript.
746#[cfg(feature = "napi")]
747#[napi_derive::napi(object)]
748#[derive(Clone)]
749pub struct NodeRule {
750    pub head: NodePredicate,
751    pub body: Vec<NodePredicate>,
752}
753
754#[cfg(feature = "napi")]
755impl NodeRule {
756    fn into_rust_rule(self) -> napi::Result<ipfrs::Rule> {
757        let rust_head = self.head.into_rust_predicate()?;
758        let rust_body: napi::Result<Vec<ipfrs::Predicate>> = self
759            .body
760            .into_iter()
761            .map(|p| p.into_rust_predicate())
762            .collect();
763        let body = rust_body?;
764        if body.is_empty() {
765            Ok(ipfrs::Rule::fact(rust_head))
766        } else {
767            Ok(ipfrs::Rule::new(rust_head, body))
768        }
769    }
770}
771
772// ---------------------------------------------------------------------------
773// Error helpers
774// ---------------------------------------------------------------------------
775
776/// Internal error type for `IpfrsClient` operations.
777#[derive(Debug)]
778pub struct ClientError {
779    message: String,
780}
781
782impl ClientError {
783    fn lock(op: &str) -> Self {
784        Self {
785            message: format!("Mutex poisoned during '{op}'"),
786        }
787    }
788
789    fn serialise(msg: String) -> Self {
790        Self { message: msg }
791    }
792}
793
794impl std::fmt::Display for ClientError {
795    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
796        write!(f, "IpfrsClient error: {}", self.message)
797    }
798}
799
800impl std::error::Error for ClientError {}
801
802#[cfg(feature = "napi")]
803fn into_napi(e: ClientError) -> napi::Error {
804    napi::Error::from_reason(e.to_string())
805}
806
807// ---------------------------------------------------------------------------
808// Tests
809// ---------------------------------------------------------------------------
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    // ------------------------------------------------------------------
816    // IpfrsClient – add / get round-trip
817    // ------------------------------------------------------------------
818
819    #[test]
820    fn test_client_add_get_roundtrip() {
821        let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
822
823        let payload = b"hello, IPFRS!";
824        let cid = client.add_bytes_inner(payload).expect("add_bytes");
825        let retrieved = client
826            .get_bytes_inner(&cid)
827            .expect("get_bytes")
828            .expect("should be Some");
829
830        assert_eq!(payload.as_slice(), retrieved.as_slice());
831    }
832
833    // ------------------------------------------------------------------
834    // CID is deterministic for the same input
835    // ------------------------------------------------------------------
836
837    #[test]
838    fn test_cid_deterministic() {
839        let data = b"determinism matters";
840        let cid1 = cid_from_bytes(data);
841        let cid2 = cid_from_bytes(data);
842        assert_eq!(cid1, cid2);
843    }
844
845    // ------------------------------------------------------------------
846    // Different data → different CIDs
847    // ------------------------------------------------------------------
848
849    #[test]
850    fn test_cid_unique_per_content() {
851        let cid_a = cid_from_bytes(b"alpha");
852        let cid_b = cid_from_bytes(b"beta");
853        assert_ne!(cid_a, cid_b);
854    }
855
856    // ------------------------------------------------------------------
857    // has() returns false for an unknown CID
858    // ------------------------------------------------------------------
859
860    #[test]
861    fn test_has_returns_false_for_unknown() {
862        let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
863
864        let present = client
865            .has_inner("bafyreifake000000000000000000000000000000000000000")
866            .expect("has");
867        assert!(!present);
868    }
869
870    // ------------------------------------------------------------------
871    // has() returns true after add
872    // ------------------------------------------------------------------
873
874    #[test]
875    fn test_has_returns_true_after_add() {
876        let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
877
878        let cid = client.add_bytes_inner(b"exists").expect("add_bytes");
879        assert!(client.has_inner(&cid).expect("has"));
880    }
881
882    // ------------------------------------------------------------------
883    // delete()
884    // ------------------------------------------------------------------
885
886    #[test]
887    fn test_delete_block() {
888        let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
889
890        let cid = client.add_bytes_inner(b"to delete").expect("add_bytes");
891        assert!(client.has_inner(&cid).expect("has before delete"));
892
893        let removed = client.delete_inner(&cid).expect("delete");
894        assert!(removed);
895        assert!(!client.has_inner(&cid).expect("has after delete"));
896    }
897
898    // ------------------------------------------------------------------
899    // delete() returns false for unknown CID
900    // ------------------------------------------------------------------
901
902    #[test]
903    fn test_delete_unknown_returns_false() {
904        let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
905
906        let removed = client
907            .delete_inner("bafyreifake000000000000000000000000000000000000000")
908            .expect("delete");
909        assert!(!removed);
910    }
911
912    // ------------------------------------------------------------------
913    // list_cids()
914    // ------------------------------------------------------------------
915
916    #[test]
917    fn test_list_cids() {
918        let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
919
920        assert!(
921            client.list_cids_inner().expect("list empty").is_empty(),
922            "starts empty"
923        );
924
925        let cid1 = client.add_bytes_inner(b"first").expect("add 1");
926        let cid2 = client.add_bytes_inner(b"second").expect("add 2");
927
928        let mut cids = client.list_cids_inner().expect("list two");
929        cids.sort();
930        let mut expected = vec![cid1, cid2];
931        expected.sort();
932        assert_eq!(cids, expected);
933    }
934
935    // ------------------------------------------------------------------
936    // stats() returns valid JSON
937    // ------------------------------------------------------------------
938
939    #[test]
940    fn test_stats_json_valid() {
941        let client =
942            IpfrsClient::create("/tmp/ipfrs-stats-test".to_string()).expect("create client");
943
944        client.add_bytes_inner(b"some data").expect("add");
945        let json_str = client.stats_inner().expect("stats");
946        let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
947
948        assert_eq!(parsed["block_count"], 1);
949        assert_eq!(parsed["data_dir"], "/tmp/ipfrs-stats-test");
950        assert!(parsed["total_bytes"].as_u64().unwrap_or(0) > 0);
951    }
952
953    // ------------------------------------------------------------------
954    // verify_cid (free function)
955    // ------------------------------------------------------------------
956
957    #[test]
958    fn test_verify_cid() {
959        let data = b"verifiable content";
960        let cid = cid_from_bytes(data);
961
962        assert!(cid_from_bytes(data) == cid, "correct CID should verify");
963        assert!(cid_from_bytes(data) != "bwrongcid", "wrong CID should fail");
964    }
965
966    // ------------------------------------------------------------------
967    // compute_cid (free function) – starts with 'b'
968    // ------------------------------------------------------------------
969
970    #[test]
971    fn test_compute_cid_free_function() {
972        let cid = cid_from_bytes(b"ipfrs nodejs");
973        assert!(
974            cid.starts_with('b'),
975            "CIDv1 multibase base32lower must start with 'b', got: {cid}"
976        );
977        // Length sanity: 1 (prefix) + ceil(36 * 8 / 5) = 1 + 58 = 59
978        assert_eq!(cid.len(), 59, "unexpected CID length: {}", cid.len());
979    }
980
981    // ------------------------------------------------------------------
982    // version string
983    // ------------------------------------------------------------------
984
985    #[test]
986    fn test_version_string() {
987        let v = version();
988        assert!(!v.is_empty(), "version must not be empty");
989        // Expect semver format X.Y.Z
990        let parts: Vec<&str> = v.split('.').collect();
991        assert_eq!(parts.len(), 3, "version should be semver: {v}");
992    }
993
994    // ------------------------------------------------------------------
995    // Idempotent add – same bytes same CID, no duplicate
996    // ------------------------------------------------------------------
997
998    #[test]
999    fn test_idempotent_add() {
1000        let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
1001
1002        let cid1 = client.add_bytes_inner(b"idempotent").expect("add 1");
1003        let cid2 = client.add_bytes_inner(b"idempotent").expect("add 2");
1004        assert_eq!(cid1, cid2, "same content must yield same CID");
1005
1006        let cids = client.list_cids_inner().expect("list");
1007        assert_eq!(cids.len(), 1, "should only store one block");
1008    }
1009
1010    // ------------------------------------------------------------------
1011    // get_bytes returns None for unknown CID
1012    // ------------------------------------------------------------------
1013
1014    #[test]
1015    fn test_get_bytes_unknown_returns_none() {
1016        let client = IpfrsClient::create("/tmp/ipfrs-test".to_string()).expect("create client");
1017
1018        let result = client
1019            .get_bytes_inner("bafyreifake000000000000000000000000000000000000000")
1020            .expect("get_bytes");
1021        assert!(result.is_none());
1022    }
1023
1024    // ------------------------------------------------------------------
1025    // CID format: base32lower prefix 'b', all-lowercase body
1026    // ------------------------------------------------------------------
1027
1028    #[test]
1029    fn test_cid_format_base32lower() {
1030        let cid = cid_from_bytes(b"format check");
1031        assert!(cid.starts_with('b'));
1032        // All chars after 'b' must be lowercase base32 alphabet
1033        for ch in cid.chars().skip(1) {
1034            assert!(
1035                ch.is_ascii_lowercase() || ('2'..='7').contains(&ch),
1036                "unexpected char '{ch}' in CID '{cid}'"
1037            );
1038        }
1039    }
1040
1041    // ------------------------------------------------------------------
1042    // Known-value CID stability (regression test)
1043    // ------------------------------------------------------------------
1044
1045    #[test]
1046    fn test_cid_known_value_stability() {
1047        // Pre-computed: echo -n "ipfrs stable" | ipfs add --cid-version 1 --raw-leaves
1048        // We just verify the value doesn't change across builds.
1049        let cid = cid_from_bytes(b"ipfrs stable");
1050        // Must start with 'b' and be 59 chars
1051        assert!(cid.starts_with('b'));
1052        assert_eq!(cid.len(), 59);
1053        // Store for regression detection
1054        let cid2 = cid_from_bytes(b"ipfrs stable");
1055        assert_eq!(cid, cid2);
1056    }
1057}