Skip to main content

oxirs_core/rdf/
blank_node_allocator.rs

1//! Blank node allocation and scoping for RDF graphs.
2//!
3//! Provides deterministic blank node ID generation, scope-based allocation,
4//! skolemization/deskolemization, and isomorphic blank node mapping.
5
6use std::collections::HashMap;
7use std::fmt;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::Arc;
10
11/// Errors that can occur during blank node operations.
12#[derive(Debug, Clone, PartialEq)]
13pub enum BlankNodeError {
14    /// The blank node ID is empty.
15    EmptyId,
16    /// The prefix contains invalid characters.
17    InvalidPrefix(String),
18    /// The scope does not exist.
19    UnknownScope(String),
20    /// Skolemization base URI is invalid.
21    InvalidBaseUri(String),
22    /// A mapping conflict was detected.
23    MappingConflict(String),
24    /// The allocator limit was exceeded.
25    LimitExceeded(usize),
26}
27
28impl fmt::Display for BlankNodeError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            BlankNodeError::EmptyId => write!(f, "Blank node ID cannot be empty"),
32            BlankNodeError::InvalidPrefix(p) => write!(f, "Invalid prefix: {p}"),
33            BlankNodeError::UnknownScope(s) => write!(f, "Unknown scope: {s}"),
34            BlankNodeError::InvalidBaseUri(u) => write!(f, "Invalid base URI: {u}"),
35            BlankNodeError::MappingConflict(msg) => write!(f, "Mapping conflict: {msg}"),
36            BlankNodeError::LimitExceeded(n) => write!(f, "Allocator limit exceeded: {n}"),
37        }
38    }
39}
40
41impl std::error::Error for BlankNodeError {}
42
43/// A generated blank node identifier with scope information.
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct BlankNodeId {
46    /// The full blank node identifier (e.g., `"b0"`, `"doc1_b5"`).
47    pub id: String,
48    /// The scope this blank node belongs to, if any.
49    pub scope: Option<String>,
50}
51
52impl BlankNodeId {
53    /// Create a new blank node ID.
54    pub fn new(id: impl Into<String>) -> Self {
55        Self {
56            id: id.into(),
57            scope: None,
58        }
59    }
60
61    /// Create a blank node ID within a specific scope.
62    pub fn with_scope(id: impl Into<String>, scope: impl Into<String>) -> Self {
63        Self {
64            id: id.into(),
65            scope: Some(scope.into()),
66        }
67    }
68
69    /// Return the `_:id` form suitable for N-Triples/Turtle serialization.
70    pub fn to_ntriples(&self) -> String {
71        format!("_:{}", self.id)
72    }
73}
74
75impl fmt::Display for BlankNodeId {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(f, "_:{}", self.id)
78    }
79}
80
81/// Configuration for the blank node allocator.
82#[derive(Debug, Clone)]
83pub struct AllocatorConfig {
84    /// Default prefix for generated IDs (e.g., `"b"`).
85    pub default_prefix: String,
86    /// Maximum number of blank nodes before an error is raised (0 = unlimited).
87    pub max_allocations: usize,
88    /// Base URI for skolemization (e.g., `"https://example.org/.well-known/genid/"`).
89    pub skolem_base: String,
90}
91
92impl Default for AllocatorConfig {
93    fn default() -> Self {
94        Self {
95            default_prefix: "b".to_string(),
96            max_allocations: 0,
97            skolem_base: "https://example.org/.well-known/genid/".to_string(),
98        }
99    }
100}
101
102/// Tracks allocation counts per scope.
103#[derive(Debug, Clone, Default)]
104pub struct AllocationStats {
105    /// Total blank nodes allocated across all scopes.
106    pub total_allocated: u64,
107    /// Blank nodes allocated per scope.
108    pub per_scope: HashMap<String, u64>,
109    /// Number of active scopes.
110    pub active_scopes: usize,
111    /// Number of skolemized nodes.
112    pub skolemized: u64,
113    /// Number of deskolemized nodes.
114    pub deskolemized: u64,
115}
116
117/// Thread-safe blank node allocator with scope support.
118///
119/// Uses an atomic counter for fast, lock-free ID generation.
120pub struct BlankNodeAllocator {
121    /// Atomic counter for deterministic, lock-free ID generation.
122    counter: Arc<AtomicU64>,
123    /// Per-scope counters (scope_name -> next_id).
124    scope_counters: HashMap<String, u64>,
125    /// Known allocated IDs per scope for tracking.
126    scope_ids: HashMap<String, Vec<String>>,
127    /// Allocator configuration.
128    config: AllocatorConfig,
129    /// Cumulative statistics.
130    stats: AllocationStats,
131}
132
133impl BlankNodeAllocator {
134    /// Create a new allocator with default settings.
135    pub fn new() -> Self {
136        Self {
137            counter: Arc::new(AtomicU64::new(0)),
138            scope_counters: HashMap::new(),
139            scope_ids: HashMap::new(),
140            config: AllocatorConfig::default(),
141            stats: AllocationStats::default(),
142        }
143    }
144
145    /// Create an allocator with explicit configuration.
146    pub fn with_config(config: AllocatorConfig) -> Result<Self, BlankNodeError> {
147        validate_prefix(&config.default_prefix)?;
148        if !config.skolem_base.is_empty() && !config.skolem_base.starts_with("http") {
149            return Err(BlankNodeError::InvalidBaseUri(config.skolem_base.clone()));
150        }
151        Ok(Self {
152            counter: Arc::new(AtomicU64::new(0)),
153            scope_counters: HashMap::new(),
154            scope_ids: HashMap::new(),
155            config,
156            stats: AllocationStats::default(),
157        })
158    }
159
160    // --- Deterministic ID generation ---
161
162    /// Allocate the next blank node using the global counter.
163    pub fn next(&self) -> Result<BlankNodeId, BlankNodeError> {
164        let max = self.config.max_allocations;
165        let n = self.counter.fetch_add(1, Ordering::Relaxed);
166        if max > 0 && (n as usize) >= max {
167            // Roll back the counter
168            self.counter.fetch_sub(1, Ordering::Relaxed);
169            return Err(BlankNodeError::LimitExceeded(max));
170        }
171        Ok(BlankNodeId::new(format!(
172            "{}{n}",
173            self.config.default_prefix
174        )))
175    }
176
177    /// Allocate a blank node with a custom prefix.
178    pub fn next_with_prefix(&self, prefix: &str) -> Result<BlankNodeId, BlankNodeError> {
179        validate_prefix(prefix)?;
180        let n = self.counter.fetch_add(1, Ordering::Relaxed);
181        let max = self.config.max_allocations;
182        if max > 0 && (n as usize) >= max {
183            self.counter.fetch_sub(1, Ordering::Relaxed);
184            return Err(BlankNodeError::LimitExceeded(max));
185        }
186        Ok(BlankNodeId::new(format!("{prefix}{n}")))
187    }
188
189    /// Return the current counter value (next ID that *will* be generated).
190    pub fn current_counter(&self) -> u64 {
191        self.counter.load(Ordering::Relaxed)
192    }
193
194    /// Reset the global counter to zero.
195    pub fn reset_counter(&self) {
196        self.counter.store(0, Ordering::Relaxed);
197    }
198
199    // --- Scope-based allocation ---
200
201    /// Create a new scope for blank node allocation.
202    pub fn create_scope(&mut self, name: impl Into<String>) -> Result<(), BlankNodeError> {
203        let name = name.into();
204        if name.is_empty() {
205            return Err(BlankNodeError::EmptyId);
206        }
207        self.scope_counters.entry(name.clone()).or_insert(0);
208        self.scope_ids.entry(name.clone()).or_default();
209        self.stats.active_scopes = self.scope_counters.len();
210        Ok(())
211    }
212
213    /// Allocate a blank node within a named scope.
214    pub fn next_in_scope(&mut self, scope: &str) -> Result<BlankNodeId, BlankNodeError> {
215        let scope_counter = self
216            .scope_counters
217            .get_mut(scope)
218            .ok_or_else(|| BlankNodeError::UnknownScope(scope.to_string()))?;
219        let n = *scope_counter;
220        *scope_counter += 1;
221        let id_str = format!("{}_{}{n}", scope, self.config.default_prefix);
222        let blank = BlankNodeId::with_scope(id_str.clone(), scope);
223        if let Some(ids) = self.scope_ids.get_mut(scope) {
224            ids.push(id_str);
225        }
226        self.stats.total_allocated += 1;
227        *self.stats.per_scope.entry(scope.to_string()).or_insert(0) += 1;
228        Ok(blank)
229    }
230
231    /// List all blank node IDs allocated within a scope.
232    pub fn ids_in_scope(&self, scope: &str) -> Result<&[String], BlankNodeError> {
233        self.scope_ids
234            .get(scope)
235            .map(|v| v.as_slice())
236            .ok_or_else(|| BlankNodeError::UnknownScope(scope.to_string()))
237    }
238
239    /// Remove a scope and all its tracking data.
240    pub fn remove_scope(&mut self, scope: &str) -> Result<(), BlankNodeError> {
241        if !self.scope_counters.contains_key(scope) {
242            return Err(BlankNodeError::UnknownScope(scope.to_string()));
243        }
244        self.scope_counters.remove(scope);
245        self.scope_ids.remove(scope);
246        self.stats.active_scopes = self.scope_counters.len();
247        Ok(())
248    }
249
250    /// Return the list of active scope names.
251    pub fn active_scopes(&self) -> Vec<String> {
252        self.scope_counters.keys().cloned().collect()
253    }
254
255    // --- Skolemization ---
256
257    /// Skolemize a blank node ID, replacing it with a well-known IRI.
258    ///
259    /// E.g., `_:b0` -> `<https://example.org/.well-known/genid/b0>`
260    pub fn skolemize(&mut self, blank_id: &str) -> Result<String, BlankNodeError> {
261        if blank_id.is_empty() {
262            return Err(BlankNodeError::EmptyId);
263        }
264        if self.config.skolem_base.is_empty() {
265            return Err(BlankNodeError::InvalidBaseUri(
266                "Skolem base URI is not configured".to_string(),
267            ));
268        }
269        self.stats.skolemized += 1;
270        Ok(format!("{}{blank_id}", self.config.skolem_base))
271    }
272
273    /// Skolemize a blank node ID with a custom base URI.
274    pub fn skolemize_with_base(
275        &mut self,
276        blank_id: &str,
277        base: &str,
278    ) -> Result<String, BlankNodeError> {
279        if blank_id.is_empty() {
280            return Err(BlankNodeError::EmptyId);
281        }
282        if base.is_empty() || !base.starts_with("http") {
283            return Err(BlankNodeError::InvalidBaseUri(base.to_string()));
284        }
285        self.stats.skolemized += 1;
286        let sep = if base.ends_with('/') { "" } else { "/" };
287        Ok(format!("{base}{sep}{blank_id}"))
288    }
289
290    /// Deskolemize a well-known IRI back to a blank node ID.
291    ///
292    /// E.g., `<https://example.org/.well-known/genid/b0>` -> `_:b0`
293    pub fn deskolemize(&mut self, iri: &str) -> Result<BlankNodeId, BlankNodeError> {
294        let stripped = iri
295            .strip_prefix('<')
296            .unwrap_or(iri)
297            .strip_suffix('>')
298            .unwrap_or(iri);
299        if let Some(local) = stripped.strip_prefix(self.config.skolem_base.as_str()) {
300            if local.is_empty() {
301                return Err(BlankNodeError::EmptyId);
302            }
303            self.stats.deskolemized += 1;
304            return Ok(BlankNodeId::new(local));
305        }
306        Err(BlankNodeError::InvalidBaseUri(format!(
307            "IRI does not match skolem base: {iri}"
308        )))
309    }
310
311    /// Deskolemize using a custom base.
312    pub fn deskolemize_with_base(
313        &mut self,
314        iri: &str,
315        base: &str,
316    ) -> Result<BlankNodeId, BlankNodeError> {
317        let stripped = iri
318            .strip_prefix('<')
319            .unwrap_or(iri)
320            .strip_suffix('>')
321            .unwrap_or(iri);
322        if let Some(local) = stripped.strip_prefix(base) {
323            let local = local.strip_prefix('/').unwrap_or(local);
324            if local.is_empty() {
325                return Err(BlankNodeError::EmptyId);
326            }
327            self.stats.deskolemized += 1;
328            return Ok(BlankNodeId::new(local));
329        }
330        Err(BlankNodeError::InvalidBaseUri(format!(
331            "IRI does not match base: {iri}"
332        )))
333    }
334
335    // --- Blank node mapping ---
336
337    /// Discover an isomorphic mapping between blank nodes of two graphs.
338    ///
339    /// `source` and `target` are lists of blank node IDs from each graph.
340    /// Returns a bijective mapping from source IDs to target IDs if sizes match,
341    /// or an error if they are incompatible.
342    pub fn discover_mapping(
343        source: &[&str],
344        target: &[&str],
345    ) -> Result<HashMap<String, String>, BlankNodeError> {
346        if source.len() != target.len() {
347            return Err(BlankNodeError::MappingConflict(format!(
348                "Source has {} blank nodes but target has {}",
349                source.len(),
350                target.len()
351            )));
352        }
353        let mut mapping = HashMap::new();
354        let mut used_targets: std::collections::HashSet<String> = std::collections::HashSet::new();
355        for (i, s) in source.iter().enumerate() {
356            let t = target
357                .get(i)
358                .ok_or_else(|| BlankNodeError::MappingConflict("Index out of bounds".into()))?;
359            if used_targets.contains(*t) {
360                return Err(BlankNodeError::MappingConflict(format!(
361                    "Target blank node '{t}' is already mapped"
362                )));
363            }
364            mapping.insert(s.to_string(), t.to_string());
365            used_targets.insert(t.to_string());
366        }
367        Ok(mapping)
368    }
369
370    /// Apply a mapping to a set of blank node IDs, returning the renamed IDs.
371    pub fn apply_mapping(ids: &[&str], mapping: &HashMap<String, String>) -> Vec<String> {
372        ids.iter()
373            .map(|id| mapping.get(*id).cloned().unwrap_or_else(|| id.to_string()))
374            .collect()
375    }
376
377    /// Verify that a mapping is bijective (no duplicate targets).
378    pub fn verify_mapping(mapping: &HashMap<String, String>) -> Result<(), BlankNodeError> {
379        let mut seen = std::collections::HashSet::new();
380        for (k, v) in mapping {
381            if !seen.insert(v.clone()) {
382                return Err(BlankNodeError::MappingConflict(format!(
383                    "Duplicate target '{v}' for source '{k}'"
384                )));
385            }
386        }
387        Ok(())
388    }
389
390    // --- Blank node renaming ---
391
392    /// Rename all blank nodes in a list using a new prefix.
393    ///
394    /// E.g., `["b0", "b1"]` with prefix `"merged_"` -> `["merged_0", "merged_1"]`.
395    pub fn rename_with_prefix(
396        ids: &[&str],
397        new_prefix: &str,
398    ) -> Result<Vec<BlankNodeId>, BlankNodeError> {
399        validate_prefix(new_prefix)?;
400        let mut result = Vec::with_capacity(ids.len());
401        for (i, _) in ids.iter().enumerate() {
402            result.push(BlankNodeId::new(format!("{new_prefix}{i}")));
403        }
404        Ok(result)
405    }
406
407    /// Rename blank nodes using a mapping from old to new prefix, preserving
408    /// numeric suffixes.
409    pub fn rename_prefix(
410        ids: &[&str],
411        old_prefix: &str,
412        new_prefix: &str,
413    ) -> Result<Vec<BlankNodeId>, BlankNodeError> {
414        validate_prefix(new_prefix)?;
415        let mut result = Vec::with_capacity(ids.len());
416        for id in ids {
417            if let Some(suffix) = id.strip_prefix(old_prefix) {
418                result.push(BlankNodeId::new(format!("{new_prefix}{suffix}")));
419            } else {
420                result.push(BlankNodeId::new(id.to_string()));
421            }
422        }
423        Ok(result)
424    }
425
426    /// Generate a merge-safe renaming: concatenate `scope` prefix to each ID.
427    pub fn scope_rename(ids: &[&str], scope: &str) -> Result<Vec<BlankNodeId>, BlankNodeError> {
428        if scope.is_empty() {
429            return Err(BlankNodeError::EmptyId);
430        }
431        let mut result = Vec::with_capacity(ids.len());
432        for id in ids {
433            result.push(BlankNodeId::with_scope(format!("{scope}_{id}"), scope));
434        }
435        Ok(result)
436    }
437
438    // --- Statistics ---
439
440    /// Return current allocation statistics.
441    pub fn stats(&self) -> &AllocationStats {
442        &self.stats
443    }
444
445    /// Return the configuration.
446    pub fn config(&self) -> &AllocatorConfig {
447        &self.config
448    }
449
450    /// Get an atomic clone of the counter for sharing across threads.
451    pub fn shared_counter(&self) -> Arc<AtomicU64> {
452        Arc::clone(&self.counter)
453    }
454}
455
456impl Default for BlankNodeAllocator {
457    fn default() -> Self {
458        Self::new()
459    }
460}
461
462/// Validate that a prefix contains only alphanumeric characters and underscores.
463fn validate_prefix(prefix: &str) -> Result<(), BlankNodeError> {
464    if prefix.is_empty() {
465        return Err(BlankNodeError::InvalidPrefix(
466            "prefix must not be empty".to_string(),
467        ));
468    }
469    if !prefix
470        .chars()
471        .all(|c| c.is_ascii_alphanumeric() || c == '_')
472    {
473        return Err(BlankNodeError::InvalidPrefix(prefix.to_string()));
474    }
475    Ok(())
476}
477
478// ---------------------------------------------------------------------------
479// Tests
480// ---------------------------------------------------------------------------
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    // -- Deterministic counter-based generation --
487
488    #[test]
489    fn test_next_generates_sequential_ids() {
490        let alloc = BlankNodeAllocator::new();
491        let a = alloc.next().expect("first");
492        let b = alloc.next().expect("second");
493        assert_eq!(a.id, "b0");
494        assert_eq!(b.id, "b1");
495    }
496
497    #[test]
498    fn test_next_with_prefix() {
499        let alloc = BlankNodeAllocator::new();
500        let id = alloc.next_with_prefix("node").expect("prefix");
501        assert!(id.id.starts_with("node"));
502    }
503
504    #[test]
505    fn test_counter_reset() {
506        let alloc = BlankNodeAllocator::new();
507        let _ = alloc.next();
508        let _ = alloc.next();
509        assert_eq!(alloc.current_counter(), 2);
510        alloc.reset_counter();
511        assert_eq!(alloc.current_counter(), 0);
512        let id = alloc.next().expect("after reset");
513        assert_eq!(id.id, "b0");
514    }
515
516    #[test]
517    fn test_to_ntriples() {
518        let id = BlankNodeId::new("x42");
519        assert_eq!(id.to_ntriples(), "_:x42");
520    }
521
522    #[test]
523    fn test_display_trait() {
524        let id = BlankNodeId::new("abc");
525        assert_eq!(format!("{id}"), "_:abc");
526    }
527
528    // -- Configuration --
529
530    #[test]
531    fn test_with_config_custom_prefix() {
532        let cfg = AllocatorConfig {
533            default_prefix: "node".to_string(),
534            ..Default::default()
535        };
536        let alloc = BlankNodeAllocator::with_config(cfg).expect("cfg");
537        let id = alloc.next().expect("next");
538        assert_eq!(id.id, "node0");
539    }
540
541    #[test]
542    fn test_max_allocations_limit() {
543        let cfg = AllocatorConfig {
544            max_allocations: 2,
545            ..Default::default()
546        };
547        let alloc = BlankNodeAllocator::with_config(cfg).expect("cfg");
548        assert!(alloc.next().is_ok());
549        assert!(alloc.next().is_ok());
550        let err = alloc.next().expect_err("should exceed limit");
551        assert!(matches!(err, BlankNodeError::LimitExceeded(2)));
552    }
553
554    #[test]
555    fn test_invalid_prefix_rejected() {
556        let cfg = AllocatorConfig {
557            default_prefix: "no-dash".to_string(),
558            ..Default::default()
559        };
560        let result = BlankNodeAllocator::with_config(cfg);
561        assert!(result.is_err());
562    }
563
564    #[test]
565    fn test_invalid_skolem_base_rejected() {
566        let cfg = AllocatorConfig {
567            skolem_base: "ftp://bad".to_string(),
568            ..Default::default()
569        };
570        let result = BlankNodeAllocator::with_config(cfg);
571        assert!(result.is_err());
572    }
573
574    // -- Scope-based allocation --
575
576    #[test]
577    fn test_create_and_use_scope() {
578        let mut alloc = BlankNodeAllocator::new();
579        alloc.create_scope("doc1").expect("create");
580        let id = alloc.next_in_scope("doc1").expect("scoped");
581        assert!(id.id.starts_with("doc1_"));
582        assert_eq!(id.scope.as_deref(), Some("doc1"));
583    }
584
585    #[test]
586    fn test_scope_counter_independent() {
587        let mut alloc = BlankNodeAllocator::new();
588        alloc.create_scope("a").expect("a");
589        alloc.create_scope("b").expect("b");
590        let a0 = alloc.next_in_scope("a").expect("a0");
591        let b0 = alloc.next_in_scope("b").expect("b0");
592        let a1 = alloc.next_in_scope("a").expect("a1");
593        assert_eq!(a0.id, "a_b0");
594        assert_eq!(b0.id, "b_b0");
595        assert_eq!(a1.id, "a_b1");
596    }
597
598    #[test]
599    fn test_unknown_scope_error() {
600        let mut alloc = BlankNodeAllocator::new();
601        let err = alloc.next_in_scope("missing").expect_err("unknown scope");
602        assert!(matches!(err, BlankNodeError::UnknownScope(_)));
603    }
604
605    #[test]
606    fn test_ids_in_scope() {
607        let mut alloc = BlankNodeAllocator::new();
608        alloc.create_scope("s").expect("create");
609        alloc.next_in_scope("s").expect("s0");
610        alloc.next_in_scope("s").expect("s1");
611        let ids = alloc.ids_in_scope("s").expect("list");
612        assert_eq!(ids.len(), 2);
613    }
614
615    #[test]
616    fn test_remove_scope() {
617        let mut alloc = BlankNodeAllocator::new();
618        alloc.create_scope("tmp").expect("create");
619        alloc.next_in_scope("tmp").expect("n");
620        alloc.remove_scope("tmp").expect("remove");
621        assert!(!alloc.active_scopes().contains(&"tmp".to_string()));
622    }
623
624    #[test]
625    fn test_remove_unknown_scope_errors() {
626        let mut alloc = BlankNodeAllocator::new();
627        let err = alloc.remove_scope("nope").expect_err("remove unknown");
628        assert!(matches!(err, BlankNodeError::UnknownScope(_)));
629    }
630
631    #[test]
632    fn test_create_scope_empty_name_fails() {
633        let mut alloc = BlankNodeAllocator::new();
634        let err = alloc.create_scope("").expect_err("empty scope");
635        assert!(matches!(err, BlankNodeError::EmptyId));
636    }
637
638    #[test]
639    fn test_active_scopes_list() {
640        let mut alloc = BlankNodeAllocator::new();
641        alloc.create_scope("x").expect("x");
642        alloc.create_scope("y").expect("y");
643        let scopes = alloc.active_scopes();
644        assert!(scopes.contains(&"x".to_string()));
645        assert!(scopes.contains(&"y".to_string()));
646    }
647
648    // -- Skolemization --
649
650    #[test]
651    fn test_skolemize_default_base() {
652        let mut alloc = BlankNodeAllocator::new();
653        let iri = alloc.skolemize("b0").expect("skolemize");
654        assert_eq!(iri, "https://example.org/.well-known/genid/b0");
655    }
656
657    #[test]
658    fn test_skolemize_custom_base() {
659        let mut alloc = BlankNodeAllocator::new();
660        let iri = alloc
661            .skolemize_with_base("n1", "https://data.org/genid/")
662            .expect("custom");
663        assert_eq!(iri, "https://data.org/genid/n1");
664    }
665
666    #[test]
667    fn test_skolemize_custom_base_no_trailing_slash() {
668        let mut alloc = BlankNodeAllocator::new();
669        let iri = alloc
670            .skolemize_with_base("n1", "https://data.org/genid")
671            .expect("no slash");
672        assert_eq!(iri, "https://data.org/genid/n1");
673    }
674
675    #[test]
676    fn test_skolemize_empty_id_fails() {
677        let mut alloc = BlankNodeAllocator::new();
678        let err = alloc.skolemize("").expect_err("empty");
679        assert!(matches!(err, BlankNodeError::EmptyId));
680    }
681
682    #[test]
683    fn test_skolemize_no_base_configured() {
684        let cfg = AllocatorConfig {
685            skolem_base: String::new(),
686            ..Default::default()
687        };
688        let mut alloc = BlankNodeAllocator::with_config(cfg).expect("cfg");
689        let err = alloc.skolemize("b0").expect_err("no base");
690        assert!(matches!(err, BlankNodeError::InvalidBaseUri(_)));
691    }
692
693    // -- Deskolemization --
694
695    #[test]
696    fn test_deskolemize_default_base() {
697        let mut alloc = BlankNodeAllocator::new();
698        let bn = alloc
699            .deskolemize("https://example.org/.well-known/genid/b42")
700            .expect("de");
701        assert_eq!(bn.id, "b42");
702    }
703
704    #[test]
705    fn test_deskolemize_with_angle_brackets() {
706        let mut alloc = BlankNodeAllocator::new();
707        let bn = alloc
708            .deskolemize("<https://example.org/.well-known/genid/n7>")
709            .expect("de");
710        assert_eq!(bn.id, "n7");
711    }
712
713    #[test]
714    fn test_deskolemize_wrong_base_fails() {
715        let mut alloc = BlankNodeAllocator::new();
716        let err = alloc
717            .deskolemize("https://other.org/genid/b0")
718            .expect_err("wrong base");
719        assert!(matches!(err, BlankNodeError::InvalidBaseUri(_)));
720    }
721
722    #[test]
723    fn test_deskolemize_with_custom_base() {
724        let mut alloc = BlankNodeAllocator::new();
725        let bn = alloc
726            .deskolemize_with_base("https://data.org/genid/x1", "https://data.org/genid/")
727            .expect("custom de");
728        assert_eq!(bn.id, "x1");
729    }
730
731    #[test]
732    fn test_roundtrip_skolemize_deskolemize() {
733        let mut alloc = BlankNodeAllocator::new();
734        let iri = alloc.skolemize("abc123").expect("sk");
735        let bn = alloc.deskolemize(&iri).expect("de");
736        assert_eq!(bn.id, "abc123");
737    }
738
739    // -- Mapping --
740
741    #[test]
742    fn test_discover_mapping_equal_sizes() {
743        let source = vec!["b0", "b1", "b2"];
744        let target = vec!["x0", "x1", "x2"];
745        let map = BlankNodeAllocator::discover_mapping(&source, &target).expect("map");
746        assert_eq!(map.get("b0"), Some(&"x0".to_string()));
747        assert_eq!(map.get("b2"), Some(&"x2".to_string()));
748    }
749
750    #[test]
751    fn test_discover_mapping_size_mismatch() {
752        let source = vec!["b0", "b1"];
753        let target = vec!["x0"];
754        let err = BlankNodeAllocator::discover_mapping(&source, &target).expect_err("mismatch");
755        assert!(matches!(err, BlankNodeError::MappingConflict(_)));
756    }
757
758    #[test]
759    fn test_discover_mapping_duplicate_target() {
760        let source = vec!["b0", "b1"];
761        let target = vec!["x0", "x0"];
762        let err = BlankNodeAllocator::discover_mapping(&source, &target).expect_err("dup");
763        assert!(matches!(err, BlankNodeError::MappingConflict(_)));
764    }
765
766    #[test]
767    fn test_apply_mapping() {
768        let mut mapping = HashMap::new();
769        mapping.insert("b0".to_string(), "x0".to_string());
770        mapping.insert("b1".to_string(), "x1".to_string());
771        let result = BlankNodeAllocator::apply_mapping(&["b0", "b1", "b2"], &mapping);
772        assert_eq!(result, vec!["x0", "x1", "b2"]);
773    }
774
775    #[test]
776    fn test_verify_mapping_valid() {
777        let mut mapping = HashMap::new();
778        mapping.insert("a".to_string(), "x".to_string());
779        mapping.insert("b".to_string(), "y".to_string());
780        assert!(BlankNodeAllocator::verify_mapping(&mapping).is_ok());
781    }
782
783    #[test]
784    fn test_verify_mapping_duplicate_values() {
785        let mut mapping = HashMap::new();
786        mapping.insert("a".to_string(), "x".to_string());
787        mapping.insert("b".to_string(), "x".to_string());
788        let err = BlankNodeAllocator::verify_mapping(&mapping).expect_err("dup");
789        assert!(matches!(err, BlankNodeError::MappingConflict(_)));
790    }
791
792    // -- Renaming --
793
794    #[test]
795    fn test_rename_with_prefix() {
796        let ids = vec!["b0", "b1", "b2"];
797        let result = BlankNodeAllocator::rename_with_prefix(&ids, "merged_").expect("rename");
798        assert_eq!(result[0].id, "merged_0");
799        assert_eq!(result[1].id, "merged_1");
800        assert_eq!(result[2].id, "merged_2");
801    }
802
803    #[test]
804    fn test_rename_prefix_swap() {
805        let ids = vec!["old_0", "old_1"];
806        let result = BlankNodeAllocator::rename_prefix(&ids, "old_", "new_").expect("swap");
807        assert_eq!(result[0].id, "new_0");
808        assert_eq!(result[1].id, "new_1");
809    }
810
811    #[test]
812    fn test_rename_prefix_no_match_keeps_original() {
813        let ids = vec!["other_0"];
814        let result = BlankNodeAllocator::rename_prefix(&ids, "old_", "new_").expect("no match");
815        assert_eq!(result[0].id, "other_0");
816    }
817
818    #[test]
819    fn test_scope_rename() {
820        let ids = vec!["b0", "b1"];
821        let result = BlankNodeAllocator::scope_rename(&ids, "graph1").expect("scope rename");
822        assert_eq!(result[0].id, "graph1_b0");
823        assert_eq!(result[0].scope, Some("graph1".to_string()));
824        assert_eq!(result[1].id, "graph1_b1");
825    }
826
827    #[test]
828    fn test_scope_rename_empty_scope_fails() {
829        let ids = vec!["b0"];
830        let err = BlankNodeAllocator::scope_rename(&ids, "").expect_err("empty");
831        assert!(matches!(err, BlankNodeError::EmptyId));
832    }
833
834    #[test]
835    fn test_rename_invalid_prefix_fails() {
836        let ids = vec!["b0"];
837        let err = BlankNodeAllocator::rename_with_prefix(&ids, "bad-prefix").expect_err("invalid");
838        assert!(matches!(err, BlankNodeError::InvalidPrefix(_)));
839    }
840
841    // -- Statistics --
842
843    #[test]
844    fn test_stats_after_scope_allocation() {
845        let mut alloc = BlankNodeAllocator::new();
846        alloc.create_scope("doc").expect("create");
847        alloc.next_in_scope("doc").expect("n1");
848        alloc.next_in_scope("doc").expect("n2");
849        let stats = alloc.stats();
850        assert_eq!(stats.total_allocated, 2);
851        assert_eq!(stats.per_scope.get("doc"), Some(&2));
852    }
853
854    #[test]
855    fn test_stats_skolemized_count() {
856        let mut alloc = BlankNodeAllocator::new();
857        alloc.skolemize("b0").expect("sk");
858        alloc.skolemize("b1").expect("sk");
859        assert_eq!(alloc.stats().skolemized, 2);
860    }
861
862    #[test]
863    fn test_stats_deskolemized_count() {
864        let mut alloc = BlankNodeAllocator::new();
865        alloc
866            .deskolemize("https://example.org/.well-known/genid/b0")
867            .expect("de");
868        assert_eq!(alloc.stats().deskolemized, 1);
869    }
870
871    // -- Thread safety --
872
873    #[test]
874    fn test_shared_counter_across_threads() {
875        let alloc = BlankNodeAllocator::new();
876        let counter = alloc.shared_counter();
877        let handles: Vec<_> = (0..4)
878            .map(|_| {
879                let c = Arc::clone(&counter);
880                std::thread::spawn(move || {
881                    for _ in 0..100 {
882                        c.fetch_add(1, Ordering::Relaxed);
883                    }
884                })
885            })
886            .collect();
887        for h in handles {
888            h.join().expect("thread join");
889        }
890        assert_eq!(counter.load(Ordering::Relaxed), 400);
891    }
892
893    #[test]
894    fn test_blank_node_id_equality() {
895        let a = BlankNodeId::new("x");
896        let b = BlankNodeId::new("x");
897        assert_eq!(a, b);
898    }
899
900    #[test]
901    fn test_blank_node_id_with_scope_equality() {
902        let a = BlankNodeId::with_scope("x0", "doc1");
903        let b = BlankNodeId::with_scope("x0", "doc1");
904        assert_eq!(a, b);
905    }
906
907    #[test]
908    fn test_blank_node_id_hash() {
909        use std::collections::HashSet;
910        let mut set = HashSet::new();
911        set.insert(BlankNodeId::new("a"));
912        set.insert(BlankNodeId::new("a"));
913        set.insert(BlankNodeId::new("b"));
914        assert_eq!(set.len(), 2);
915    }
916
917    #[test]
918    fn test_default_allocator() {
919        let alloc = BlankNodeAllocator::default();
920        let id = alloc.next().expect("default");
921        assert_eq!(id.id, "b0");
922    }
923
924    #[test]
925    fn test_config_accessor() {
926        let alloc = BlankNodeAllocator::new();
927        assert_eq!(alloc.config().default_prefix, "b");
928    }
929
930    #[test]
931    fn test_empty_mapping_is_valid() {
932        let map: HashMap<String, String> = HashMap::new();
933        assert!(BlankNodeAllocator::verify_mapping(&map).is_ok());
934    }
935
936    #[test]
937    fn test_discover_mapping_empty() {
938        let source: Vec<&str> = vec![];
939        let target: Vec<&str> = vec![];
940        let map = BlankNodeAllocator::discover_mapping(&source, &target).expect("empty");
941        assert!(map.is_empty());
942    }
943}