Skip to main content

oxirs_core/provenance/
mod.rs

1//! W3C PROV-O ontology support for RDF data provenance tracking
2//!
3//! This module implements the W3C PROV-O (Provenance Ontology) for tracking
4//! data provenance in RDF graphs. It supports the core PROV-O classes
5//! (Entity, Activity, Agent), relations, and bundles.
6//!
7//! # References
8//! - <https://www.w3.org/TR/prov-o/>
9//! - <https://www.w3.org/TR/prov-dm/>
10
11use crate::model::{Literal, NamedNode, Object, Predicate, Subject, Triple};
12use crate::OxirsError;
13use std::collections::HashMap;
14
15/// PROV-O namespace prefix
16pub const PROV_NS: &str = "http://www.w3.org/ns/prov#";
17
18/// XSD namespace prefix
19pub const XSD_NS: &str = "http://www.w3.org/2001/XMLSchema#";
20
21/// RDF namespace prefix
22pub const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
23
24/// Build a PROV-O IRI from a local name
25fn prov_iri(local: &str) -> NamedNode {
26    NamedNode::new_unchecked(format!("{PROV_NS}{local}"))
27}
28
29/// Build an XSD datatype IRI
30fn xsd_iri(local: &str) -> NamedNode {
31    NamedNode::new_unchecked(format!("{XSD_NS}{local}"))
32}
33
34/// Build an RDF IRI
35fn rdf_iri(local: &str) -> NamedNode {
36    NamedNode::new_unchecked(format!("{RDF_NS}{local}"))
37}
38
39// ── Agent type ───────────────────────────────────────────────────────────────
40
41/// The type of a PROV-O agent (prov:SoftwareAgent, prov:Person, prov:Organization)
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub enum AgentType {
44    /// prov:SoftwareAgent — a running software system
45    SoftwareAgent,
46    /// prov:Person — a human being
47    Person,
48    /// prov:Organization — a social or legal institution
49    Organization,
50}
51
52impl AgentType {
53    /// Return the PROV-O IRI for this agent type
54    pub fn as_iri(&self) -> NamedNode {
55        match self {
56            AgentType::SoftwareAgent => prov_iri("SoftwareAgent"),
57            AgentType::Person => prov_iri("Person"),
58            AgentType::Organization => prov_iri("Organization"),
59        }
60    }
61}
62
63// ── Core PROV-O classes ───────────────────────────────────────────────────────
64
65/// A PROV-O Entity — something whose provenance we track (prov:Entity)
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ProvEntity {
68    /// The IRI identifying this entity
69    pub iri: NamedNode,
70    /// Arbitrary attribute-value pairs attached to this entity
71    pub attributes: Vec<(NamedNode, Object)>,
72}
73
74impl ProvEntity {
75    /// Create a new PROV-O entity with the given IRI and no extra attributes
76    pub fn new(iri: NamedNode) -> Self {
77        Self {
78            iri,
79            attributes: Vec::new(),
80        }
81    }
82
83    /// Create a new entity with additional attributes
84    pub fn with_attributes(iri: NamedNode, attributes: Vec<(NamedNode, Object)>) -> Self {
85        Self { iri, attributes }
86    }
87
88    /// Emit RDF triples that represent this entity
89    pub fn to_triples(&self) -> Vec<Triple> {
90        let mut triples = Vec::new();
91        // rdf:type prov:Entity
92        triples.push(Triple::new(
93            self.iri.clone(),
94            rdf_iri("type"),
95            prov_iri("Entity"),
96        ));
97        for (pred, obj) in &self.attributes {
98            triples.push(Triple::new(self.iri.clone(), pred.clone(), obj.clone()));
99        }
100        triples
101    }
102}
103
104/// A PROV-O Activity — something that occurred over a period of time (prov:Activity)
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ProvActivity {
107    /// The IRI identifying this activity
108    pub iri: NamedNode,
109    /// Optional start time as an XSD dateTime string
110    pub started_at: Option<String>,
111    /// Optional end time as an XSD dateTime string
112    pub ended_at: Option<String>,
113    /// Arbitrary attribute-value pairs attached to this activity
114    pub attributes: Vec<(NamedNode, Object)>,
115}
116
117impl ProvActivity {
118    /// Create a new PROV-O activity with only an IRI
119    pub fn new(iri: NamedNode) -> Self {
120        Self {
121            iri,
122            started_at: None,
123            ended_at: None,
124            attributes: Vec::new(),
125        }
126    }
127
128    /// Create a new activity with optional start/end times and attributes
129    pub fn with_times(
130        iri: NamedNode,
131        started_at: Option<String>,
132        ended_at: Option<String>,
133        attributes: Vec<(NamedNode, Object)>,
134    ) -> Self {
135        Self {
136            iri,
137            started_at,
138            ended_at,
139            attributes,
140        }
141    }
142
143    /// Emit RDF triples that represent this activity
144    pub fn to_triples(&self) -> Vec<Triple> {
145        let mut triples = Vec::new();
146        let xsd_datetime = xsd_iri("dateTime");
147
148        // rdf:type prov:Activity
149        triples.push(Triple::new(
150            self.iri.clone(),
151            rdf_iri("type"),
152            prov_iri("Activity"),
153        ));
154
155        if let Some(ref start) = self.started_at {
156            triples.push(Triple::new(
157                self.iri.clone(),
158                prov_iri("startedAtTime"),
159                Literal::new_typed(start.as_str(), xsd_datetime.clone()),
160            ));
161        }
162
163        if let Some(ref end) = self.ended_at {
164            triples.push(Triple::new(
165                self.iri.clone(),
166                prov_iri("endedAtTime"),
167                Literal::new_typed(end.as_str(), xsd_datetime.clone()),
168            ));
169        }
170
171        for (pred, obj) in &self.attributes {
172            triples.push(Triple::new(self.iri.clone(), pred.clone(), obj.clone()));
173        }
174
175        triples
176    }
177}
178
179/// A PROV-O Agent — something responsible for an activity (prov:Agent)
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct ProvAgent {
182    /// The IRI identifying this agent
183    pub iri: NamedNode,
184    /// The specific sub-type of agent
185    pub agent_type: AgentType,
186    /// Arbitrary attribute-value pairs attached to this agent
187    pub attributes: Vec<(NamedNode, Object)>,
188}
189
190impl ProvAgent {
191    /// Create a new PROV-O agent
192    pub fn new(iri: NamedNode, agent_type: AgentType) -> Self {
193        Self {
194            iri,
195            agent_type,
196            attributes: Vec::new(),
197        }
198    }
199
200    /// Create a new agent with extra attributes
201    pub fn with_attributes(
202        iri: NamedNode,
203        agent_type: AgentType,
204        attributes: Vec<(NamedNode, Object)>,
205    ) -> Self {
206        Self {
207            iri,
208            agent_type,
209            attributes,
210        }
211    }
212
213    /// Emit RDF triples that represent this agent
214    pub fn to_triples(&self) -> Vec<Triple> {
215        let mut triples = Vec::new();
216
217        // rdf:type prov:Agent
218        triples.push(Triple::new(
219            self.iri.clone(),
220            rdf_iri("type"),
221            prov_iri("Agent"),
222        ));
223
224        // rdf:type <specific-agent-type>
225        triples.push(Triple::new(
226            self.iri.clone(),
227            rdf_iri("type"),
228            self.agent_type.as_iri(),
229        ));
230
231        for (pred, obj) in &self.attributes {
232            triples.push(Triple::new(self.iri.clone(), pred.clone(), obj.clone()));
233        }
234
235        triples
236    }
237}
238
239// ── PROV-O relations ──────────────────────────────────────────────────────────
240
241/// The kind of PROV-O relation connecting two resources
242#[derive(Debug, Clone, PartialEq, Eq, Hash)]
243pub enum ProvRelationKind {
244    /// entity prov:wasGeneratedBy activity
245    WasGeneratedBy,
246    /// entity prov:wasDerivedFrom entity
247    WasDerivedFrom,
248    /// entity prov:wasAttributedTo agent
249    WasAttributedTo,
250    /// activity prov:used entity
251    Used,
252    /// activity prov:wasAssociatedWith agent
253    WasAssociatedWith,
254    /// activity prov:wasInformedBy activity
255    WasInformedBy,
256    /// agent prov:actedOnBehalfOf agent
257    ActedOnBehalfOf,
258}
259
260impl ProvRelationKind {
261    /// Return the PROV-O predicate IRI for this relation kind
262    pub fn as_predicate(&self) -> NamedNode {
263        match self {
264            ProvRelationKind::WasGeneratedBy => prov_iri("wasGeneratedBy"),
265            ProvRelationKind::WasDerivedFrom => prov_iri("wasDerivedFrom"),
266            ProvRelationKind::WasAttributedTo => prov_iri("wasAttributedTo"),
267            ProvRelationKind::Used => prov_iri("used"),
268            ProvRelationKind::WasAssociatedWith => prov_iri("wasAssociatedWith"),
269            ProvRelationKind::WasInformedBy => prov_iri("wasInformedBy"),
270            ProvRelationKind::ActedOnBehalfOf => prov_iri("actedOnBehalfOf"),
271        }
272    }
273}
274
275/// A single PROV-O relation connecting two IRIs
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct ProvRelation {
278    /// The kind of relation (determines the predicate IRI)
279    pub kind: ProvRelationKind,
280    /// The subject of the relation
281    pub subject: NamedNode,
282    /// The object of the relation
283    pub object: NamedNode,
284    /// Optional qualifier (for qualified relations such as prov:qualifiedGeneration)
285    pub qualifier: Option<NamedNode>,
286}
287
288impl ProvRelation {
289    /// Create a new PROV-O relation
290    pub fn new(kind: ProvRelationKind, subject: NamedNode, object: NamedNode) -> Self {
291        Self {
292            kind,
293            subject,
294            object,
295            qualifier: None,
296        }
297    }
298
299    /// Create a new PROV-O relation with a qualifier
300    pub fn with_qualifier(
301        kind: ProvRelationKind,
302        subject: NamedNode,
303        object: NamedNode,
304        qualifier: NamedNode,
305    ) -> Self {
306        Self {
307            kind,
308            subject,
309            object,
310            qualifier: Some(qualifier),
311        }
312    }
313
314    /// Emit the RDF triple for this relation
315    pub fn to_triple(&self) -> Triple {
316        Triple::new(
317            self.subject.clone(),
318            self.kind.as_predicate(),
319            self.object.clone(),
320        )
321    }
322}
323
324// ── Provenance Bundle ─────────────────────────────────────────────────────────
325
326/// A PROV-O Bundle — a named collection of provenance statements
327///
328/// Bundles allow grouping provenance statements that describe the same
329/// provenance record. They map naturally to named graphs in RDF datasets.
330#[derive(Debug, Clone)]
331pub struct ProvBundle {
332    /// IRI identifying this bundle
333    pub iri: NamedNode,
334    /// Entities in this bundle
335    pub entities: Vec<ProvEntity>,
336    /// Activities in this bundle
337    pub activities: Vec<ProvActivity>,
338    /// Agents in this bundle
339    pub agents: Vec<ProvAgent>,
340    /// Relations between entities, activities, and agents
341    pub relations: Vec<ProvRelation>,
342}
343
344impl ProvBundle {
345    /// Create an empty provenance bundle
346    pub fn new(iri: NamedNode) -> Self {
347        Self {
348            iri,
349            entities: Vec::new(),
350            activities: Vec::new(),
351            agents: Vec::new(),
352            relations: Vec::new(),
353        }
354    }
355
356    /// Add an entity to this bundle
357    pub fn add_entity(&mut self, entity: ProvEntity) {
358        self.entities.push(entity);
359    }
360
361    /// Add an activity to this bundle
362    pub fn add_activity(&mut self, activity: ProvActivity) {
363        self.activities.push(activity);
364    }
365
366    /// Add an agent to this bundle
367    pub fn add_agent(&mut self, agent: ProvAgent) {
368        self.agents.push(agent);
369    }
370
371    /// Add a relation to this bundle
372    pub fn add_relation(&mut self, relation: ProvRelation) {
373        self.relations.push(relation);
374    }
375
376    /// Serialize the bundle to a flat Vec of RDF triples.
377    ///
378    /// The bundle IRI is typed as prov:Bundle. All entities, activities,
379    /// agents, and relations are serialized into the same flat triple list.
380    pub fn to_rdf(&self) -> Vec<Triple> {
381        let mut triples = Vec::new();
382
383        // Declare the bundle itself
384        triples.push(Triple::new(
385            self.iri.clone(),
386            rdf_iri("type"),
387            prov_iri("Bundle"),
388        ));
389
390        for entity in &self.entities {
391            triples.extend(entity.to_triples());
392        }
393        for activity in &self.activities {
394            triples.extend(activity.to_triples());
395        }
396        for agent in &self.agents {
397            triples.extend(agent.to_triples());
398        }
399        for relation in &self.relations {
400            triples.push(relation.to_triple());
401        }
402
403        triples
404    }
405
406    /// Parse a bundle from a slice of RDF triples.
407    ///
408    /// This is a best-effort round-trip: it reconstructs entities, activities,
409    /// agents, and relations from the triple patterns defined by PROV-O.
410    pub fn from_rdf(triples: &[Triple]) -> Result<Self, OxirsError> {
411        // Group triples by subject
412        let mut by_subject: HashMap<String, Vec<&Triple>> = HashMap::new();
413        for triple in triples {
414            let key = match triple.subject() {
415                Subject::NamedNode(n) => n.as_str().to_string(),
416                Subject::BlankNode(b) => b.as_str().to_string(),
417                _ => continue,
418            };
419            by_subject.entry(key).or_default().push(triple);
420        }
421
422        let type_pred_full = format!("{RDF_NS}type");
423        let bundle_type_full = format!("{PROV_NS}Bundle");
424
425        // Determine the bundle IRI — it is typed as prov:Bundle
426        let bundle_iri_str = triples
427            .iter()
428            .find(|t| {
429                matches!(t.predicate(), Predicate::NamedNode(p) if p.as_str() == type_pred_full)
430                    && matches!(t.object(), Object::NamedNode(o) if o.as_str() == bundle_type_full)
431            })
432            .and_then(|t| match t.subject() {
433                Subject::NamedNode(n) => Some(n.as_str().to_string()),
434                _ => None,
435            })
436            .ok_or_else(|| OxirsError::Parse("No prov:Bundle declaration found".to_string()))?;
437
438        let bundle_iri = NamedNode::new_unchecked(bundle_iri_str.clone());
439
440        // Classify subjects by rdf:type
441        let entity_type = format!("{PROV_NS}Entity");
442        let activity_type = format!("{PROV_NS}Activity");
443        let agent_type_iri_str = format!("{PROV_NS}Agent");
444        let software_type = format!("{PROV_NS}SoftwareAgent");
445        let person_type = format!("{PROV_NS}Person");
446        let org_type = format!("{PROV_NS}Organization");
447
448        let mut entities: Vec<ProvEntity> = Vec::new();
449        let mut activities: Vec<ProvActivity> = Vec::new();
450        let mut agents: Vec<ProvAgent> = Vec::new();
451        let mut relations: Vec<ProvRelation> = Vec::new();
452
453        // Build relation kind map (predicate IRI -> kind)
454        let relation_kind_map: HashMap<String, ProvRelationKind> = [
455            (
456                format!("{PROV_NS}wasGeneratedBy"),
457                ProvRelationKind::WasGeneratedBy,
458            ),
459            (
460                format!("{PROV_NS}wasDerivedFrom"),
461                ProvRelationKind::WasDerivedFrom,
462            ),
463            (
464                format!("{PROV_NS}wasAttributedTo"),
465                ProvRelationKind::WasAttributedTo,
466            ),
467            (format!("{PROV_NS}used"), ProvRelationKind::Used),
468            (
469                format!("{PROV_NS}wasAssociatedWith"),
470                ProvRelationKind::WasAssociatedWith,
471            ),
472            (
473                format!("{PROV_NS}wasInformedBy"),
474                ProvRelationKind::WasInformedBy,
475            ),
476            (
477                format!("{PROV_NS}actedOnBehalfOf"),
478                ProvRelationKind::ActedOnBehalfOf,
479            ),
480        ]
481        .into_iter()
482        .collect();
483
484        // Scan each triple for relations
485        for triple in triples {
486            let subj_iri = match triple.subject() {
487                Subject::NamedNode(n) => n.clone(),
488                _ => continue,
489            };
490            let pred_str = match triple.predicate() {
491                Predicate::NamedNode(p) => p.as_str().to_string(),
492                _ => continue,
493            };
494            let obj_iri = match triple.object() {
495                Object::NamedNode(o) => o.clone(),
496                _ => continue,
497            };
498
499            if let Some(kind) = relation_kind_map.get(&pred_str) {
500                relations.push(ProvRelation::new(kind.clone(), subj_iri, obj_iri));
501            }
502        }
503
504        // Classify each subject
505        for (subj_str, subj_triples) in &by_subject {
506            if subj_str == &bundle_iri_str {
507                continue;
508            }
509
510            // Collect types for this subject
511            let types: Vec<String> = subj_triples
512                .iter()
513                .filter(|t| {
514                    matches!(t.predicate(), Predicate::NamedNode(p) if p.as_str() == type_pred_full)
515                })
516                .filter_map(|t| match t.object() {
517                    Object::NamedNode(o) => Some(o.as_str().to_string()),
518                    _ => None,
519                })
520                .collect();
521
522            let iri = NamedNode::new_unchecked(subj_str.clone());
523
524            // Collect non-type, non-relation attributes
525            let attributes: Vec<(NamedNode, Object)> = subj_triples
526                .iter()
527                .filter_map(|t| {
528                    if let Predicate::NamedNode(p) = t.predicate() {
529                        let p_str = p.as_str();
530                        if p_str == type_pred_full {
531                            return None;
532                        }
533                        if relation_kind_map.contains_key(p_str) {
534                            return None;
535                        }
536                        Some((p.clone(), t.object().clone()))
537                    } else {
538                        None
539                    }
540                })
541                .collect();
542
543            if types.contains(&entity_type) {
544                entities.push(ProvEntity::with_attributes(iri, attributes));
545            } else if types.contains(&activity_type) {
546                let start_pred = format!("{PROV_NS}startedAtTime");
547                let end_pred = format!("{PROV_NS}endedAtTime");
548
549                let start = attributes
550                    .iter()
551                    .find(|(p, _)| p.as_str() == start_pred)
552                    .and_then(|(_, o)| match o {
553                        Object::Literal(l) => Some(l.value().to_string()),
554                        _ => None,
555                    });
556                let end = attributes
557                    .iter()
558                    .find(|(p, _)| p.as_str() == end_pred)
559                    .and_then(|(_, o)| match o {
560                        Object::Literal(l) => Some(l.value().to_string()),
561                        _ => None,
562                    });
563                let extra_attrs: Vec<(NamedNode, Object)> = attributes
564                    .into_iter()
565                    .filter(|(p, _)| p.as_str() != start_pred && p.as_str() != end_pred)
566                    .collect();
567                activities.push(ProvActivity::with_times(iri, start, end, extra_attrs));
568            } else if types.contains(&agent_type_iri_str) {
569                let agent_kind = if types.contains(&software_type) {
570                    AgentType::SoftwareAgent
571                } else if types.contains(&person_type) {
572                    AgentType::Person
573                } else if types.contains(&org_type) {
574                    AgentType::Organization
575                } else {
576                    AgentType::Person
577                };
578                agents.push(ProvAgent::with_attributes(iri, agent_kind, attributes));
579            }
580        }
581
582        Ok(Self {
583            iri: bundle_iri,
584            entities,
585            activities,
586            agents,
587            relations,
588        })
589    }
590}
591
592// ── Query provenance tracker ──────────────────────────────────────────────────
593
594/// Track the provenance of a SPARQL query execution
595///
596/// This captures who executed a query, when, against what dataset, and
597/// what result dataset was produced. It can be exported as a PROV-O bundle.
598#[derive(Debug, Clone)]
599pub struct QueryProvenanceTracker {
600    /// IRI identifying this specific query execution
601    pub query_iri: NamedNode,
602    /// When the query was executed (XSD dateTime string)
603    pub executed_at: String,
604    /// IRI of the software agent that ran the query
605    pub executed_by: NamedNode,
606    /// IRI of the input dataset (the graph queried over)
607    pub input_dataset: NamedNode,
608    /// IRI of the result dataset (the query output)
609    pub result_dataset: NamedNode,
610    /// Optional SPARQL query string
611    pub query_text: Option<String>,
612}
613
614impl QueryProvenanceTracker {
615    /// Create a new query provenance tracker
616    pub fn new(
617        query_iri: NamedNode,
618        executed_at: String,
619        executed_by: NamedNode,
620        input_dataset: NamedNode,
621        result_dataset: NamedNode,
622    ) -> Self {
623        Self {
624            query_iri,
625            executed_at,
626            executed_by,
627            input_dataset,
628            result_dataset,
629            query_text: None,
630        }
631    }
632
633    /// Attach the original SPARQL query text as a prov:value attribute
634    pub fn with_query_text(mut self, text: impl Into<String>) -> Self {
635        self.query_text = Some(text.into());
636        self
637    }
638
639    /// Convert this tracker to a PROV-O bundle
640    ///
641    /// The bundle represents:
642    /// - `result_dataset` was generated by `query_iri`
643    /// - `query_iri` used `input_dataset`
644    /// - `query_iri` was associated with `executed_by`
645    /// - `result_dataset` was attributed to `executed_by`
646    pub fn to_bundle(&self) -> ProvBundle {
647        let bundle_iri =
648            NamedNode::new_unchecked(format!("{}/provenance", self.query_iri.as_str()));
649        let mut bundle = ProvBundle::new(bundle_iri);
650
651        // Entities: input and result datasets
652        bundle.add_entity(ProvEntity::new(self.input_dataset.clone()));
653        bundle.add_entity(ProvEntity::new(self.result_dataset.clone()));
654
655        // Activity: the query execution itself
656        let mut activity_attrs: Vec<(NamedNode, Object)> = Vec::new();
657        if let Some(ref text) = self.query_text {
658            activity_attrs.push((
659                prov_iri("value"),
660                Object::Literal(Literal::new(text.as_str())),
661            ));
662        }
663
664        bundle.add_activity(ProvActivity::with_times(
665            self.query_iri.clone(),
666            Some(self.executed_at.clone()),
667            Some(self.executed_at.clone()),
668            activity_attrs,
669        ));
670
671        // Agent: the software agent
672        bundle.add_agent(ProvAgent::new(
673            self.executed_by.clone(),
674            AgentType::SoftwareAgent,
675        ));
676
677        // Relations
678        bundle.add_relation(ProvRelation::new(
679            ProvRelationKind::WasGeneratedBy,
680            self.result_dataset.clone(),
681            self.query_iri.clone(),
682        ));
683        bundle.add_relation(ProvRelation::new(
684            ProvRelationKind::Used,
685            self.query_iri.clone(),
686            self.input_dataset.clone(),
687        ));
688        bundle.add_relation(ProvRelation::new(
689            ProvRelationKind::WasAssociatedWith,
690            self.query_iri.clone(),
691            self.executed_by.clone(),
692        ));
693        bundle.add_relation(ProvRelation::new(
694            ProvRelationKind::WasAttributedTo,
695            self.result_dataset.clone(),
696            self.executed_by.clone(),
697        ));
698
699        bundle
700    }
701}
702
703// ── Tests ─────────────────────────────────────────────────────────────────────
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    fn nn(iri: &str) -> NamedNode {
710        NamedNode::new_unchecked(iri)
711    }
712
713    // ── AgentType ──────────────────────────────────────────────────────────
714
715    #[test]
716    fn test_agent_type_software_agent_iri() {
717        assert_eq!(
718            AgentType::SoftwareAgent.as_iri().as_str(),
719            "http://www.w3.org/ns/prov#SoftwareAgent"
720        );
721    }
722
723    #[test]
724    fn test_agent_type_person_iri() {
725        assert_eq!(
726            AgentType::Person.as_iri().as_str(),
727            "http://www.w3.org/ns/prov#Person"
728        );
729    }
730
731    #[test]
732    fn test_agent_type_organization_iri() {
733        assert_eq!(
734            AgentType::Organization.as_iri().as_str(),
735            "http://www.w3.org/ns/prov#Organization"
736        );
737    }
738
739    #[test]
740    fn test_agent_type_equality() {
741        assert_eq!(AgentType::Person, AgentType::Person);
742        assert_ne!(AgentType::Person, AgentType::Organization);
743    }
744
745    // ── ProvEntity ─────────────────────────────────────────────────────────
746
747    #[test]
748    fn test_entity_new_has_type_triple() {
749        let entity = ProvEntity::new(nn("http://example.org/data1"));
750        let triples = entity.to_triples();
751        assert!(
752            triples.iter().any(|t| {
753                matches!(t.predicate(), Predicate::NamedNode(p) if p.as_str().contains("type"))
754                    && matches!(t.object(), Object::NamedNode(o) if o.as_str().contains("Entity"))
755            }),
756            "entity must have rdf:type prov:Entity triple"
757        );
758    }
759
760    #[test]
761    fn test_entity_new_no_extra_attributes() {
762        let entity = ProvEntity::new(nn("http://example.org/data1"));
763        // Only the type triple
764        assert_eq!(entity.to_triples().len(), 1);
765    }
766
767    #[test]
768    fn test_entity_with_attributes() {
769        let label_pred = nn("http://www.w3.org/2000/01/rdf-schema#label");
770        let entity = ProvEntity::with_attributes(
771            nn("http://example.org/data1"),
772            vec![(label_pred, Object::Literal(Literal::new("Dataset 1")))],
773        );
774        let triples = entity.to_triples();
775        assert_eq!(triples.len(), 2); // type + label
776    }
777
778    #[test]
779    fn test_entity_attributes_are_emitted() {
780        let pred = nn("http://example.org/customPred");
781        let entity = ProvEntity::with_attributes(
782            nn("http://example.org/data1"),
783            vec![(pred.clone(), Object::Literal(Literal::new("custom value")))],
784        );
785        let triples = entity.to_triples();
786        assert!(triples.iter().any(|t| {
787            matches!(t.predicate(), Predicate::NamedNode(p) if p.as_str() == pred.as_str())
788        }));
789    }
790
791    #[test]
792    fn test_entity_iri_preserved() {
793        let iri = "http://example.org/myentity";
794        let entity = ProvEntity::new(nn(iri));
795        assert_eq!(entity.iri.as_str(), iri);
796    }
797
798    #[test]
799    fn test_entity_iri_is_subject_in_triples() {
800        let iri = "http://example.org/myentity";
801        let entity = ProvEntity::new(nn(iri));
802        let triples = entity.to_triples();
803        for triple in &triples {
804            assert!(
805                matches!(triple.subject(), Subject::NamedNode(s) if s.as_str() == iri),
806                "entity IRI must be subject of all its triples"
807            );
808        }
809    }
810
811    // ── ProvActivity ───────────────────────────────────────────────────────
812
813    #[test]
814    fn test_activity_new_has_type_triple() {
815        let activity = ProvActivity::new(nn("http://example.org/query1"));
816        let triples = activity.to_triples();
817        assert!(
818            triples.iter().any(|t| {
819                matches!(t.object(), Object::NamedNode(o) if o.as_str().contains("Activity"))
820            }),
821            "activity must have rdf:type prov:Activity triple"
822        );
823    }
824
825    #[test]
826    fn test_activity_with_start_time() {
827        let activity = ProvActivity::with_times(
828            nn("http://example.org/query1"),
829            Some("2026-02-24T10:00:00Z".to_string()),
830            None,
831            vec![],
832        );
833        let triples = activity.to_triples();
834        assert!(triples.iter().any(|t| {
835            matches!(t.predicate(), Predicate::NamedNode(p) if p.as_str().contains("startedAtTime"))
836        }));
837    }
838
839    #[test]
840    fn test_activity_with_end_time() {
841        let activity = ProvActivity::with_times(
842            nn("http://example.org/query1"),
843            None,
844            Some("2026-02-24T10:05:00Z".to_string()),
845            vec![],
846        );
847        let triples = activity.to_triples();
848        assert!(triples.iter().any(|t| {
849            matches!(t.predicate(), Predicate::NamedNode(p) if p.as_str().contains("endedAtTime"))
850        }));
851    }
852
853    #[test]
854    fn test_activity_with_both_times() {
855        let activity = ProvActivity::with_times(
856            nn("http://example.org/query1"),
857            Some("2026-02-24T10:00:00Z".to_string()),
858            Some("2026-02-24T10:05:00Z".to_string()),
859            vec![],
860        );
861        let triples = activity.to_triples();
862        // type + startedAt + endedAt = 3
863        assert_eq!(triples.len(), 3);
864    }
865
866    #[test]
867    fn test_activity_no_times() {
868        let activity = ProvActivity::new(nn("http://example.org/query1"));
869        // Only type triple
870        assert_eq!(activity.to_triples().len(), 1);
871    }
872
873    #[test]
874    fn test_activity_with_attributes() {
875        let activity = ProvActivity::with_times(
876            nn("http://example.org/query1"),
877            None,
878            None,
879            vec![(
880                nn("http://example.org/desc"),
881                Object::Literal(Literal::new("SPARQL query")),
882            )],
883        );
884        let triples = activity.to_triples();
885        // type + desc attribute
886        assert_eq!(triples.len(), 2);
887    }
888
889    #[test]
890    fn test_activity_iri_is_subject() {
891        let iri = "http://example.org/query1";
892        let activity = ProvActivity::new(nn(iri));
893        let triples = activity.to_triples();
894        assert!(triples
895            .iter()
896            .all(|t| { matches!(t.subject(), Subject::NamedNode(s) if s.as_str() == iri) }));
897    }
898
899    // ── ProvAgent ──────────────────────────────────────────────────────────
900
901    #[test]
902    fn test_agent_new_has_type_agent_triple() {
903        let agent = ProvAgent::new(nn("http://example.org/oxirs"), AgentType::SoftwareAgent);
904        let triples = agent.to_triples();
905        assert!(triples.iter().any(|t| {
906            matches!(t.object(), Object::NamedNode(o) if o.as_str() == format!("{PROV_NS}Agent"))
907        }));
908    }
909
910    #[test]
911    fn test_agent_software_type_triple() {
912        let agent = ProvAgent::new(nn("http://example.org/oxirs"), AgentType::SoftwareAgent);
913        let triples = agent.to_triples();
914        assert!(triples.iter().any(|t| {
915            matches!(t.object(), Object::NamedNode(o) if o.as_str() == format!("{PROV_NS}SoftwareAgent"))
916        }));
917    }
918
919    #[test]
920    fn test_agent_person_type_triple() {
921        let agent = ProvAgent::new(nn("http://example.org/alice"), AgentType::Person);
922        let triples = agent.to_triples();
923        assert!(triples.iter().any(|t| {
924            matches!(t.object(), Object::NamedNode(o) if o.as_str().contains("Person"))
925        }));
926    }
927
928    #[test]
929    fn test_agent_organization_type_triple() {
930        let agent = ProvAgent::new(nn("http://example.org/acme"), AgentType::Organization);
931        let triples = agent.to_triples();
932        assert!(triples.iter().any(|t| {
933            matches!(t.object(), Object::NamedNode(o) if o.as_str().contains("Organization"))
934        }));
935    }
936
937    #[test]
938    fn test_agent_with_attributes() {
939        let agent = ProvAgent::with_attributes(
940            nn("http://example.org/oxirs"),
941            AgentType::SoftwareAgent,
942            vec![(
943                nn("http://example.org/version"),
944                Object::Literal(Literal::new("0.2.0")),
945            )],
946        );
947        let triples = agent.to_triples();
948        // type prov:Agent + type SoftwareAgent + version attr = 3
949        assert_eq!(triples.len(), 3);
950    }
951
952    #[test]
953    fn test_agent_iri_is_subject_in_triples() {
954        let iri = "http://example.org/myagent";
955        let agent = ProvAgent::new(nn(iri), AgentType::Organization);
956        let triples = agent.to_triples();
957        for triple in &triples {
958            assert!(
959                matches!(triple.subject(), Subject::NamedNode(s) if s.as_str() == iri),
960                "agent IRI must be subject of all its triples"
961            );
962        }
963    }
964
965    // ── ProvRelationKind ───────────────────────────────────────────────────
966
967    #[test]
968    fn test_relation_kind_was_generated_by_predicate() {
969        assert!(ProvRelationKind::WasGeneratedBy
970            .as_predicate()
971            .as_str()
972            .contains("wasGeneratedBy"));
973    }
974
975    #[test]
976    fn test_relation_kind_was_derived_from_predicate() {
977        assert!(ProvRelationKind::WasDerivedFrom
978            .as_predicate()
979            .as_str()
980            .contains("wasDerivedFrom"));
981    }
982
983    #[test]
984    fn test_relation_kind_was_attributed_to_predicate() {
985        assert!(ProvRelationKind::WasAttributedTo
986            .as_predicate()
987            .as_str()
988            .contains("wasAttributedTo"));
989    }
990
991    #[test]
992    fn test_relation_kind_used_predicate() {
993        assert!(ProvRelationKind::Used
994            .as_predicate()
995            .as_str()
996            .contains("used"));
997    }
998
999    #[test]
1000    fn test_relation_kind_was_associated_with_predicate() {
1001        assert!(ProvRelationKind::WasAssociatedWith
1002            .as_predicate()
1003            .as_str()
1004            .contains("wasAssociatedWith"));
1005    }
1006
1007    #[test]
1008    fn test_relation_kind_was_informed_by_predicate() {
1009        assert!(ProvRelationKind::WasInformedBy
1010            .as_predicate()
1011            .as_str()
1012            .contains("wasInformedBy"));
1013    }
1014
1015    #[test]
1016    fn test_relation_kind_acted_on_behalf_of_predicate() {
1017        assert!(ProvRelationKind::ActedOnBehalfOf
1018            .as_predicate()
1019            .as_str()
1020            .contains("actedOnBehalfOf"));
1021    }
1022
1023    #[test]
1024    fn test_all_seven_relation_kinds_produce_distinct_predicates() {
1025        let kinds = [
1026            ProvRelationKind::WasGeneratedBy,
1027            ProvRelationKind::WasDerivedFrom,
1028            ProvRelationKind::WasAttributedTo,
1029            ProvRelationKind::Used,
1030            ProvRelationKind::WasAssociatedWith,
1031            ProvRelationKind::WasInformedBy,
1032            ProvRelationKind::ActedOnBehalfOf,
1033        ];
1034        let predicates: Vec<String> = kinds
1035            .iter()
1036            .map(|k| k.as_predicate().as_str().to_string())
1037            .collect();
1038        let unique: std::collections::HashSet<_> = predicates.iter().collect();
1039        assert_eq!(
1040            unique.len(),
1041            7,
1042            "all 7 relation kinds must have unique predicates"
1043        );
1044    }
1045
1046    // ── ProvRelation ───────────────────────────────────────────────────────
1047
1048    #[test]
1049    fn test_relation_to_triple_correct_predicate() {
1050        let relation = ProvRelation::new(
1051            ProvRelationKind::WasGeneratedBy,
1052            nn("http://example.org/result"),
1053            nn("http://example.org/query"),
1054        );
1055        let triple = relation.to_triple();
1056        assert!(
1057            matches!(triple.predicate(), Predicate::NamedNode(p) if p.as_str().contains("wasGeneratedBy"))
1058        );
1059    }
1060
1061    #[test]
1062    fn test_relation_to_triple_correct_subject() {
1063        let relation = ProvRelation::new(
1064            ProvRelationKind::Used,
1065            nn("http://example.org/query"),
1066            nn("http://example.org/input"),
1067        );
1068        let triple = relation.to_triple();
1069        assert!(
1070            matches!(triple.subject(), Subject::NamedNode(s) if s.as_str() == "http://example.org/query")
1071        );
1072    }
1073
1074    #[test]
1075    fn test_relation_to_triple_correct_object() {
1076        let relation = ProvRelation::new(
1077            ProvRelationKind::Used,
1078            nn("http://example.org/query"),
1079            nn("http://example.org/input"),
1080        );
1081        let triple = relation.to_triple();
1082        assert!(
1083            matches!(triple.object(), Object::NamedNode(o) if o.as_str() == "http://example.org/input")
1084        );
1085    }
1086
1087    #[test]
1088    fn test_relation_with_qualifier() {
1089        let relation = ProvRelation::with_qualifier(
1090            ProvRelationKind::WasGeneratedBy,
1091            nn("http://example.org/result"),
1092            nn("http://example.org/query"),
1093            nn("http://example.org/qual1"),
1094        );
1095        assert!(relation.qualifier.is_some());
1096        assert_eq!(
1097            relation
1098                .qualifier
1099                .as_ref()
1100                .expect("operation should succeed")
1101                .as_str(),
1102            "http://example.org/qual1"
1103        );
1104    }
1105
1106    #[test]
1107    fn test_relation_no_qualifier_by_default() {
1108        let relation = ProvRelation::new(
1109            ProvRelationKind::WasInformedBy,
1110            nn("http://example.org/q2"),
1111            nn("http://example.org/q1"),
1112        );
1113        assert!(relation.qualifier.is_none());
1114    }
1115
1116    // ── ProvBundle ─────────────────────────────────────────────────────────
1117
1118    #[test]
1119    fn test_bundle_new_is_empty() {
1120        let bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1121        assert!(bundle.entities.is_empty());
1122        assert!(bundle.activities.is_empty());
1123        assert!(bundle.agents.is_empty());
1124        assert!(bundle.relations.is_empty());
1125    }
1126
1127    #[test]
1128    fn test_bundle_to_rdf_includes_bundle_type() {
1129        let bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1130        let triples = bundle.to_rdf();
1131        assert!(triples.iter().any(|t| {
1132            matches!(t.object(), Object::NamedNode(o) if o.as_str().contains("Bundle"))
1133        }));
1134    }
1135
1136    #[test]
1137    fn test_bundle_add_entity() {
1138        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1139        bundle.add_entity(ProvEntity::new(nn("http://example.org/e1")));
1140        assert_eq!(bundle.entities.len(), 1);
1141    }
1142
1143    #[test]
1144    fn test_bundle_add_activity() {
1145        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1146        bundle.add_activity(ProvActivity::new(nn("http://example.org/a1")));
1147        assert_eq!(bundle.activities.len(), 1);
1148    }
1149
1150    #[test]
1151    fn test_bundle_add_agent() {
1152        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1153        bundle.add_agent(ProvAgent::new(
1154            nn("http://example.org/ag1"),
1155            AgentType::SoftwareAgent,
1156        ));
1157        assert_eq!(bundle.agents.len(), 1);
1158    }
1159
1160    #[test]
1161    fn test_bundle_add_relation() {
1162        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1163        bundle.add_relation(ProvRelation::new(
1164            ProvRelationKind::WasGeneratedBy,
1165            nn("http://example.org/r"),
1166            nn("http://example.org/a"),
1167        ));
1168        assert_eq!(bundle.relations.len(), 1);
1169    }
1170
1171    #[test]
1172    fn test_bundle_to_rdf_contains_entity_type() {
1173        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1174        bundle.add_entity(ProvEntity::new(nn("http://example.org/e1")));
1175        let triples = bundle.to_rdf();
1176        assert!(triples.iter().any(|t| {
1177            matches!(t.object(), Object::NamedNode(o) if o.as_str().contains("Entity"))
1178        }));
1179    }
1180
1181    #[test]
1182    fn test_bundle_to_rdf_contains_activity_type() {
1183        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1184        bundle.add_activity(ProvActivity::new(nn("http://example.org/a1")));
1185        let triples = bundle.to_rdf();
1186        assert!(triples.iter().any(|t| {
1187            matches!(t.object(), Object::NamedNode(o) if o.as_str().contains("Activity"))
1188        }));
1189    }
1190
1191    #[test]
1192    fn test_bundle_to_rdf_contains_agent_type() {
1193        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1194        bundle.add_agent(ProvAgent::new(
1195            nn("http://example.org/ag1"),
1196            AgentType::SoftwareAgent,
1197        ));
1198        let triples = bundle.to_rdf();
1199        assert!(triples.iter().any(|t| {
1200            matches!(t.object(), Object::NamedNode(o) if o.as_str().contains("Agent"))
1201        }));
1202    }
1203
1204    #[test]
1205    fn test_bundle_to_rdf_contains_relation_triple() {
1206        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1207        bundle.add_relation(ProvRelation::new(
1208            ProvRelationKind::WasGeneratedBy,
1209            nn("http://example.org/r"),
1210            nn("http://example.org/a"),
1211        ));
1212        let triples = bundle.to_rdf();
1213        assert!(triples.iter().any(|t| {
1214            matches!(t.predicate(), Predicate::NamedNode(p) if p.as_str().contains("wasGeneratedBy"))
1215        }));
1216    }
1217
1218    #[test]
1219    fn test_bundle_full_to_rdf_triple_count() {
1220        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1221        bundle.add_entity(ProvEntity::new(nn("http://example.org/e1")));
1222        bundle.add_activity(ProvActivity::new(nn("http://example.org/a1")));
1223        bundle.add_agent(ProvAgent::new(
1224            nn("http://example.org/ag1"),
1225            AgentType::Person,
1226        ));
1227        bundle.add_relation(ProvRelation::new(
1228            ProvRelationKind::WasGeneratedBy,
1229            nn("http://example.org/e1"),
1230            nn("http://example.org/a1"),
1231        ));
1232        let triples = bundle.to_rdf();
1233        // bundle type(1) + entity type(1) + activity type(1) + agent(2) + relation(1) = 6
1234        assert!(
1235            triples.len() >= 6,
1236            "expected at least 6 triples, got {}",
1237            triples.len()
1238        );
1239    }
1240
1241    // ── ProvBundle::from_rdf ───────────────────────────────────────────────
1242
1243    #[test]
1244    fn test_bundle_round_trip_entity() {
1245        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1246        bundle.add_entity(ProvEntity::new(nn("http://example.org/e1")));
1247        let triples = bundle.to_rdf();
1248        let restored = ProvBundle::from_rdf(&triples).expect("from_rdf should succeed");
1249        assert_eq!(restored.entities.len(), 1);
1250    }
1251
1252    #[test]
1253    fn test_bundle_round_trip_activity() {
1254        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1255        bundle.add_activity(ProvActivity::with_times(
1256            nn("http://example.org/a1"),
1257            Some("2026-02-24T10:00:00Z".to_string()),
1258            Some("2026-02-24T10:05:00Z".to_string()),
1259            vec![],
1260        ));
1261        let triples = bundle.to_rdf();
1262        let restored = ProvBundle::from_rdf(&triples).expect("from_rdf should succeed");
1263        assert_eq!(restored.activities.len(), 1);
1264        assert_eq!(
1265            restored.activities[0].started_at.as_deref(),
1266            Some("2026-02-24T10:00:00Z")
1267        );
1268        assert_eq!(
1269            restored.activities[0].ended_at.as_deref(),
1270            Some("2026-02-24T10:05:00Z")
1271        );
1272    }
1273
1274    #[test]
1275    fn test_bundle_round_trip_agent_software() {
1276        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1277        bundle.add_agent(ProvAgent::new(
1278            nn("http://example.org/ag1"),
1279            AgentType::SoftwareAgent,
1280        ));
1281        let triples = bundle.to_rdf();
1282        let restored = ProvBundle::from_rdf(&triples).expect("from_rdf should succeed");
1283        assert_eq!(restored.agents.len(), 1);
1284        assert_eq!(restored.agents[0].agent_type, AgentType::SoftwareAgent);
1285    }
1286
1287    #[test]
1288    fn test_bundle_round_trip_agent_person() {
1289        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1290        bundle.add_agent(ProvAgent::new(
1291            nn("http://example.org/alice"),
1292            AgentType::Person,
1293        ));
1294        let triples = bundle.to_rdf();
1295        let restored = ProvBundle::from_rdf(&triples).expect("from_rdf should succeed");
1296        assert_eq!(restored.agents.len(), 1);
1297        assert_eq!(restored.agents[0].agent_type, AgentType::Person);
1298    }
1299
1300    #[test]
1301    fn test_bundle_round_trip_agent_organization() {
1302        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1303        bundle.add_agent(ProvAgent::new(
1304            nn("http://example.org/acme"),
1305            AgentType::Organization,
1306        ));
1307        let triples = bundle.to_rdf();
1308        let restored = ProvBundle::from_rdf(&triples).expect("from_rdf should succeed");
1309        assert_eq!(restored.agents.len(), 1);
1310        assert_eq!(restored.agents[0].agent_type, AgentType::Organization);
1311    }
1312
1313    #[test]
1314    fn test_bundle_round_trip_relation() {
1315        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1316        bundle.add_entity(ProvEntity::new(nn("http://example.org/e1")));
1317        bundle.add_activity(ProvActivity::new(nn("http://example.org/a1")));
1318        bundle.add_relation(ProvRelation::new(
1319            ProvRelationKind::WasGeneratedBy,
1320            nn("http://example.org/e1"),
1321            nn("http://example.org/a1"),
1322        ));
1323        let triples = bundle.to_rdf();
1324        let restored = ProvBundle::from_rdf(&triples).expect("from_rdf should succeed");
1325        assert_eq!(restored.relations.len(), 1);
1326        assert_eq!(restored.relations[0].kind, ProvRelationKind::WasGeneratedBy);
1327    }
1328
1329    #[test]
1330    fn test_bundle_from_rdf_missing_bundle_declaration() {
1331        // Triples without prov:Bundle declaration should fail
1332        let triples = vec![Triple::new(
1333            nn("http://example.org/e1"),
1334            nn("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
1335            nn("http://www.w3.org/ns/prov#Entity"),
1336        )];
1337        let result = ProvBundle::from_rdf(&triples);
1338        assert!(result.is_err());
1339    }
1340
1341    #[test]
1342    fn test_bundle_from_rdf_bundle_iri_preserved() {
1343        let bundle = ProvBundle::new(nn("http://example.org/mybundle"));
1344        let triples = bundle.to_rdf();
1345        let restored = ProvBundle::from_rdf(&triples).expect("from_rdf should succeed");
1346        assert_eq!(restored.iri.as_str(), "http://example.org/mybundle");
1347    }
1348
1349    #[test]
1350    fn test_bundle_round_trip_all_relation_kinds() {
1351        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1352        let kinds = vec![
1353            ProvRelationKind::WasGeneratedBy,
1354            ProvRelationKind::WasDerivedFrom,
1355            ProvRelationKind::WasAttributedTo,
1356            ProvRelationKind::Used,
1357            ProvRelationKind::WasAssociatedWith,
1358            ProvRelationKind::WasInformedBy,
1359            ProvRelationKind::ActedOnBehalfOf,
1360        ];
1361        for kind in &kinds {
1362            bundle.add_relation(ProvRelation::new(
1363                kind.clone(),
1364                nn("http://example.org/s"),
1365                nn("http://example.org/o"),
1366            ));
1367        }
1368        let triples = bundle.to_rdf();
1369        let restored = ProvBundle::from_rdf(&triples).expect("from_rdf should succeed");
1370        assert_eq!(restored.relations.len(), kinds.len());
1371    }
1372
1373    #[test]
1374    fn test_bundle_multiple_entities() {
1375        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1376        for i in 0..5 {
1377            bundle.add_entity(ProvEntity::new(nn(&format!("http://example.org/e{i}"))));
1378        }
1379        let triples = bundle.to_rdf();
1380        let restored = ProvBundle::from_rdf(&triples).expect("from_rdf should succeed");
1381        assert_eq!(restored.entities.len(), 5);
1382    }
1383
1384    #[test]
1385    fn test_bundle_multiple_activities() {
1386        let mut bundle = ProvBundle::new(nn("http://example.org/bundle1"));
1387        for i in 0..3 {
1388            bundle.add_activity(ProvActivity::new(nn(&format!("http://example.org/a{i}"))));
1389        }
1390        let triples = bundle.to_rdf();
1391        let restored = ProvBundle::from_rdf(&triples).expect("from_rdf should succeed");
1392        assert_eq!(restored.activities.len(), 3);
1393    }
1394
1395    // ── QueryProvenanceTracker ─────────────────────────────────────────────
1396
1397    #[test]
1398    fn test_query_tracker_to_bundle_has_entities() {
1399        let tracker = QueryProvenanceTracker::new(
1400            nn("http://example.org/query1"),
1401            "2026-02-24T10:00:00Z".to_string(),
1402            nn("http://example.org/oxirs"),
1403            nn("http://example.org/dataset"),
1404            nn("http://example.org/result"),
1405        );
1406        let bundle = tracker.to_bundle();
1407        assert_eq!(bundle.entities.len(), 2);
1408    }
1409
1410    #[test]
1411    fn test_query_tracker_to_bundle_has_activity() {
1412        let tracker = QueryProvenanceTracker::new(
1413            nn("http://example.org/query1"),
1414            "2026-02-24T10:00:00Z".to_string(),
1415            nn("http://example.org/oxirs"),
1416            nn("http://example.org/dataset"),
1417            nn("http://example.org/result"),
1418        );
1419        let bundle = tracker.to_bundle();
1420        assert_eq!(bundle.activities.len(), 1);
1421    }
1422
1423    #[test]
1424    fn test_query_tracker_to_bundle_has_software_agent() {
1425        let tracker = QueryProvenanceTracker::new(
1426            nn("http://example.org/query1"),
1427            "2026-02-24T10:00:00Z".to_string(),
1428            nn("http://example.org/oxirs"),
1429            nn("http://example.org/dataset"),
1430            nn("http://example.org/result"),
1431        );
1432        let bundle = tracker.to_bundle();
1433        assert_eq!(bundle.agents.len(), 1);
1434        assert_eq!(bundle.agents[0].agent_type, AgentType::SoftwareAgent);
1435    }
1436
1437    #[test]
1438    fn test_query_tracker_to_bundle_has_four_relations() {
1439        let tracker = QueryProvenanceTracker::new(
1440            nn("http://example.org/query1"),
1441            "2026-02-24T10:00:00Z".to_string(),
1442            nn("http://example.org/oxirs"),
1443            nn("http://example.org/dataset"),
1444            nn("http://example.org/result"),
1445        );
1446        let bundle = tracker.to_bundle();
1447        assert_eq!(bundle.relations.len(), 4);
1448    }
1449
1450    #[test]
1451    fn test_query_tracker_to_bundle_was_generated_by() {
1452        let tracker = QueryProvenanceTracker::new(
1453            nn("http://example.org/query1"),
1454            "2026-02-24T10:00:00Z".to_string(),
1455            nn("http://example.org/oxirs"),
1456            nn("http://example.org/dataset"),
1457            nn("http://example.org/result"),
1458        );
1459        let bundle = tracker.to_bundle();
1460        assert!(bundle
1461            .relations
1462            .iter()
1463            .any(|r| r.kind == ProvRelationKind::WasGeneratedBy));
1464    }
1465
1466    #[test]
1467    fn test_query_tracker_to_bundle_used() {
1468        let tracker = QueryProvenanceTracker::new(
1469            nn("http://example.org/query1"),
1470            "2026-02-24T10:00:00Z".to_string(),
1471            nn("http://example.org/oxirs"),
1472            nn("http://example.org/dataset"),
1473            nn("http://example.org/result"),
1474        );
1475        let bundle = tracker.to_bundle();
1476        assert!(bundle
1477            .relations
1478            .iter()
1479            .any(|r| r.kind == ProvRelationKind::Used));
1480    }
1481
1482    #[test]
1483    fn test_query_tracker_to_bundle_was_associated_with() {
1484        let tracker = QueryProvenanceTracker::new(
1485            nn("http://example.org/query1"),
1486            "2026-02-24T10:00:00Z".to_string(),
1487            nn("http://example.org/oxirs"),
1488            nn("http://example.org/dataset"),
1489            nn("http://example.org/result"),
1490        );
1491        let bundle = tracker.to_bundle();
1492        assert!(bundle
1493            .relations
1494            .iter()
1495            .any(|r| r.kind == ProvRelationKind::WasAssociatedWith));
1496    }
1497
1498    #[test]
1499    fn test_query_tracker_to_bundle_was_attributed_to() {
1500        let tracker = QueryProvenanceTracker::new(
1501            nn("http://example.org/query1"),
1502            "2026-02-24T10:00:00Z".to_string(),
1503            nn("http://example.org/oxirs"),
1504            nn("http://example.org/dataset"),
1505            nn("http://example.org/result"),
1506        );
1507        let bundle = tracker.to_bundle();
1508        assert!(bundle
1509            .relations
1510            .iter()
1511            .any(|r| r.kind == ProvRelationKind::WasAttributedTo));
1512    }
1513
1514    #[test]
1515    fn test_query_tracker_with_query_text() {
1516        let tracker = QueryProvenanceTracker::new(
1517            nn("http://example.org/query1"),
1518            "2026-02-24T10:00:00Z".to_string(),
1519            nn("http://example.org/oxirs"),
1520            nn("http://example.org/dataset"),
1521            nn("http://example.org/result"),
1522        )
1523        .with_query_text("SELECT * WHERE { ?s ?p ?o }");
1524        assert!(tracker.query_text.is_some());
1525        let bundle = tracker.to_bundle();
1526        assert!(bundle.activities[0]
1527            .attributes
1528            .iter()
1529            .any(|(_, v)| { matches!(v, Object::Literal(l) if l.value().contains("SELECT")) }));
1530    }
1531
1532    #[test]
1533    fn test_query_tracker_to_bundle_to_rdf() {
1534        let tracker = QueryProvenanceTracker::new(
1535            nn("http://example.org/query1"),
1536            "2026-02-24T10:00:00Z".to_string(),
1537            nn("http://example.org/oxirs"),
1538            nn("http://example.org/dataset"),
1539            nn("http://example.org/result"),
1540        );
1541        let bundle = tracker.to_bundle();
1542        let triples = bundle.to_rdf();
1543        assert!(triples.len() > 5);
1544    }
1545
1546    #[test]
1547    fn test_query_tracker_executed_at_in_activity() {
1548        let tracker = QueryProvenanceTracker::new(
1549            nn("http://example.org/query1"),
1550            "2026-02-24T10:00:00Z".to_string(),
1551            nn("http://example.org/oxirs"),
1552            nn("http://example.org/dataset"),
1553            nn("http://example.org/result"),
1554        );
1555        let bundle = tracker.to_bundle();
1556        let activity = &bundle.activities[0];
1557        assert_eq!(activity.started_at.as_deref(), Some("2026-02-24T10:00:00Z"));
1558    }
1559
1560    // ── PROV-O namespace constants ─────────────────────────────────────────
1561
1562    #[test]
1563    fn test_prov_ns_constant() {
1564        assert_eq!(PROV_NS, "http://www.w3.org/ns/prov#");
1565    }
1566
1567    #[test]
1568    fn test_xsd_ns_constant() {
1569        assert_eq!(XSD_NS, "http://www.w3.org/2001/XMLSchema#");
1570    }
1571
1572    #[test]
1573    fn test_rdf_ns_constant() {
1574        assert_eq!(RDF_NS, "http://www.w3.org/1999/02/22-rdf-syntax-ns#");
1575    }
1576}