Skip to main content

tensorlogic_oxirs_bridge/
blank_node.rs

1//! Blank-node management: mint stable IRIs for anonymous RDF nodes.
2//!
3//! In RDF, blank nodes are local identifiers (prefixed with `_:`) that have
4//! no global identity. This module provides [`BlankNodeManager`] which
5//! deterministically replaces blank-node identifiers with minted IRIs so that
6//! downstream processing can treat every node uniformly.
7//!
8//! # Examples
9//!
10//! ```
11//! use tensorlogic_oxirs_bridge::BlankNodeManager;
12//!
13//! let mut mgr = BlankNodeManager::new("http://example.org/blank/");
14//! let iri = mgr.mint("_:b0");
15//! assert!(iri.starts_with("http://example.org/blank/"));
16//! assert_eq!(mgr.resolve("_:b0"), iri); // idempotent for same blank id
17//! assert_eq!(mgr.resolve("http://named/"), "http://named/"); // named → unchanged
18//! ```
19
20use std::collections::HashMap;
21
22/// Converts blank-node identifiers (`_:…`) into stable, unique IRIs.
23pub struct BlankNodeManager {
24    counter: u64,
25    base_iri: String,
26    /// Maps a blank-node id such as `"_:b0"` to the minted IRI.
27    pub mapping: HashMap<String, String>,
28}
29
30impl BlankNodeManager {
31    /// Create a new manager.  Every minted IRI will start with `base_iri`.
32    pub fn new(base_iri: impl Into<String>) -> Self {
33        Self {
34            counter: 0,
35            base_iri: base_iri.into(),
36            mapping: HashMap::new(),
37        }
38    }
39
40    /// Mint a fresh IRI for `blank_id` and remember the mapping.
41    ///
42    /// If the same `blank_id` has already been minted, the existing IRI is
43    /// returned without incrementing the counter.
44    pub fn mint(&mut self, blank_id: &str) -> String {
45        if let Some(existing) = self.mapping.get(blank_id) {
46            return existing.clone();
47        }
48        let iri = format!("{}_blank_{}", self.base_iri, self.counter);
49        self.counter += 1;
50        self.mapping.insert(blank_id.to_owned(), iri.clone());
51        iri
52    }
53
54    /// If `s` is a blank-node identifier return (or create) its minted IRI;
55    /// otherwise return `s` unchanged.
56    pub fn resolve(&mut self, s: &str) -> String {
57        if Self::is_blank(s) {
58            self.mint(s)
59        } else {
60            s.to_owned()
61        }
62    }
63
64    /// Return `true` when `s` starts with the conventional `_:` prefix.
65    pub fn is_blank(s: &str) -> bool {
66        s.starts_with("_:")
67    }
68
69    /// Total number of unique blank nodes minted so far.
70    pub fn mapping_count(&self) -> usize {
71        self.mapping.len()
72    }
73
74    /// Look up a previously minted IRI without mutating state.
75    pub fn get_minted(&self, blank_id: &str) -> Option<&str> {
76        self.mapping.get(blank_id).map(|s| s.as_str())
77    }
78}
79
80// ── tests ─────────────────────────────────────────────────────────────────────
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_is_blank_node_detection() {
88        assert!(BlankNodeManager::is_blank("_:b0"));
89        assert!(BlankNodeManager::is_blank("_:xyz"));
90        assert!(!BlankNodeManager::is_blank("http://example.org/"));
91        assert!(!BlankNodeManager::is_blank(""));
92        assert!(!BlankNodeManager::is_blank("notblank"));
93    }
94
95    #[test]
96    fn test_mint_returns_unique_iris() {
97        let mut mgr = BlankNodeManager::new("http://base/");
98        let a = mgr.mint("_:a");
99        let b = mgr.mint("_:b");
100        assert_ne!(a, b, "different blank ids must yield different IRIs");
101    }
102
103    #[test]
104    fn test_mint_increments_counter() {
105        let mut mgr = BlankNodeManager::new("http://base/");
106        mgr.mint("_:x");
107        mgr.mint("_:y");
108        // Counter must have advanced to 2 after two distinct mints
109        assert_eq!(mgr.mapping_count(), 2);
110    }
111
112    #[test]
113    fn test_resolve_blank_returns_minted() {
114        let mut mgr = BlankNodeManager::new("http://base/");
115        let first = mgr.resolve("_:node1");
116        let second = mgr.resolve("_:node1");
117        assert_eq!(first, second, "resolve must be idempotent");
118        assert!(first.starts_with("http://base/"));
119    }
120
121    #[test]
122    fn test_resolve_named_unchanged() {
123        let mut mgr = BlankNodeManager::new("http://base/");
124        let named = "http://example.org/Alice";
125        assert_eq!(mgr.resolve(named), named);
126        assert_eq!(mgr.mapping_count(), 0, "no blank node should be recorded");
127    }
128
129    #[test]
130    fn test_mapping_stored() {
131        let mut mgr = BlankNodeManager::new("http://base/");
132        let iri = mgr.mint("_:b42");
133        assert!(mgr.mapping.contains_key("_:b42"));
134        assert_eq!(mgr.mapping["_:b42"], iri);
135    }
136
137    #[test]
138    fn test_get_minted_returns_correct_iri() {
139        let mut mgr = BlankNodeManager::new("http://base/");
140        let iri = mgr.mint("_:foo");
141        assert_eq!(mgr.get_minted("_:foo"), Some(iri.as_str()));
142        assert_eq!(mgr.get_minted("_:unknown"), None);
143    }
144}